Merge remote-tracking branch 'upstream/main' into fix/drone-airspace

Resolve the CURRENT-STATE.md layer table by keeping main's realigned
table and adding this layer's row. Main added an Arabic locale, so
translate the 12 layer strings and list ar.json in the drone-airspace
package.
This commit is contained in:
Matt Van Horn committed 2026-11-02 16:10:41 -08:00
commit 351a47ee22
1060 files changed
+69675 -20451

No files matched your search

+26
View File
@@ -265,6 +265,32 @@ AISSTREAM_API_KEY=
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
OVERPASS_UPSTREAMS=
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
# endpoint set here. Nominatim stays off unless NOMINATIM_URL is set; Photon
# uses its public service when PHOTON_URL is unset.
# NOMINATIM_URL: search endpoint for last-resort place search and voice
# outlines (e.g. https://nominatim.example.org/search). A configured URL
# selects the endpoint explicitly. Unset, empty and invalid values disable
# Nominatim; nothing falls back to nominatim.openstreetmap.org. Use an endpoint
# you operate or are authorized to use. Answers are cached (outlines 90 days,
# places 30 days, no-results 24 h in .gev-cache/nominatim).
# Explicit public-service configuration retains per-install pacing and caps;
# these do not establish an application-wide allowance or authorize public use.
# NOMINATIM_URL=
# Public-instance requests per UTC day, outlines and search together. Not
# applied to your own NOMINATIM_URL.
# NOMINATIM_DAILY_CAP=50
# Public-instance pacing is shared through .gev-cache/nominatim/state.json. If
# the server stopped mid-request (or its disk filled), it leaves a
# state.json.dispatch.* marker and refuses public lookups ("unfinished durable
# Nominatim dispatch") for ten minutes rather than risk breaking the spacing;
# the next lookup after that removes the marker and resumes with fresh spacing.
# PHOTON_URL: keyless place search endpoint used by the browser
# (e.g. https://photon.example.org/api/). Unset uses photon.komoot.io; set it
# empty to disable. Only https endpoints are accepted (the page's
# Content-Security-Policy allows no plain-http origin); any other value disables it.
# PHOTON_URL=
# Optional: who may frame the app in embed mode (?embed=1), as CSP
# frame-ancestors sources such as "https://example.com", or * for any page.
# Unset, no page may frame it. Other pages of the app can never be framed.
+1 -1
View File
@@ -40,7 +40,7 @@ body:
attributes:
label: OS, Node version, browser
description: Include the versions you can find. Write "unknown" for anything unavailable.
placeholder: "Windows 11, Node 24.14.0, Chrome 151"
placeholder: "Windows 11, Node 24.22.2, Chrome 151"
validations:
required: true
- type: input
+2 -1
View File
@@ -34,7 +34,8 @@ If it's bigger, link to it instead.
<!--
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Audio,
and Utilities (its rows render in the separate UTILITIES panel).
If none fits, propose a new group and say why.
-->
+4 -4
View File
@@ -20,10 +20,10 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [24.14.0, 26.x]
node: [24.22.2, 26.x]
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }}
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.22.2' && '1' || '0' }}
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -76,7 +76,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.0
node-version: 24.22.2
cache: npm
- name: Install locked dependencies and headless Chrome
@@ -131,7 +131,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.0
node-version: 24.22.2
cache: npm
- name: Run the Pinokio install path
+5
View File
@@ -20,3 +20,8 @@ node_modules
.vite
.vite-*
*.min.js
# Application views, and panels lifted from them, hold the page markup
# verbatim; reflowing it changes the DOM
src/ui/views/*View.vue
src/ui/views/panels/
+7 -1
View File
@@ -2,5 +2,11 @@
"singleQuote": true,
"tabWidth": 2,
"semi": true,
"endOfLine": "lf"
"endOfLine": "lf",
"overrides": [
{
"files": "*.vue",
"options": { "htmlWhitespaceSensitivity": "strict" }
}
]
}
+85 -7
View File
@@ -4,13 +4,15 @@ Thanks for being here. God's Eye View is an open foundation for live spatial int
## Getting set up
Use Node.js 24.14.x or 26.x (also enforced by `package.json`).
Use Node.js 24 (24.14.0 or later) or 26.x (also enforced by `package.json`).
Prefer 24.22.1 or later: earlier releases lack the October 21, 2026 security
fixes, including CVE-2026-111497 (a Brotli decoder flaw).
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
nvm install 24.14.0
nvm use 24.14.0
nvm install 24.22.2
nvm use 24.22.2
npm install
npm run doctor
./scripts/dev-fresh.sh # or: npm run dev (keys are optional)
@@ -27,6 +29,25 @@ through Pinokio; the terminal path above remains the contributor path.
Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.**
### Test prerequisites
- **`npm test`** — unit tests. Runs standalone; no dev server or credentials
required. Exit code is the gate.
- **`npm run test:track`** — the 3D flight-tracking regression harness. It
drives the **real app** in headless Chromium, so it **requires a running
dev server** (default `http://localhost:4173`). To target another instance,
run `npm run test:track -- --url <app-url>` (or
`node scripts/track-regression.mjs --url <app-url>`). It does not depend on
live OpenSky / adsb.lol / AISStream (those are optional or rate-limited
locally); it installs a persistent `fetch` shim that returns synthetic
aircraft in the exact upstream payload shapes the layers parse.
- **`npm run build`** — production Vite build. No server or credentials
required; it validates that the client bundle compiles cleanly.
Each feature also has its own headless gate; see
[Feature regression gates](#feature-regression-gates) below. Docs and changelog
rules are under [Pull requests](#pull-requests).
## Checking a built app locally
Run `npm run build` followed by `npm run preview`. Preview serves the built
@@ -115,7 +136,7 @@ The highest-leverage places to jump in:
## Architecture in one minute
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
- **Vue 3 + Pinia for the interface,** [CesiumJS](https://cesium.com/platform/cesiumjs/) for the globe and [Vite](https://vitejs.dev/) to build. See [Building UI](#building-ui).
- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/<layer>.js` entries retain compatibility; new code belongs with its focused owner.
- Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers/<family>/source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details.
- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
@@ -126,9 +147,65 @@ The highest-leverage places to jump in:
- ES modules, **2-space indent, single quotes, semicolons.**
- JSDoc on exported/public functions.
- Match the surrounding code — comment density, naming, and idiom.
- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
- User-visible text goes through `t()` or `tn()`, with `msg()` marking English that is stored and translated later. Components call `t()` in their templates, so their text follows the locale; `data-i18n*` attributes are only for static page markup such as the loading screen, and `npm run i18n` rejects them in a template. `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
## Building UI
New UX elements are Vue components whose state lives in a Pinia store, and so
are existing ones when a change converts them:
- **Views are Vue components.** Application views live in `src/ui/views/` and
the components they place in `src/ui/views/panels/`; a component that
belongs to one feature sits beside its owner (`src/ui/rail/`,
`src/ui/weather/`). A view renders what its store holds and forwards
clicks and input to the store; it holds no application logic.
- **State lives in a Pinia store** in `src/ui/stores/`. The owner presents a
plain view object and publishes it to the store; the view renders it. User
actions return to the owner through handlers it connects to the store,
which replace an earlier owner's. Where several owners answer different
actions of one store, use `createOwners()` from `src/ui/stores/owners.js`:
`act(name, ...args)` calls the newest connected owner that handles `name`,
and the owner calls `detach(token)` when it stops.
- **Portable owners stay framework-free.** An owner in a portable export
imports neither Vue nor Pinia: it takes a `publish` option or offers a
`subscribe…()`, and the application shell connects it to the store.
`npm run check:boundaries` enforces this.
- **Reuse the kit first.** `src/ui/kit/` has one component per control:
`UiButton` (including pressed toggles and pressable chips), `UiSelect`,
`UiSlider`, `UiSegmented`, `UiChip` (a label, not a control), `UiBadge`,
`UiEmpty`, `UiIcon`, `UiTooltip`, `UiPanel` and `UiRail`. Use these before
writing a control. When the kit lacks something, extend a kit component or
add one that follows the kit's names, props, events and class names (see
[Component kit](docs/VUE-MIGRATION.md#component-kit)), rather than
building a one-off.
- **No hand-written DOM binding.** Don't build elements or write text,
classes or attributes from a controller to show UI state; render it from
the store. New controls take input through the store rather than through
listeners the owner adds. To render outside the view's host (the body, the
globe container, or a slot another surface opens), use `<Teleport>` to
`body`, a fixed id or a container the owner publishes; don't move nodes by
hand. What stays imperative: classes on `<body>` and `<head>`, which sit
outside Vue (`cockpit-mode`, `ui-clean-view`); focus moves; positions a
layout pass measures in the same task that writes them; and surfaces an
owner paints or plays, such as the Cesium canvas, overlay canvases and live
video. A few existing surfaces keep listeners or bindings that
[docs/VUE-MIGRATION.md](docs/VUE-MIGRATION.md) records; keep those
contracts when you change them, and don't copy them into new UI.
- **Text** goes through `t()` or `tn()` where the template renders it.
English kept in a constant or a store is marked with `msg()` and passed to
`t()` when it renders, since a `t()` at module load would fix the English.
- **Test components, not markup strings.** Mount the component over
happy-dom with `@vue/test-utils` or `createApp` and assert what it renders
from its store (see `src/testSupport/vueDom.mjs`, `src/ui/kit/*.test.mjs`
and `src/ui/views/intelHudView.test.mjs`). Run one such file with
`node --import=./build/registerVue.js --test <file>`. Don't pass happy-dom
nodes to `assert.equal`/`deepEqual`; compare them with `assertSameNode()`
from `src/testSupport/domAssert.mjs`.
[docs/VUE-MIGRATION.md](docs/VUE-MIGRATION.md) records how each surface moved
and what stays imperative, and why.
## Formatting and reusable components
Run `npm run format` before submitting changes, then `npm run format:check`
@@ -170,8 +247,9 @@ template has a section for them.
instead of attaching it.
3. **The proposed category.** Say which layer-panel group the layer belongs in.
The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today:
Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer
that isn't listed there falls under "Other layers". If none of the groups
Movement, Cameras, Infrastructure, Events, Weather and Audio, plus Utilities,
whose rows render in the separate UTILITIES panel). A layer that isn't
listed there falls under "Other layers". If none of the groups
fits, propose a new one and explain why.
4. **The data source and licence.** Name the provider and endpoint, link its
licence or terms, give the attribution shown in the app, and say whether it
+13 -1
View File
@@ -188,7 +188,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover.
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` uses the shared Nominatim forward-search queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
@@ -210,6 +211,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
@@ -306,6 +309,15 @@ the retrieval date (2026-07-30), exact download URL, license evidence, and the
deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m
Douglas-Peucker simplification, 6-decimal rounding).
### Local CCTV source packs (`config/`)
The server can read a local camera list from `CCTV_SOURCES_FILE` (default
`config/cctv_sources.austin.json`, which ships empty) and inline JSON from
`CCTV_SOURCES_JSON`; the two are merged, and inline entries win on a duplicate
id. A non-empty local pack replaces the live open-data camera packs unless
`CCTV_FORCE_AUSTIN=1` is set. The packs under `config/` show the schema, and a
pack's cameras keep their own source's license and attribution.
---
## Weather sources
+1 -1
View File
@@ -4,7 +4,7 @@
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
# is nothing else to containerise. See compose.yaml for the usual entry point.
# Same Node as CI (package.json engines, .github/workflows), pinned by digest.
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
FROM node:24.22.2-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
+43 -7
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start
**Start without an account or API keys.** Both paths open the same app with
**Start without an account or API keys.** Every path opens the same app with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
@@ -129,8 +129,9 @@ hardware; it does not establish a minimum GPU requirement.
### Path 2 — Terminal / coding agent
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
Node 25, which is end-of-life.
Use **Node.js 24.x (24.14.0 or later) or 26.x**; 24.22.1 or later (26.12.1 or
later on 26) includes the October 21, 2026 security fixes, among them
CVE-2026-111497. The setup doctor warns about Node 25, which is end-of-life.
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
@@ -233,6 +234,41 @@ the container.
</details>
### Path 4 — GitHub Codespaces (cloud, no local install)
Runs the same dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core
machine. Org and Enterprise plans include no free quota; current terms and
quotas apply.
1. Fork this repository, then open **your fork** on GitHub.
2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**.
3. In the codespace terminal:
```bash
nvm install 24 && nvm use 24
npm ci
npm run doctor
GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev
```
4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in
the browser. The port is private by default, so the URL asks for your GitHub
login. Keep it private: a public port lets anyone use the app and the keys
in your `.env`.
`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only
the hostnames it trusts, and a forwarded port arrives on
`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the
page is `Blocked request. This host is not allowed.` Leading-dot wildcards are
ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings does not open on the forwarded URL, so put keys in the workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
threat model.
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
@@ -357,7 +393,7 @@ camera input stops rotation.
> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
**✍️ Or draw it yourself** — UTILITIES ▸ **Draw**: pick Area, Line or Pin and a colour, click the vertices on the real world, double-click to finish, then name, recolour, hide or fly to each shape from its row. Same whiteboard, same persistence, no microphone needed.
![Zilker Park and Lady Bird Lake drawing onto the 3D city as persistent vector annotations, by voice](docs/media/01-voice-annotate-zilker.gif)
@@ -416,7 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities.
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
**The basemap ladder — what each tier buys you:**
@@ -492,12 +528,12 @@ How the globe handles live data:
- **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating.
- **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls.
- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider).
- **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
- **A small stack.** **Vue 3** and **Pinia** for the interface, **CesiumJS** for the globe and **Vite** to build it — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on.
```
src/
├── main.js # Bootstrap: Google 3D tiles, layer registration
├── ui/ # Runtime UI — panels, HUD, styles, controls
├── ui/ # Runtime UI — Vue views, Pinia stores, kit, controllers, styles
├── hud.js # Intelligence HUD + AI scene summary
├── keySetup.js # POWER UP panel — in-app provider keys (dev server only)
├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks
-40
View File
@@ -1,40 +0,0 @@
import { readFileSync } from 'node:fs';
export const APPLICATION_TEMPLATES = Object.freeze([
'scene-chrome',
'cockpit',
'display-controls',
'command-dock',
'layer-panels',
'context',
'welcome',
'provider-settings',
'hud-loading',
]);
const allowed = new Set(APPLICATION_TEMPLATES);
/** Expand only known component templates; markers cannot name filesystem paths. */
export function expandApplicationHtml(html) {
return html.replace(
/^[ \t]*<!-- gev:template ([^\s]+) -->\r?\n?/gm,
(_, name) => {
if (!allowed.has(name))
throw new Error(`Unknown application template: ${name}`);
return readFileSync(
new URL(`../src/ui/templates/${name}.html`, import.meta.url),
'utf8',
);
},
);
}
/** Assemble static application markup before Vite processes scripts and assets. */
export function applicationHtmlPlugin() {
return {
name: 'application-component-templates',
transformIndexHtml: {
order: 'pre',
handler: expandApplicationHtml,
},
};
}
+8
View File
@@ -0,0 +1,8 @@
import { registerHooks } from 'node:module';
import { load } from './vueLoader.js';
// Lets Node import single-file components: `node --import=<this file>`.
// Hosts reach it as `<core package>/build/register-vue`. The hooks run on the
// loading thread: off-thread hooks (module.register) would add a round trip
// to every module a test imports, not only the components.
registerHooks({ load });
+44 -2
View File
@@ -1,6 +1,7 @@
import { fileURLToPath } from 'node:url';
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium';
import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
@@ -24,6 +25,32 @@ export function escapeFsDenyPath(path) {
const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT);
/**
* Browser-safe form of the optional Photon endpoint build input. Only https
* is accepted: the page's connect-src allows no plain-http origin, so an
* http endpoint would only fail later, silently, in the browser.
*/
function photonEndpointSetting(value) {
if (value === undefined) return undefined;
const text = String(value ?? '').trim();
if (!text || ['off', 'none', 'disabled'].includes(text.toLowerCase()))
return null;
try {
const url = new URL(text);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash
)
return null;
return url.href;
} catch {
return null;
}
}
/**
* Content-Security-Policy for every document the dev/preview server serves.
* No inline script is permitted, and the only foreign script origins are the
@@ -142,12 +169,14 @@ export function createBrowserViteConfig({
publicDir,
googleApiKey,
cesiumToken,
photonUrl,
mapillaryToken,
host = 'localhost',
port = 4173,
allowedHosts = DEFAULT_ALLOWED_HOSTS,
command,
} = {}) {
const photonSetting = photonEndpointSetting(photonUrl);
return {
plugins: [
// First, so every API response, including a Host refusal, carries them.
@@ -155,7 +184,8 @@ export function createBrowserViteConfig({
// Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(),
cesium(),
applicationHtmlPlugin(),
// Application views are Vue single-file components.
vue(VUE_PLUGIN_OPTIONS),
...plugins,
embedFramingPlugin(),
panelBuildPlugin(),
@@ -209,8 +239,20 @@ export function createBrowserViteConfig({
// framing + CSP hardening (one constant, no drift).
preview: { headers: BROWSER_HEADERS },
define: {
// vue-i18n holds the locale and catalogs; Core formats the messages
// (src/ui/i18n.js), so the build drops vue-i18n's message compiler.
__VUE_I18N_FULL_INSTALL__: 'false',
__VUE_I18N_LEGACY_API__: 'false',
__INTLIFY_PROD_DEVTOOLS__: 'false',
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
// Only expose a validated URL or the explicit disabled sentinel.
...(photonSetting === undefined
? {}
: {
'import.meta.env.PHOTON_URL': JSON.stringify(photonSetting),
}),
// Mapillary client tokens are public by design (see SECURITY.md).
'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify(
mapillaryToken ?? '',
+170
View File
@@ -0,0 +1,170 @@
import { createHash, randomBytes } from 'node:crypto';
import {
lstatSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js';
const require = createRequire(import.meta.url);
// The compiler loads on the first component that is not cached, so a test
// process that imports no views, or only cached ones, never pays for it.
let compiler = null;
const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc'));
/**
* Compile a single-file component to an ES module for Node, so unit tests (the
* application's and a host's) can import components without a bundler.
* Styles are dropped: tests read the global stylesheet separately.
*/
export function compileVueModule(source, filename) {
const { compileScript, compileTemplate, parse } = sfcCompiler();
// The template is parsed with the SFC; parse it as the browser build does.
const { descriptor, errors } = parse(source, {
filename,
templateParseOptions: VUE_TEMPLATE_OPTIONS.compilerOptions,
});
if (errors.length) throw errors[0];
const id = filename;
if (descriptor.scriptSetup) {
return compileScript(descriptor, {
id,
inlineTemplate: true,
templateOptions: VUE_TEMPLATE_OPTIONS,
}).content;
}
const script = descriptor.script
? compileScript(descriptor, { id, genDefaultAs: '__sfc__' }).content
: 'const __sfc__ = {};';
let template = '';
if (descriptor.template) {
const compiled = compileTemplate({
source: descriptor.template.content,
filename,
id,
...VUE_TEMPLATE_OPTIONS,
});
if (compiled.errors.length) throw compiled.errors[0];
template = compiled.code;
}
return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`;
}
// The loader's own code is part of every key, so a change to how components
// compile never reuses output from before it.
let loaderFingerprint = null;
const fingerprint = () =>
(loaderFingerprint ??= createHash('sha256')
.update(readFileSync(fileURLToPath(import.meta.url)))
.update(readFileSync(new URL('./vueOptions.js', import.meta.url)))
.update(require('vue/package.json').version)
.digest('hex'));
/**
* Where compiled components are kept between test processes, never a
* package's own tree (a host's checks refuse files inside Core): the system
* temp directory, or on Windows the per-user local app data directory, since
* a temp directory there may be shared and its ownership is not checked.
* GEV_VUE_CACHE=0 compiles every time.
*/
export function vueCacheDirectory(
env = process.env,
platform = process.platform,
) {
if (env.GEV_VUE_CACHE === '0') return null;
if (platform === 'win32') {
return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null;
}
return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`);
}
/** The cache entry for one component: its path, its text and the loader. */
export function vueCacheKey(source, filename) {
return createHash('sha256')
.update(JSON.stringify([fingerprint(), filename]))
.update('\0')
.update(source)
.digest('hex');
}
/**
* Compile a component, reusing a previous process's result for the same text.
* A cache that cannot be read or written only costs the compile.
*/
export function cachedVueModule(
source,
filename,
directory = vueCacheDirectory(),
) {
if (!directory || !ownCacheDirectory(directory)) {
return compileVueModule(source, filename);
}
const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`);
try {
return readFileSync(entry, 'utf8');
} catch {
// Not compiled yet.
}
const compiled = compileVueModule(source, filename);
// Concurrent test processes and threads may compile the same component;
// each writes its own new file and the rename makes the entry appear whole.
const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`;
try {
writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 });
renameSync(partial, entry);
} catch {
// An unwritable directory, or a rename another process holds open,
// leaves the cache without this entry.
try {
rmSync(partial, { force: true });
} catch {
// A partial file that cannot be removed is only clutter.
}
}
return compiled;
}
/**
* Cached modules run as code, so the directory must be this user's own: a
* real directory (not a link), owned by the current user and writable by no
* one else. In a shared temp directory another user could otherwise create it
* first and plant modules. On Windows it sits in the user's own profile.
*/
const checkedDirectories = new Map();
export function ownCacheDirectory(directory) {
if (!checkedDirectories.has(directory)) {
checkedDirectories.set(directory, isOwnDirectory(directory));
}
return checkedDirectories.get(directory);
}
function isOwnDirectory(directory) {
try {
mkdirSync(directory, { recursive: true, mode: 0o700 });
const stats = lstatSync(directory);
if (!stats.isDirectory()) return false;
if (process.platform === 'win32') return true;
return stats.uid === process.getuid() && (stats.mode & 0o022) === 0;
} catch {
return false;
}
}
/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */
export function load(url, context, nextLoad) {
if (!url.endsWith('.vue')) return nextLoad(url, context);
const filename = fileURLToPath(url);
return {
format: 'module',
source: cachedVueModule(readFileSync(filename, 'utf8'), filename),
shortCircuit: true,
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Template options shared by the browser build, the boundary check and the
* unit-test loader. Whitespace between nodes is kept (as a single space) so
* an application view renders as its static markup did; Prettier formats
* single-file components with strict whitespace sensitivity to match. Asset
* URLs such as `/pin.svg` stay literal instead of becoming module imports.
*/
export const VUE_TEMPLATE_OPTIONS = Object.freeze({
compilerOptions: Object.freeze({ whitespace: 'preserve' }),
transformAssetUrls: false,
});
/** `@vitejs/plugin-vue` options for application builds. */
export const VUE_PLUGIN_OPTIONS = Object.freeze({
template: VUE_TEMPLATE_OPTIONS,
});
@@ -0,0 +1,4 @@
- Keep every submarine-cable segment associated with its source feature after
Cesium expands multipart geometry, correcting segment colors, click destinations
and horizon culling while retaining one reference stem per feature
(@Aliciawque, #1093).
@@ -0,0 +1,4 @@
- The military-installations proxy (`/api/military-installations`) now refuses
cross-site and reverse-proxy-forwarded browser requests with 403 before any
Overpass work, through the same shared gate as `/api/overpass`. Same-origin
app requests and local tools such as curl still work (@Sunil56224972, #1427).
+3
View File
@@ -0,0 +1,3 @@
- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD
rounded the course without wrapping, so a ship with no reported heading on
a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432).
@@ -0,0 +1,6 @@
- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker
chose metres before rounding, so 999.6 m read "1000 m" while the panel read
"1.0 km" (Aditya, @Rohilalala, #1433).
- Distance labels share one formatter that takes each unit cut after rounding,
so the Contacts panel and markers, the drawn-line measure and the Cockpit
show 9.96 km as 10 km rather than 10.0 km (#1433).
@@ -0,0 +1,3 @@
- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact
whose feed reported no altitude; the reply and card leave the altitude out
(Aditya, @Rohilalala, #1434).
@@ -0,0 +1,3 @@
- Military aircraft whose transponders report a GNSS vertical rate now carry it
in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala,
#1436).
+3
View File
@@ -0,0 +1,3 @@
- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token
and the camera panel rounded the bearing without wrapping, so a camera
facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441).
@@ -0,0 +1,4 @@
- Keep Street Traffic's flow status honest at the edges. A few TomTom-matched
dots among hundreds rounded to 0% and read "(no matches)", and a few
simulated dots rounded to "100% cov"; coverage now stays within 1–99% while
both are shown (Aditya, @Rohilalala, #1442).
+3
View File
@@ -0,0 +1,3 @@
- Run Space Missions' Replay Ascent countdown from T−10 after the five-second
tile hold; the hold ran 10 s and the countdown started at T−05 (Aditya,
@Rohilalala, #1443).
@@ -0,0 +1,3 @@
- Keep Space Missions' replay countdown in real time when the replay speed
changes before liftoff; 0.25× had stretched T−8 to T−32 (Aditya,
@Rohilalala, #1444).
@@ -0,0 +1,3 @@
- Label a route, arrow or drawn line of 995 m or more as 1.0 km, and of
9,950 m or more as 10 km. The placed label picked the unit before rounding,
so it read "1000 m" and "10.0 km" (Aditya, @Rohilalala, #1445).
+3
View File
@@ -0,0 +1,3 @@
- Show a drawn area of 9,999.5 m² or more as 1.0 ha, and of 999,500 m² or
more as 1.00 km². The draw hint picked the unit before rounding, so it read
"10000 m²" and "100.0 ha" (Aditya, @Rohilalala, #1446).
@@ -0,0 +1,3 @@
- Bike Share stations whose feed publishes no capacity now take it from each
status poll's bikes and docks instead of keeping the first poll's value or
the placeholder 15 (Aditya, @Rohilalala, #1447).
+6
View File
@@ -0,0 +1,6 @@
- Local ADS-B decoder feeds now carry a GNSS-sourced vertical rate
(`geom_rate`) when no barometric rate is sent, so the card shows V/S
instead of a dash (Aditya, @Rohilalala, #1448).
- Local ADS-B, military and the adsb.lol flight fallback read the vertical
rate through one shared readsb field reader, so all three prefer the
barometric rate and fall back to the GNSS rate the same way (#1448).
@@ -0,0 +1,3 @@
- List each submarine cable once in find_submarine_cables. TeleGeography
splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a
search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449).
+3
View File
@@ -0,0 +1,3 @@
- Show a route of 9.95 km or more as 10 km. The Directions row and step
cards kept one decimal until the raw distance reached 10 km, so 9.96 km
read "10.0 km" (Aditya, @Rohilalala, #1450).
+2
View File
@@ -0,0 +1,2 @@
- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0°
rather than DIRECTION 360° (Aditya, @Rohilalala, #1451).
@@ -0,0 +1,3 @@
- Show a transit vehicle's reported bearing of 359.5° or more as 0°. The
selected-vehicle card rounded it without wrapping, so a bus heading 359.6°
read "reported hdg 360°" (Aditya, @Rohilalala, #1452).
@@ -0,0 +1,6 @@
- Space Missions no longer shows an older satellite with a similar name and a
different known launch year as a new payload's orbit: the launch-year check
on loaded satellites now runs (Aditya, @Rohilalala, #1453).
- Satellite launch years come from one TLE designator parser for both the
loaded catalog and the active-TLE fallback; a blank designator is an
unknown year instead of 2000 (#1453).
@@ -0,0 +1,5 @@
- Describe a selected flight or military contact on 359.5° or more to the
voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454).
- Heading and bearing readouts share one rounding helper, so the Cockpit's
TRACK ACQUIRED course no longer reads 360°, and a negative bearing wraps
instead of printing a minus sign (#1454).
+5
View File
@@ -0,0 +1,5 @@
- Between 721px and 900px wide (tablet portrait, phone landscape), the
Location and Visual Presets dock wings show only their icons, so their
labels no longer spill over the voice control or take taps meant for the
mic button. The full name stays in the tooltip and accessible name, and the
Cyber layout keeps its labels (Denis Groshev, @ZloyS, #1457).
@@ -0,0 +1,4 @@
- The Docker image and CI now pin Node 24.22.2, the current 24 LTS patch. It
carries the October 21, 2026 security fixes first released in 24.22.1,
including CVE-2026-111497 in the Brotli decoder (Luis Ortiz, @anobjectn,
#1458).
+1 -1
View File
@@ -2,4 +2,4 @@
looks up place names when no OpenAI key is configured, reverse-geocodes at
most two points instead of up to four, and looks up again only once the view
shows a different area, not after every small pan, so a small pan no longer
asks OpenAI for a new summary either (samehkhamis, #0000).
asks OpenAI for a new summary either (samehkhamis, #1459).
@@ -0,0 +1,5 @@
- Leave layer aliases the layer id already spells, such as "dams" for
`local-dams`, out of the voice layer hint. The Realtime prompt is 305
characters shorter, leaving room under the 52,000 gate for new
voice-toggleable layers; every alias is still accepted (@quanticsoul4772,
#1462).
+6
View File
@@ -0,0 +1,6 @@
- Gather Directions, Draw and Import in a new UTILITIES panel in the left
column, between Data Layers and Scenes. Draw and Import leave DISPLAY, and
Radio stays in Data Layers under "Audio". Each drawn shape now gets a row
under Draw, like an imported file: name it, recolour it, hide it, fly to it
or remove it there. Area, Line and Pin are icon buttons, and the colour for
the next shape is a row of swatches (asturksever, #1469).
+5
View File
@@ -0,0 +1,5 @@
- Add Arabic (ar), the first right-to-left language: the title, rails, dock and
corner row mirror, numbers keep Western digits, and plurals use all six
Arabic forms. FIRMS cards and Space Missions landing labels are now
translated in every language. Based on translations by @abiyeenzo (#915)
(German, @gekh, #1479).
+3
View File
@@ -0,0 +1,3 @@
- Let a GNSS integrity source set the layer's evidence window (`windowMs`). A
missing or invalid value keeps the 30-minute default, and the layer row now
names the window in use instead of a fixed "30 min" (samehkhamis, #1491).
@@ -0,0 +1,4 @@
- Let a weather source describe its own products through an optional
`display` object in each manifest (source, credit, units, coverage, info,
legend and extent). Each valid field replaces the built-in value, and
manifests without it render as before (samehkhamis, #1494).
+205
View File
@@ -0,0 +1,205 @@
- The language picker switches language in place instead of reloading the
page. Views re-render; code that publishes translated text words it again
through `onLocaleChange` (`src/i18n/index.js`), and `<html lang>`/`dir` and
the page's `data-i18n` markup follow. `persistLocale` replaces
`persistLocaleAndReload`, and `registerUnsavedWork` and the switch's
unsaved-work prompt are removed, since a switch no longer loses anything
(samehkhamis, #1499).
- A map source's `unavailableReason` may be a function, read when the reason
is shown so it follows the locale; the sources from `createDefaultMapSources`
(`./maps/defaults`) give functions. `MapSourceController` reads either form
(samehkhamis, #1499).
- The application views translate their text with `t()` as they render,
so it follows the active locale; they no longer carry `data-i18n*`
attributes, and `npm run i18n` rejects those in a Vue template. The boot
pass (`applyDocumentTranslations`) still translates static page markup
such as the loading screen and a host's own markup (samehkhamis, #1499).
- The page's locale lives in a vue-i18n instance installed in every
application view, and `t()`, `tn()` and the Intl formatters read it from
there, so a view that translates while rendering renders again when the
locale changes. `bindLocale` (`src/i18n/index.js`) keeps the locale in a
given ref; Core's Vite config builds vue-i18n without its message
compiler. Catalogs, messages and call sites are unchanged
(samehkhamis, #1499).
- Cockpit's Display popover shows the Display panel's own HUD, Detection,
Parameters and 3D groups through Vue teleports: `CockpitDisplayPortal`
publishes the popover's slots (`publish`, with `afterRender` to lay out once
they have moved) instead of moving the groups itself (samehkhamis, #1499).
- The Cockpit utility strip's measured layout renders from the `gev-cockpit`
store: the layout pass publishes `utilityLayout` (the HUD's
`--cockpit-utility-*` lengths, `primaryOnly` and `hideCollapsed`), and a host
constructing `CockpitController` passes `publish.utilityLayout` beside its
other publishers (samehkhamis, #1499).
- The Cockpit Contact and briefing cards' collapse renders from the
`gev-cockpit` store: the controller publishes `contextCollapsed` and
`signalCollapsed` in its presence, and each card's `data-collapsed` and its
toggle's state, words and icon follow it (samehkhamis, #1499).
- The panels' own state classes (`collapsed`, `dock-pinned`, `dock-pinned-top`,
`layout-auto-collapsed`, `accordion-collapsed`) render from the
`gev-panel-chrome` store: the shell, the rails and the accordion present each
panel's state there instead of writing its classes. `layoutLeftPanelRail` and
`layoutRightPanelRail` (`./ui/layout`) now call
`onCollapse(panel, {collapsed, auto})` and leave drawing it to the host.
Without a shell to own the panels, the voice tools' `set_panel_open` now
reports that there is no panel to open instead of writing its classes
(samehkhamis, #1499).
- The shell's panel chrome no longer writes the panels' collapse buttons, the
dock tray toggles' names or the dock pins' pressed state: it presents each
panel's open and pinned state to the `gev-panel-chrome` store, and the
buttons, now views, call it there. `bindPanelDisclosure` (`./ui/panels`) binds
only a panel's Escape; it no longer takes `buttons` or `onChange`. `UiButton`
joins the component kit (samehkhamis, #1499).
- `SceneControls` (`./ui/scenes`) no longer takes `elements` or writes the Scene
panel's buttons, status, progress or runtime chip: it presents them to the
`gev-scenes` store and the panel's buttons call its actions there.
`sceneElements`, `presentSceneButtons`, `presentSceneProgress` and
`presentSceneRuntime` give way to `sceneButtonsDisabled` and `scenePercent`.
It no longer stands down when the page has no Scene panel: ESC still stops a
playing scene and the runtime chip still shows (samehkhamis, #1499).
- `initFirstRunExperience` (via `./application/chrome`) no longer writes the
launcher's classes, attributes or text, or removes the card: it presents them
to the welcome view's `gev-first-run` store (its new `store` option) and
connects the controls the view's buttons call (samehkhamis, #1499).
- `IntelHUD` (`./ui/hud`) no longer builds or writes `#intel-hud`: it hands its
readouts, visibility, layout variant and style colors to its `publish` option,
and Core's `intel-hud` view renders them. A page places that view with
`<div class="gev-view" data-gev-view="intel-hud">` instead of a bare
`#intel-hud` element. `./layers/vessels` shows the selected vessel's HUD
readout through `services.readouts.presentSelectedVessel()` instead of writing
`#hud-ais-vessel`; the application passes `src/hudReadouts.js`, which IntelHUD
follows (samehkhamis, #1499).
- `LocationControls` (`./ui/location`) no longer builds the city and landmark
pills or writes the mini status: it presents the tray, including the search
error (`setSearchError()`), and hands it to its `publish` option, and Core's
dock renders it and calls `chooseCity()` and `choosePoi()`. Its `elements` are
now only `search`: `resetButtons` and `searchToggle` are gone, and the reset
buttons and the search toggle call its new `reset()` and `toggleSearch()`. It
presents the search field's open and searching state too (`searchExpanded`,
`searching`; a `searchExpanded` option starts it open), set through
`setSearching()`, `settleSearch()` and `closeSearch()`, and a host renders the
field's `expanded` and `searching` classes from it; `LocationSearch` takes a
`setSearching` option in place of writing the field's class, and
`NavigationController` a `settleSearch` option in place of `searchInput`.
`createOrbitIndicator()` is replaced by `showOrbit(active)`, which adds the
ORBIT indicator to the presented tray as `orbit: { active }` (null until
offered) (samehkhamis, #1499).
- `createMapSourceControls()` (`./ui/maps`) no longer renders the map source
chips into a `container` or writes a `statusElement`: it presents the chip row
and status badge and hands them to its `publish` option, and Core's dock
renders them and calls `select()`. `renderMapStackChips`, `syncMapStackChips`
and `MAP_STACK_CHIP_CLASS` are removed (samehkhamis, #1499).
- `./layers/launches` no longer writes the Space Missions roster or the selected
mission panel into `#space-mission-panel-host`: the layer publishes both
through `subscribePanel(listener)` and Core's Space Missions views render
them, calling the layer's panel commands (`selectMissionAt`,
`selectPreviousMission`, `selectNextMission`, `deselectMission`,
`focusSelectedLaunchSite`, `startSelectedReplay`, `toggleReplayPause`,
`cancelReplay`, `setReplaySpeed` and the `roster*` preview inputs). The
`bindMissionRosterItemKeyboardPreview`, `captureMissionRosterFocus` and
`restoreMissionRosterFocus` helpers are gone; the roster keeps focus itself
(samehkhamis, #1499).
- `./layers/awareness` no longer writes the Contacts panel into
`#military-awareness-panel`: the layer publishes a plain panel view through
`subscribePanel(listener)` and Core's Contacts view renders it, calling the
layer's `navigatePrevious`, `focusCurrent`, `navigateNext` and `focusTarget`.
The `awarenessPanelControlKey`, `captureAwarenessPanelFocus` and
`restoreAwarenessPanelFocus` helpers are gone; the view keeps focus itself
(samehkhamis, #1499).
- `./ui/cctv` and `./ui/radio` import Vue and Pinia: their controllers publish
the panel's state to a store that the CCTV and Radio views render, instead of
writing the panel's markup themselves (samehkhamis, #1499).
- `./ui/display`'s `bindDisplayControls` is gone: the Display panel's controls
and the dock's style buttons call their owner through the display store's
`connect`/`act`, and its allocation and 3D model choices are the kit's
`UiSegmented`. `initDrawTool` takes `connect(actions)`, which returns their
disconnect, and no longer listens on the Draw controls; its view publishes
`color` and `label` too, and the shape choice is a `UiSegmented`.
`initGeoFileImport` takes `connect(actions)` the same way and no longer
listens on the Import button, picker or list, nor restores focus itself: the
`GeoImport` view does both, and its `publish` need not settle once the list
renders. Street Level's fit and panorama filters and Recent Imagery's mode are
`UiSegmented` too: the Street Level store's owner answers `setPano` and
`setRenderMode`, and `RecentImageryReadout` takes `onSetMode`. The Radio
panel's and Cockpit's internet radio controls and the tuner slider's events
call `RadioControls`, and the local RTL-SDR card's controls
`LocalSdrControls`, through the radio store's new `connect`/`act`; the card
labels its gain options itself. `act` returns what the answering action
returned. The Display, Radio, RTL-SDR, Cockpit, mission replay, style
parameter, rail card, observation timeline, imagery opacity, Street Level date
and Bhote Koshi sliders are the kit's `UiSlider`, a native range input that
emits numbers, and the pressed toggles are the kit's `UiButton`, which gains
`pressedClass`; so are the rail card chips, map source chips and location
pills (which now report `aria-pressed`), and the voice card's chips are the
kit's new `UiChip`. The selects are the kit's new `UiSelect`, the status
badges its new `UiBadge` and the one-line empty states its new `UiEmpty`. The
top-center clear, share, tilt, north-up and reset buttons, and the cockpit's
reset, call the shell through the new globe actions store, which also carries
the camera's tilt and compass heading. The recording safe frame renders what
`RecordingControls` publishes to the new recording store. `syncScopeControls`
(`./ui/display`), `shouldHideCollapsedRightPanels` (`./ui/layout`),
`bindClearLayersControl` (`./ui/layers`) and `createStyleParameters`
(`./ui/input`; the Display panel renders the style parameters VisualSettings
publishes to the display store) are deprecated: the Display view, the rail and
the globe actions view now own what they did. `./ui/input` imports Vue
(samehkhamis, #1499).
- `./scenes` presents the scene sharing dialogs and the EDIT DETAILS / SHARE
SCENE bar through a store that the scene panels view renders, so a page that
omits `layer-panels` shows neither (samehkhamis, #1499).
- `./voice/control`'s `createVoiceControl({ panel })` returns the voice
control's presenter instead of element handles: owners write it by part
(`update('detail', { text })`) and listen with `on('button', fn)`, and the
command dock renders it from a store. `createVoiceCommands`' `createControl`
and `VoiceCardControls` (`{ ui }` instead of `{ root }`) take the same
presenter; the `reset` option is gone, since a newer control replaces the
previous one (samehkhamis, #1499).
- Pages place each view with a `<div class="gev-view" data-gev-view="NAME">`
host and write their own loading screen; the `build/html` template expansion
and its `gev:template` markers are gone. `createApplication` takes `omitViews`
(for example `['provider-settings']`) for views an application leaves out
(samehkhamis, #1499).
- The page's panels, dock, cockpit and dialogs are Vue views with the same
markup as before. Hosts that do not initialize Core's locale pass
`localePicker: false` to `createApplicationControls` to drop the language
picker, and Node tests that import application modules register the component
loader with `--import=<core>/build/register-vue` (samehkhamis, #1499).
- The Data, Authoring, Weather, Recent Imagery, Context, CCTV, Street Level and
Radio panels are one component, `UiPanel` (`src/ui/kit/`), with the same
markup plus the kit's `ui-panel` classes, their titles translated by `t()`
rather than `data-i18n`; `RailPanel` binds it to the panel chrome store.
`PanelCollapseButton` drops its `initial` and `initialTitle` props and takes
`startCollapsed`, so before startup presents a panel its button already shows
that state's glyph, words and `aria-expanded` (samehkhamis, #1499).
- The Authoring, shot list and CCTV buttons and Street Level's nearest button
are the kit's `UiButton` (danger buttons in the `alert` tone, primary ones the
`primary` variant), keeping their classes; the CCTV on/off, coverage,
auto-hop, projection and adjust toggles now say their state with
`aria-pressed` (samehkhamis, #1499).
- Each desktop sidebar has an icon rail that lists every panel on that side and
opens one with a click. One panel is open per side in every HUD layout, so the
collapsed panel headers no longer take up the sidebars. Phones keep the bottom
tab bar. The rail is the first view built with Vue and Pinia, which now ship
with the app; see docs/VUE-MIGRATION.md (samehkhamis, #1499).
+4
View File
@@ -0,0 +1,4 @@
- `build/register-vue` loads components on the test thread and keeps compiled
components between test processes in a per-user cache directory, so Node
tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles
every time (samehkhamis, #1500).
+3
View File
@@ -0,0 +1,3 @@
- The side panel rails show each panel as a labelled pill while no panel on
that side is open, and fold to icons once one opens. `UiRail` takes
`collapsible` for this (samehkhamis, #1501).
@@ -0,0 +1,4 @@
- Contributor docs: CONTRIBUTING lists what each of the three baseline checks
needs to run, DATA_SOURCES documents loading a local CCTV source pack, and
KNOWN-ISSUES points the floor-placement note at its verification script
instead of a missing report (Gaurav Patidar, @gaurav7902, #210).
+52
View File
@@ -0,0 +1,52 @@
- Add source-backed voice outlines without public Overpass calls: bundled Census and
Who's On First boundaries cover places and neighborhoods, OpenFreeMap tiles
cover streets, buildings, and grounds, and guarded Nominatim lookup handles
remaining named areas with bounded caching and usage limits (bilawalsidhu,
#842).
- Preserve standalone server-token maps, project tile outlines only on demand,
select building components with their holes independently, preserve non-Latin
street-name marks, and enforce public Nominatim spacing at fetch invocation.
Nominatim now requires explicit endpoint configuration (manjunath22466, #842).
- Keep Census labels in filled geometry, handle local dateline outlines in one
coordinate frame, and require whole settlement names or explicit returned
aliases while retaining landmark shorthand (manjunath22466, #842).
- Harden and integrate voice outlines with main (manjunath22466, #842).
- Wait for the original building installation before reporting duplicate results,
including deferred outline installs, without restarting probes
(manjunath22466, #842).
- Carry actual outline provenance to visible geometry and retain inline map
credits only while the owning outline is displayed (manjunath22466, #842).
- Require a named building's resolved anchor inside its tile footprint, outside
courtyard holes, while keeping nearby selection for pointed-at buildings
(manjunath22466, #842).
- Start each queued building's ground-sampling budget when it reaches the head
of the queue, keep Nominatim outlines that cross the antimeridian, let an
unnamed school, university, hospital, cemetery, stadium or zoo area answer a
grounds ask whose head noun names that kind, keep the shared OpenFreeMap tile
caches within the 64 MiB budget of the single source traffic and installations
shared before, fold place names in one shared helper, and exclude Who's On
First sources whose licence is only listed as CC BY or CC0 "equivalent",
including the Ottawa, Saskatoon, Gatineau and Madrid records
(manjunath22466, #842).
- Skip Nominatim place and outline lookups when the server reports that no
Nominatim endpoint is configured, so a default install sends no request it
would refuse (manjunath22466, #842).
- Keep the phone credit line on screen when map and outline credits make it
longer, wrapping it within the screen instead of running off the edge
(manjunath22466, #842).
- Outline nothing from tiles when the tile under the point failed to load, so a
neighbouring building or area never stands in for it, and match each of a
Nominatim name tag's ";"-separated names (manjunath22466, #842).
- Keep bent streets outlined when the geocoded point sits beside the
centreline, never answer a landmark ask with the park or square around it,
pick the same-name Nominatim outline that holds the resolved place, and keep
a new outline when an identical older mark fails to clean up
(samehkhamis, #842).
- Tighten the remaining voice outline review items: Nominatim name checks use
the bundled packs' place-name key ("St. Paul" = "Saint Paul"), "E Street" no
longer matches "East Street" while "E St NW" answers "E Street" near it,
"Burj Khalifa Tower" still finds the tower, no neighbour standing in
for a building cut at the tile edge, no cut area answering grounds, https-only
PHOTON_URL, an interrupted Nominatim dispatch reclaimed after ten minutes,
no node:sqlite startup warning, and lazily decoded shared OpenFreeMap layers
(samehkhamis, #842).
+3
View File
@@ -0,0 +1,3 @@
- Document GitHub Codespaces as a fourth setup path in the README: a cloud
workspace runs the dev server while the globe renders in your own browser
(John Leshan, @johnleshan, #980).
+21 -3
View File
@@ -200,6 +200,24 @@ its pending results. Source disposal does not silently reinstate a default.
`ui/composition` supplies the default engines to the shell. Applications can
supply a HUD implementation or request policy through control services. The
shared chrome owns the welcome/loading transition; standalone composition adds
Provider Settings. `build/html` expands an allowlist of component markers from
`src/ui/templates`; unknown names cannot read arbitrary filesystem paths. The
standalone document expands to the same markup as before this extraction.
Provider Settings. A page places each Vue view in `src/ui/views` with a host,
`<div class="gev-view" data-gev-view="NAME"></div>`, and writes its own loading
screen (`#loading-screen` with a `.loader-status` line) as static markup so it
paints before any script runs. `createApplication()` renders the views before
anything reads the page; `omitViews` names views the application leaves out,
such as `provider-settings`, and their hosts are removed. Leaving out
`layer-panels` also leaves out the scene sharing dialogs it draws, and leaving
out `scene-chrome` the before/after imagery dividers (the imagery still splits,
with no line or handle to move it), the status chips, the toast, the ORBIT
indicator, the FPS readout, the Scene actions panel, the data-pack attribution
cards, the wind inspection marker, the Space Missions replay vehicle, the
full-globe celestial ring, the screen-space map annotations, the visible
map target list and the Global Context compass;
leaving out `command-dock` the voice control and its pointer reticle;
leaving out `context` the Bhote Koshi reconstruction panel; and leaving out
`intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD
implementation publishes to the `gev-hud` store. An unknown
view name is an error. A host that does not initialize Core's locale passes
`localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`.
+22 -26
View File
@@ -276,7 +276,7 @@ compatible through re-exports.
### Adding a right-rail readout panel
Add the shell in `src/ui/templates/context.html` inside `#right-context-rail`:
Add the shell in `src/ui/views/ContextView.vue` inside `#right-context-rail`:
a `.panel-collapsible` with a stable `id` and matching `data-panel-id`, the
existing `.panel-header`, collapse button and a dedicated body. Register it
with the existing panel chrome/layout owners; do not allocate rail space in
@@ -297,13 +297,13 @@ compactStatus, blocks }` in place. Their full-width native button headers expose
adapter owns accordion state. Generic cards do not select data or enable layers.
`blocks` is an ordered array of loose, optional `{ id, type, ... }` descriptors,
rendered by `railCardBlocks.js`. There are no per-section disclosures:
rendered by `rail/RailCardBlocks.vue`. There are no per-section disclosures:
- `summary`: `text`, a single summary line.
- `list`: `list: { ariaLabel, items }`, reconciled by `syncRowList` in `rowList.js`.
- `list`: `list: { ariaLabel, items }`, keyed by `rail/RailRowList.vue`.
- `lines`: keyed `lines: [{ id, text, muted? }]` for details and status.
- `settings`: `settings: [{ id, label, chips }]`; each labelled row has a 56 px
label column and a wrapping `syncChipGroup` from `chipGroup.js`.
label column and wrapping keyed chips from `rail/RailChips.vue`.
- `legend`: `legend: { colors, labels, units?, zeroIndex?, categorical? }` for a
ramp/scale (including a physical freezing anchor) or separate keyed swatches.
- `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?,
@@ -372,12 +372,10 @@ facade's asynchronous teardown can yield.
## Ordinary action buttons
`src/ui/actionButton.js` is a native, listener-free view helper used by Scene
presentation and sharing-toolbar owners. It receives translated text and explicit
presentation/accessibility properties. Feature controllers own action dispatch,
state, persistence and cleanup. Static Scenes/CCTV templates use the same CSS
surface and preserve IDs and layout hooks. The helper imports no application,
renderer, storage or services; it belongs to the consuming package closures.
The scene panels view and the CCTV panel draw their action buttons with the shared
`gev-action-button` classes in `src/ui/styles/action-button.css`. Feature
controllers own action dispatch, state, persistence and cleanup, and the views
preserve IDs and layout hooks.
## Display controls
@@ -401,9 +399,9 @@ remain in their callers.
`ui/maps` owns source-chip presentation, selection feedback and its subscription
lifetime. It receives the existing controller and explicit state/action callbacks;
it imports no renderer or application. Source construction and availability policy
remain with the map controller. Rebuilding controls removes their previous chip
listeners, and destruction suppresses late completions without owning or destroying
the supplied controller.
remain with the map controller. The dock renders the chips; rebuilding controls
drops their previous subscription, and destruction suppresses late completions
without owning or destroying the supplied controller.
## Layer panel
@@ -741,13 +739,13 @@ DOM and platform middleware.
### Native form fields
`src/ui/formField.js` constructs native numeric inputs without listeners or
application state. Static selects and number inputs use the same `gev-field`
surface, with explicit compact and inline densities. Feature owners retain
Native numeric inputs are rendered by the views that own them; the CCTV pose
chip renders its inline input in `src/ui/cctv/CctvCalValue.vue`. Static
selects and number inputs use the same `gev-field` surface, with explicit
compact and inline densities. Feature owners retain
option identity/order, bounds, parsing, draft values, input/change timing and
teardown. CCTV effective-pose conversion and tuner/device operations remain
feature responsibilities. A Vue replacement can reuse this contract and CSS;
it replaces DOM construction, rather than adding a second state owner.
feature responsibilities.
The shared stylesheet owns field surfaces and disabled opacity; feature and theme
palettes use `--gev-field-background`, `--gev-field-border-color`,
@@ -759,11 +757,9 @@ field rules separate, so Radio's palette remains an intentional exception.
### Read-only status badges
`src/ui/statusBadge.js` constructs/updates ordinary badges without listeners,
status inference or live-region policy. It consumes translated text and controlled
visibility; appearance variants share CSS rather than mapping machine states to
universal severity. CCTV owns frame/source and calibration rules and their data
attributes, while Weather owns coverage labels. The keyed rail-card owner keeps
node identity, header focus, disclosure and teardown. The shared adapter never
creates another state store or announces all card updates. A Vue view can replace
this adapter and retain these presentation inputs and styles.
Views draw read-only badges with the `gev-status-badge` classes, without
listeners, status inference or live-region policy. Appearance variants share
CSS rather than mapping machine states to universal severity. CCTV owns
frame/source and calibration rules and their data attributes, while Weather
owns coverage labels. The rail card keeps node identity, header focus,
disclosure and teardown, and does not announce every card update.
+295 -105
View File
@@ -150,6 +150,26 @@ provider registration rejects compositions that cannot fit rather than truncatin
identities. A malformed or duplicate entry invalidates the complete custom-switch
field. Existing whole-link size limits still apply.
## Utilities panel — October 29, 2026
UTILITIES is its own left-side panel, between Data Layers and Scenes, with
its own rail item (`handyman`). It is a `RailPanel` (`#utilities-panel`) in
`src/ui/views/LayerPanelsView.vue` and holds the Directions layer row, which
`LayerPanel` renders into `#utilities-toggles` instead of the Data Layers
list, then Draw (`DrawControls.vue`) and Import (`GeoImport.vue`), which left
DISPLAY with their ids unchanged. Without `#utilities-toggles` (a host that
omits the panel) Directions stays in Data Layers under its Utilities heading.
Radio, formerly the other row under that heading, stays in Data Layers under
"Audio". Like the other side panels it follows the one-open-panel rule,
remembers its state, and is not part of share links. It hides in clean view
and recording. In Cockpit it stays with Directions alone, as Data Layers does;
Draw and Import hide there, as DISPLAY always did, and opening Utilities folds
the Cockpit context rail away as Data Layers does (and Contact expanding
closes it). Voice `show_data_layers_menu` for Directions opens Utilities, the
panel that holds its row, and `set_panel_open` reaches it as
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
it sits collapsed in the Layers sheet under Data Layers.
## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers,
@@ -216,6 +236,9 @@ 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.
`find_submarine_cables` lists and counts each cable once: TeleGeography splits
a few cables (2Africa, Echo) into several features under one id, and such a
cable matches when any of its parts crosses the area.
Catalog calls check their cancellation signal immediately before executing a
tool. Already-cancelled queries do not start; cancellation after execution starts
@@ -312,7 +335,7 @@ settings from active map/contact effects. Sonar settings are session-only and
return to their defaults on reload. Sliders accept integer values matching the
action's ranges.
Wind appears in the Weather group before Utilities. The surface-weather prototype
Wind appears in the Weather group before Audio. The surface-weather prototype
uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid.
It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning.
Weather rows contain only the toggle and source/meta line. Configuration lives
@@ -494,6 +517,33 @@ not worldwide detections; units are strikes/km²/min ×10³. It is neither raw G
flashes nor a live strike counter. Weather imagery orders scalar context below
satellite, radar and lightning consistently across enable and history order.
A weather source can describe its own products (credit, units, coverage,
legend) through an optional `display` object in each product manifest:
`source` (≤60 characters, the row's source name), `credit` (≤200, the imagery
credit in Data attribution, shown as plain text and updated when a refreshed
manifest changes it), `units` (≤40), `coverage` (≤48, the coverage badge and
info line), `info` (≤600, the info tooltip), `legend` (2–16 `{ label, color }`
stops; label a plain decimal number or text, at most 16 characters either way,
a number reading back exactly as the legend shows it: within ±2^53−1, with
no leading zeros and no digits lost to rounding; color `#rrggbb`) and `extent`
(`{ west, south, east, north }` degrees, non-empty; west greater than east
crosses the antimeridian; used for coverage navigation and the
outside-coverage status). `units` and `legend` describe one scale and
apply only together: if either is absent or invalid, both keep the built-in
values, and the radar info heading names the units in use. Text must be
single-line, without control characters (U+0000–U+001F, U+007F–U+009F), line
or paragraph separators, or invisible and format characters: soft hyphen
(U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C),
Mongolian vowel separator (U+180E), zero-width and direction marks
(U+200B–U+200F), bidi embedding and override (U+202A–U+202E), word joiner,
invisible operators and bidi isolates (U+2060–U+2069), variation selectors
(U+FE00–U+FE0F), byte-order mark (U+FEFF) and tag characters
(U+E0000–U+E007F). Each other field is validated on its own; an absent or
invalid field keeps the built-in value, so the bundled nowCOAST manifests,
which carry no `display`, look exactly as before. Accepted source
and units text is source data and is shown untranslated. The static Data
attribution entries remain the application's `credits` list.
Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded
same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service.
@@ -549,7 +599,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown,
and vessel cards, the HUD and analyst queries show a missing measurement rather
than a reading. Heading keeps its existing separate check for its own sentinel.
Genuine readings are unchanged, including a stopped vessel's zero and the
highest speed and course the fields can encode.
highest speed and course the fields can encode. Vessel cards and the HUD wrap
the direction after rounding to whole degrees, so a course of 359.5° or more
reads `0°` (`0DEG` in the HUD) rather than `360°`.
For an already tracked vessel, AISStream static messages update name, type,
destination and IMO without changing its position, motion, fix timestamps,
@@ -885,7 +937,7 @@ The standalone FIRMS provider, source defaults and parsing behavior are unchange
CLI tools and development launchers accept `GEV_PROJECT_ROOT` to select the project environment, dependencies and output directory. Their default remains this checkout; bundled tool resources and source checks stay relative to the script installation. The setup doctor also accepts an explicit `rootDir`.
Application composition now uses shared scene/control/catalog/tool constructors and allowlisted HTML component templates. The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
Application composition now uses shared scene/control/catalog/tool constructors and the application's Vue views (see [VUE-MIGRATION.md](VUE-MIGRATION.md)). The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
Voice controls compose a supplied action runner and connection controller.
Realtime token and SDP requests live in a configurable backend, with independent
@@ -950,7 +1002,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is
replaced once by the rendered surface under it (two depth samples per paint,
accepted only within -30/+40 m of the floor cell), so badges do not float or
sink on slopes; wedge corners follow their own floor cells instead of staying
level with the apex. Unknown bearings never acquire a wedge. The row reads
level with the apex. Unknown bearings never acquire a wedge. The selected
card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing
of 359.5° or more reads `0°` rather than `360°`. The row reads
`N nearby · M on screen`; when every loaded camera is outside the view it adds
"None on screen — nearby cameras are outside the view" instead of looking
unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off,
@@ -1123,7 +1177,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries
`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.
absent rate stays null rather than reading as level flight. A military readsb row
without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead.
## Transponder emergency highlighting
@@ -1456,11 +1511,11 @@ The application supplies actions and retains effect settings, restore claims and
rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown.
Ordinary sliders share the native range component in `ui/slider.js` and the
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
Ordinary sliders share the native `gev-quantitative-slider` surface in
`ui/styles/slider.css`. Display, shader
parameters, volume, replay speed, weather settings/history, imagery opacity and
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
Generated controls use `createSlider`; static templates apply the same class.
Templates, components and generated controls apply the same class.
Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior.
@@ -1479,11 +1534,9 @@ boundaries preserve the current Tactical/Cyber appearance and native behavior.
## Ordinary status badges
CCTV source/frame labels, calibration provenance and Weather coverage labels use
`ui/statusBadge.js` and the `gev-status-badge` CSS surface. The listener-free
adapter accepts already-translated `label` and `hidden` values; pill, box and plain
appearances describe shape/density only. Static CCTV markup uses the same classes
as generated rail-card badges. Repeated identical updates do not rewrite text or
replace nodes. Empty coverage remains hidden.
the `gev-status-badge` CSS surface, drawn by the CCTV view and the rail card from
already-translated labels; pill, box and plain appearances describe
shape/density only. Empty coverage remains hidden.
Feature owners retain the status rules, machine-state attributes, translations
and announcement policy. CCTV frame acquisition (`idle/loading/error/ready`)
@@ -1493,18 +1546,16 @@ prior poses; unsaved edits override the label until save/reset. Coverage labels
remain geographic scope, not health claims. Existing colors and the source
badge's polite live region are preserved; coverage and calibration do not gain
live regions. Interactive layer toggles, progress indicators and world overlays
retain their specialized ownership. Vue can reuse the view contract and CSS while
replacing DOM construction. Browser acceptance:
retain their specialized ownership. Browser acceptance:
`QA_BASE_URL=http://127.0.0.1:4189 node scripts/qa-status-badges.mjs`.
## Ordinary action buttons
Scene panel actions, shot LOAD/DEL actions, the EDIT DETAILS / SHARE SCENE
bar and one-shot CCTV actions use `ui/actionButton.js` and the shared
`gev-action-button` surface in `ui/styles/action-button.css`. Static templates
apply the same classes as generated buttons. The native builder accepts translated
text, default/danger variants, default/compact sizes and disabled/accessibility
properties; it installs no listeners and owns no application state. Feature
bar and one-shot CCTV actions use the shared `gev-action-button` surface in
`ui/styles/action-button.css`, with default/danger variants and
default/compact sizes, drawn by the scene panels view (`LayerPanelsView.vue`,
`SceneShotList.vue`) and the CCTV panel. Feature
controllers retain callbacks, enable/disable rules, confirmation prompts,
persistence and teardown. The smaller shot actions retain their existing sizing
and hover treatment. Feature CSS still owns panel layout and translated wrapping.
@@ -1512,21 +1563,21 @@ and hover treatment. Feature CSS still owns panel layout and translated wrapping
CCTV enable, coverage, auto-hop, projection and adjustment toggles, editable
calibration-value chips and the scene authoring dialogs retain their specialized
controls. Existing `scene-btn` hooks remain compatible with Street Level and
legacy toggles. The shared CSS, explicit input/action boundary and behavior tests
can carry to a Vue view while Vue replaces native DOM construction.
legacy toggles.
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-buttons.mjs`;
`qa-scene-controls.mjs` also covers real Scene authoring/playback with native Space capture.
## Segmented selectors
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
Segmented selectors are the kit's `UiSegmented` (`ui/kit/UiSegmented.vue`) on
the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
their feature-owned values, actions and persistence. Painting a restored value
does not emit a change or move focus. Existing keyboard policies remain explicit:
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
its controlled selection, and Display keeps its existing native button behavior.
Pure value navigation is separate from DOM rendering for reuse by a future view.
their feature-owned values, actions and persistence: the view renders the
owner's value and sends a choice back through its store. Rendering a restored
value does not emit a change or move focus. Each group names its keyboard
policy: Street View uses `radio` (arrows/Home/End with one tab stop), imagery
mode `arrows` from its controlled selection, and Display `none`, its native
button behavior.
Segments keep their word minimum width: compact inline groups such as FIT/FILL
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
the same exclusive-rail hiding rule as the other readouts.
@@ -1564,6 +1615,32 @@ 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.
## Panel rails
On desktop each side stack has a floating icon rail (`src/ui/panelNavigation.js`,
rendered by the Vue `PanelRailView` and the kit's `UiRail`) in the screen-edge
gutter beside it. The rail lists every panel the side currently offers, in stack
order: Data Layers and Authoring on the left; Display, CCTV, Weather and Recent
Imagery (while shown), Street Level and Context on the right. Selecting an item
opens its panel through `setPanelCollapsed(id, false, {explicit: true})`;
selecting the open panel's item closes it. The item of the open panel is
pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open.
While no panel on its side is open, the rail shows each item as a labelled pill
(icon and title, `UiRail`'s `collapsible`); opening a panel folds the labels away
to the icon rail. The rail is placed by its screen-side edge, so the pills grow
toward the middle of the screen, over the closed side's empty corridor.
One panel is open per desktop side, in every HUD layout. Opening a side panel
by any route (rail, header, keyboard, voice) collapses its peers; restored or
shared state that opens several is normalized by the layout pass, which keeps
the most recently opened panel, then the one holding keyboard focus, then the
first, and marks the others `accordion-collapsed` (presentation only, so share
links still report the saved preference). Collapsed panels are hidden
(`layout-exclusive`) because the rail lists them. The rail lives inside its
stack, so it hides with it in Cockpit (where Authoring's item is also hidden),
recording and clean view. The panel store (`src/ui/stores/panels.js`) mirrors
each side's panels from the DOM; `PanelChrome` remains the disclosure owner.
## Phone layout
At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and
@@ -1599,8 +1676,9 @@ sheet before a panel's own Escape handling, and the dock's hover auto-close does
not apply inside a sheet.
Cockpit, recording and clean view close the sheet and hide the tab bar; scene
playback keeps both so its STOP control in the Authoring panel stays reachable. The
right rail neither hides collapsed siblings nor applies Cyber's one-panel
accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the
side rails neither hide collapsed siblings nor apply the desktop one-panel
accordion at these widths, and the panel rails are hidden, since the sheet
chooses what shows. The Cesium and Google attribution stays visible above the
voice control, and above an open sheet. The page draws under the notch and
system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets.
Wider viewports are unchanged, and crossing to them closes any open sheet.
@@ -1859,6 +1937,8 @@ limit, as well as when a tile decodes or the source resets.
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.
A station whose `station_information` publishes no capacity takes bikes plus
docks from each status poll (15 until its status arrives), not from the first.
## Terrain height cache bound
@@ -2755,6 +2835,20 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
> name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding
> a new tray rule means checking it against the pinned variants, not just
> against the base rule.
> - **Dock wings are icon-only from 900px down to 721px (2026-10-28):** at
> ≤900px `--dock-side-compact` shrinks the Location and Visual Presets wings
> to 4.25rem, open or closed (an open tray pops out above the dock), which is
> too narrow for their text labels. The labels used to spill over the voice
> control, and the LOCATION label captured taps on the left half of the mic
> button. `src/ui/styles/command-dock-trays.css` now sets the
> labels to `font-size: 0` and drops their `›`/`‹` chevrons in that band, so
> each wing shows only its icon. `CommandDockView.vue` still gives the toggles
> their "Expand/Collapse …" `aria-label` and `title` (`trayWords`), so the
> browser tooltip is the only visible name on hover.
> The Cyber HUD layout is excluded: it sets `--dock-side-compact` to
> 9–9.75rem, which fits its labels. The former ≤620px ellipsis clip is gone:
> the phone shell takes over at 720px, so it never applied to a visible
> dock.
> - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed
> readout now follows a free-text geocode search as well as preset pills
> (`src/locationStatus.js` owns the copy for both), and every other camera
@@ -3394,7 +3488,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> style-switch persistence state. Browser/GPU performance comparison remains
> operator-side; the final accounting records only comparable population
> reductions and deterministic Node allocation/solve measurements. Those
> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits
> allocation gates were calibrated on Node.js 24.14.x and run in CI on the
> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits
> supported product runtimes on Node 24 or 26, while the allocation runner
> separately enforces Node 24 for these two calibrated probes. Each probe
> compiles synchronously and discards explicit-GC
@@ -3839,12 +3934,12 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. 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
'{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
@@ -3853,11 +3948,19 @@ Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, *
`navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. Chinese tags default to `zh-CN`; Traditional Chinese tags
select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?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.
bottom-right corner (bottom-left in Arabic) (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and switches in place:
views re-render, code that published translated text words it again through
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
follow. Nothing is lost; a toast already on screen keeps its words.
- **Right to left.** Arabic (`ar`) is in `RTL_LOCALES`, so `<html dir="rtl">`:
the title, style indicator, both panel stacks and their icon rails swap
sides through logical properties (`inset-inline-*`, `margin-inline-*`), the
dock and corner row reverse their order, and `src/ui/styles/rtl.css` holds
the rest: no letter-spacing on Arabic text, Noto Sans Arabic in the font
stacks, the intel HUD kept as a left-to-right instrument overlay, FM seek
and the radio dial kept left to right. Intl formatters get `ar-u-nu-latn`,
so numbers and dates keep Western digits.
- **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
@@ -3906,31 +4009,31 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + 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 + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + 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/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| 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 📷 | Mapillary street-level imagery (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, server memory cache 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) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| 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) |
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + 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 + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + 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/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| 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 📷 | Mapillary street-level imagery (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, server memory cache 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) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| 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) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
@@ -3957,7 +4060,6 @@ MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception.
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. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Prohibited & Restricted Airspace is off by default (share-link token `6`) and sits in
@@ -4131,8 +4233,7 @@ that does not answer in 20 s is asked once more, and one that is not a line
list is not cached. A click on a line reaches
`handlePick(id, {groundPoint})`, and the provider asks the core
(`actions.openNearest`) for its nearest showable photo within 50 m, without
framing: many photos are archived (their file answers 409, while a HEAD says
200) or have no file, so the six nearest are probed at once with a GET cut
framing: many photos are archived (their file answers 409, while a HEAD says 200) or have no file, so the six nearest are probed at once with a GET cut
off at the headers, and where all are gone the panel says KartaView's photos
there are archived. Only a definite answer (403/404/409/410, or 200) is
remembered; a storage timeout or 5xx is asked again. The open photo's
@@ -4287,7 +4388,9 @@ animate). Disable clears all state.
At the metre/kilometre boundary, route distance labels use the rounded
ten-metre value to select the unit, switching to kilometres at 995 metres;
a 996-metre leg displays as `1.0 km` rather than `1000 m`.
a 996-metre leg displays as `1.0 km` rather than `1000 m`. Whole kilometres
start where the label would round to 10.0 km, so a 9.96 km route displays as
`10 km` rather than `10.0 km`.
**Keys needed: none.** Directions works on a keyless boot, and the layer is off
until the operator turns it on.
@@ -4462,7 +4565,9 @@ to 0.45 at two minutes, 0.20 at ten minutes and 0.08 at fifteen minutes.
display delay and newest-report age, reported speed/stop, vehicle ID, and
available trail duration. Report age is rounded to whole seconds and stays in seconds while that rounded
count is below 90; otherwise it shows completed minutes calculated from the
rounded second count. Its anchor follows the rendered marker; text is
rounded second count. The reported heading wraps after rounding to whole
degrees, so a bearing of 359.5° or more reads `hdg 0°` rather than `hdg 360°`.
Its anchor follows the rendered marker; text is
checked at 4 Hz and published when it changes. DETECT labels retain route,
mode and motion, with the selected label suppressed beside its fuller card.
Candidate membership changes on revisions, coalesced at 4 Hz; paints read
@@ -4578,8 +4683,21 @@ silent for five minutes are removed. Credits appear when the feed is used.
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
Contacts distance labels select metres or kilometres after rounding to the
nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`.
Contacts distance labels, in the panel and on the globe's contact markers,
select metres or kilometres after rounding to the nearest metre, so a
999.5-metre contact displays as `1.0 km`, not `1000 m`, and drop the
kilometre decimal from 9.95 km, so 9,960 m displays as `10 km`.
Distance labels that switch between metres and kilometres share
`distanceParts` / `formatDistanceText` in `src/data/distanceFormat.js`: the
Contacts panel and markers, Directions rows and step cards, placed annotation
labels, the drawn-line measure and `formatRouteDistance`. Each cut is taken on
the value as it prints, and kilometres come back already rounded, so the
plain and localized labels agree. Callers choose the metre step (whole metres;
tens, as annotation labels use; or tens with whole metres under 100, as
Directions uses) and keep their own rendering; the Cockpit's kilometre-only
distance uses `kilometreDigits`. The HUD altitude tag and the spoken view
height still choose their own unit.
At global scale, ambient Radio cluster badges are hard-opacity shared-host
entries: count/category updates and identity replacement do not run keyhole or
@@ -4898,7 +5016,9 @@ silently demoting every later lookup for the session.
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
(`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).
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the
camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera
facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`.
- **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
@@ -5004,7 +5124,7 @@ silently demoting every later lookup for the session.
protected-card path. Disable tears the tier down completely.
Coverage/viewshed semantics unchanged.
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time.
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Both the loaded-catalog and active-TLE lookups read the launch year from the TLE international designator through one parser (`src/sources/tle.js`); a blank or malformed designator is an unknown year, which skips the year check rather than rejecting the match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline,Line truncated
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
@@ -5027,6 +5147,8 @@ 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`.
Both query handlers refuse cross-site and forwarded browser requests through
the shared local-request gate, before any rate-limit, cache or upstream work.
With no configured upstream, each route serves existing cache data (including
expired entries, marked stale with the original cache/retrieval date) without
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
@@ -5227,6 +5349,8 @@ and unreachable upstream (502/504) separately from road geometry.
cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is
queried normally and its source is returned with the selection; fallback with
no airborne records remains an honest no-data result, not an enable failure.
The spoken reply and the card leave out an altitude the feed did not report
rather than reading it as 0 feet.
- Voice treats the parent Context panel and Contacts as separate intents. An
explicit request to open Context expands only `global-context-panel`; it does
not choose a mode. An explicit request to open Contacts expands that parent
@@ -5274,7 +5398,7 @@ and unreachable upstream (502/504) separately from road geometry.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
- **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`.
- **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight.
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame.
- **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. The flights and military context records wrap the track after rounding to whole degrees, so a contact on 359.5° or more reads `0°` rather than `360°`.
- **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `<field>Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload.
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
- **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt.
@@ -5334,13 +5458,36 @@ 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
nearby-landmark recovery stands aside for it — otherwise a typed coordinate
could be replaced by whatever Places finds near the current view. The portable `./search` export
could be replaced by whatever Places finds near the current view. The portable `./search` export uses
Google first when configured, Photon/OpenStreetMap as fallback (including Google
transport failures or declined requests), and the local `/api/geocode` route
over Nominatim as a last resort when neither answers. That route keeps to the
public instance's usage policy: an identifying `User-Agent` and `Referer`, at
most one request per second shared with the cockpit's reverse lookups, answers
cached for five minutes, one upstream call shared between identical searches
over Nominatim as a last resort when neither answers and an operator explicitly
configures `NOMINATIM_URL`. Unset, empty and invalid values disable Nominatim.
`GET /api/geocode/status` answers `{configured}`; the browser asks once per
page, and when Nominatim is not configured neither the last-resort search nor
the voice outline client asks `/api/geocode`, so a default install sends no
lookup it would refuse. A missing or failed probe leaves the capability unknown
and lookups proceed as before.
Explicit public-service configuration retains an identifying `User-Agent` and `Referer`, at
least 1.1 seconds between public search and outline starts, a shared durable
per-install daily allowance and pause state that fail closed when unusable.
The shared state lock covers actual fetch invocation and recording the next
start. An unfinished dispatch marker (`state.json.dispatch.since-<ms>.*`, left
by a crash or a persistence failure) disables further public requests for ten
minutes after the start it names, far beyond any request's 9-second timeout.
A marker exists only inside the lock, so the next lock owner after that window
removes it and restarts spacing from its own clock; the count was persisted
before the interrupted invocation. A marker naming no start keeps refusing
until removed by hand. These controls do not establish an application-wide
public-service allowance or authorize its use. For manual recovery, stop every
process sharing the state file before removing only a confirmed orphan marker,
and preserve the state file's count, pause and next start.
`stats().persistence.dispatchPending` exposes the marker; an unlocked status
read can also briefly see a live dispatch. Do not reset usage state to restore
access.
Answers are cached
for 30 days (search), 90 days (outline) or 24 hours (genuine no result),
one upstream call shared between identical searches
already in flight, a queue bounded at four waiting searches (a burst past it is
refused with 429 and `Retry-After`, never held), and a queued search dropped
once its caller has given up rather than spending a slot on an answer nobody
@@ -5411,7 +5558,7 @@ request viewbox and continues searching without that geographic preference.
- Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner.
- Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch.
- Resolver contract: `src/annotations/annotationResolver.js` converts names/coords/screen pixels into world anchors and optional geometry. The resolver is type-aware: Google Geocode/Places gives a centroid + scope, operator-configured OSM/Overpass supplies admin/place/footprint/street/enclosing-area geometry, route requests use `/api/route`, and ambiguous/far results are rejected or recovered near the current view instead of drawing misleading blobs. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile building and grounds answers also need the tile holding the anchor: when that tile fails to load and only neighbours arrive, no neighbouring building or area stands in. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Guarded Nominatim keeps every accepted result for an ask, ranks an exact name first, then a name holding every asked word, then a partial match; a partial landmark match whose name lacks the ask's last word is refused unless that word appears in the result's address or type (the place around a feature, such as a park around its zoo, is not the feature, while "Space Needle Seattle" still finds the Space Needle). A building word ("Burj Khalifa Tower") also passes on a single-structure result (one a building ask would accept), never on a campus, zoo or district. Each ask is answered with the cached result among the best-named ones whose filled area holds the resolved anchor (read across the antimeridian), else the first; a worse name never wins by containment alone. Nominatim name checks use the bundled packs' place-name key, so "St. Paul" and "Saint Paul" agree there. A street ribbon that holds neither its ring mean nor the anchor is centred on the street's nearest point to the anchor when the street runs within 100 m of it. In tile street names a lone letter is a direction only beside another name word ("N Lamar Blvd"), so "E Street" is not "East Street", and a quadrant ("E St NW", "SE Main St") may go unsaid in the ask, which then takes only the quadrant of the matching street nearest the point. A building or area cut at the tile edge is not complete (past a tile buffer any vertex on the cut line marks it; on an unbuffered edge, an edge running along the boundary does, not a corner that merely touches it): a cut building nearer the point than any whole one leaves the point, and a cut area does not answer grounds. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
- With no detailed-feature capability, annotations retain their pins and say
"Detailed outline unavailable"; location focus reports the same result without
retrying. Missing capability never synthesizes an outline from a radius.
@@ -5450,6 +5597,28 @@ request viewbox and continues searching without that geographic preference.
Berlin inside Brandenburg). Each pack loads on the first ask that needs it,
never at start-up; the analyst region lookup uses the same packs. Gate:
`scripts/qa-admin-outlines.mjs`.
- Bundled place and neighborhood outlines (`src/data/placeBoundaries.js`):
US Census places (`src/data/local_data/us_census_places/`, one file per
state, public domain, `scripts/build-census-places.mjs`) and Who's On First
neighborhoods (`src/data/local_data/wof_neighborhoods/`, quadtree tiles,
per-source licences, `scripts/build-wof-neighborhoods.py`). After the admin
packs, an area ask named at or near the view is drawn from, in order, a
Census place ("Austin"; a state qualifier narrows the search to that state
wherever the camera is: "Springfield, Illinois"), the San Francisco DataSF
pack, then WOF ("Notting Hill", "Le Marais"). "Near" reaches half the view
radius. A district ask takes a neighborhood first and a place only when the
view is inside it; without an entity kind, the candidate holding the view
wins, else the nearer. "City of X" asks for an incorporated place; when
several places still fit and nothing decides, the geocoder answers.
Compound, grounds, monument and "around" asks skip these, as do names that
end like a landmark ("Central Park") unless asked as a district, and a
qualified neighborhood ("Williamsburg, Virginia") goes to the geocoder. A
geocoded city or neighborhood is drawn from the same packs when one carries
the name and holds the point, before any boundary lookup. Only the index and
the state files or tiles around the point are fetched (files under 4 KB are
inlined into the bundle), and the recently used ones are kept (12 state
files, 24 tiles). Gate:
`node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines`.
- Renderer contract: `src/annotations/hybridAnnotationRenderer.js` routes draped `area`/`route` geometry to world-space Cesium rendering and reticles/pins/arrows/callouts to the screen-space SVG renderer. Area labels are screen-space callouts so all captions share one visual language; progressive outline upgrades convert the existing screen group in place when the anchor snaps to the resolved centroid.
- Tooling: voice has `annotate_map` and `clear_annotations` tools. Annotations accumulate and persist by default; clearing is explicit only. Partial failures, approximate synthesized zones, and route fallbacks are returned as structured tool results so the voice layer can be honest.
- Console/dev API: `window.__gevAnnotations.tour()`, `.demo()`, `.annotate()`, `.clear()`, `.count()`, and `.list()` are the deterministic no-mic test surface.
@@ -5457,19 +5626,21 @@ request viewbox and continues searching without that geographic preference.
### Manual Whiteboard Drawing (September 2026)
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is a DISPLAY-rail component template (`src/ui/templates/display-controls.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`.
- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`, and drop the kilometre decimal from 9.95 km. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`.
- **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it.
- **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which.
- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas, the page body and the tool's own label field. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- 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`.
@@ -5497,7 +5668,7 @@ request viewbox and continues searching without that geographic preference.
### 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.
- UTILITIES ▸ **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.
@@ -5522,7 +5693,7 @@ pins and share links already supply complete boxes.
- **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`; opening DETAILS then scrolls its card into view once.
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) 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.
@@ -5561,10 +5732,10 @@ pins and share links already supply complete boxes.
### Split-flap status chips (August 2026)
- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). All three route through `setSplitFlapText()` in `src/splitFlap.js`; there is no other writer of those three elements. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: `ensureHost()` re-validates the shell on every call, and the long-lived `Text` node is never replaced.
- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The first call upgrades a chip label into a permanent shell (`ensureHost`): a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling. After that the ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest and lets `_updateTrafficSyncChip`'s own `textContent !==` guard keep working. The shell is built on a tick where the text is NOT changing, so no real label change ever carries a structural mutation.
- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. `setSplitFlapText` is a no-op on unchanged text, which is required — the chips are repainted by a 60 ms and a 500 ms ticker.
- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). ShellFeedback and CctvControls publish them to the `gev-status` store, and the scene chrome renders each label with `SplitFlapText.vue`, which plays the cascade `src/splitFlap.js` plans; there is no other writer of those three labels. When an owner is destroyed it publishes the label `stopped`, which rests it at once. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: the long-lived `Text` node is never replaced.
- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The label is a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling; the component's `v-flap-text` directive writes it, because Vue would patch a lone `{{ text }}` child by setting `textContent` and swap the node. The ONLY text operation for the life of the chip is `node.data = next`. Nothing is reparented, so the label is never transiently empty and the `aria-live` region never sees a removal/reinsertion pair it could announce twice. `element.textContent` is the settled string at every instant, because the cells carry no text at all: both glyphs are CSS generated content (`::before` from `data-flap-prev` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest. Whether the label is on screen is read once the change has rendered, after its chip shows or hides with it; the cells go in before the browser paints.
- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. An unchanged label is not republished, which is required — the chips are recomputed by a 60 ms and a 500 ms ticker.
- **Only what was visible flaps away.** An interrupted cascade (A→B cut short by C) derives each column's outgoing glyph from `visibleGlyphs()` — what that column is actually SHOWING at that instant, which for a column whose stagger has not elapsed is still A, not the pending B. `FLAP_TURN_RATIO` must track the `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css.
- **Columns never renumber mid-cascade — do not "optimise" this away.** For the whole cascade the board keeps one column per index of the LONGER string, each holding its own width; a column the new string does not reach flaps to a BLANK in place (`data-flap-next=" "`) rather than collapsing. Collapsing stacks the absolutely-positioned outgoing glyphs on one x AND lets a later glyph slide into an earlier column, which makes `visibleGlyphs()` lie and the interrupt rule flap the wrong glyph away. Pinned by "a cleared column holds its place instead of letting later glyphs slide left".
- Length changes are eased, never snapped, and the ease is placed so it never fights the flaps (`.gev-flap-sizing`): a GROWING label reserves its columns as the cells go in and eases at the START; a SHRINKING one holds full width for the whole cascade and eases at SETTLEMENT.
@@ -5596,7 +5767,6 @@ Vite's development filesystem deny list excludes local `.gev-logs/` and
dependency `dist/` assets. Checkout glob characters are escaped. This does not
apply to `public/` assets or built-preview serving.
- Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning)
- A successful Pinokio install writes the owner-only `pinokio/.installed`
marker. The nested launcher menu resolves that marker from its own directory:
@@ -5650,7 +5820,7 @@ apply to `public/` assets or built-preview serving.
- GEV MIC control is a glass capsule (var(--glass-bg), blur(24px) saturate(1.4), 999px radius; panel radius in error state).
- The desktop right rail (`#right-context-rail`) owns `DISPLAY`, `CCTV`, its active parameter controls, and `GLOBAL CONTEXT` as one fixed responsive stack in that order. Its compact buttons use the same 176 px width as the left accordion and one consistent 50 px height, share the left stack's 52 px edge inset and measured top baseline across HUD variants, then constrain themselves against visible HUD/chrome rectangles and the remaining vertical corridor. `DISPLAY` is no longer draggable and legacy saved coordinates are ignored.
- The right rail is labeled **DISPLAY** (formerly "MOVE") and groups, in order, HUD, DETECT, Bloom, Sharpen, 3D, Clean-UI (HUD + DETECT promoted to the top). Its expanded controls retain the same compact 176 px width as the right-side tabs instead of growing to the wider Context detail-card width. It starts expanded on first run and respects the user's later `v6` collapse choice. Collapses/expands with directional chevrons (`◀` collapsed, `▶` expanded).
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. DISPLAY may remain open beside one contextual panel; CCTV and Context are mutually exclusive. In Tactical HUD, expanding CCTV or Context hides the other contextual launcher while DISPLAY remains independently available. The most recently opened right-rail panel owns the constrained lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. Minimal and other HUD layouts retain the collapsed launchers; when their active panel exceeds the measured corridor, the rail reserves sibling heights and gaps and scrolls the active panel internally.
- Display and Context use matching 330 px expanded widths and matching compact tab dimensions. The parameter panel is part of Display's expanded content. One right-rail panel is open at a time in every HUD layout and collapsed launchers are hidden; the panel rail beside the stack switches between them (see Panel rails). The most recently opened right-rail panel owns the lane even when it appears later in DOM order; passive restoration and automatic disclosure do not replace that explicit owner. When the open panel exceeds the measured corridor, it scrolls internally.
- At 720px and below both panel stacks (`#left-panel-stack`, `#right-context-rail`) go edge-to-edge and scroll vertically. Every hosted panel's decorative `.panel-glow` is pinned to its panel box there (`inset: 0` in `src/ui/styles/responsive.css`): on desktop the glow overhangs the panel by 18–20 px as harmless paint, but inside a scroll container that overhang is scrollable overflow on both axes and drew a horizontal scrollbar band plus a vertical scrollbar that scrolled nothing but glow whenever CCTV, Context, Data Layers or Authoring was expanded. The glow's blur is ink overflow and never scrolls, so the look is unchanged. Do not fix this by clipping the stacks: `overflow-x: clip` would not remove the vertical scrollbar. Context draws no glow at all (`display: none`), so it is not part of that pin. Pinned by `src/ui/panelRails.test.mjs` against every `(max-width: 720px)` block in the flattened stylesheet.
- `STYLE PRESETS` and `LOCATIONS` start collapsed, expand on intentional hover/click, and auto-collapse after hover leave delay.
- Collapsed mini-status indicators show active style and active location/landmark.
@@ -5722,6 +5892,7 @@ apply to `public/` assets or built-preview serving.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
@@ -5930,9 +6101,11 @@ easier to meet (detection is now on more often), but does not create it.
OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow
refreshes clear old matches and report simulation. `flowCoveragePct` is the
percentage of shown dots on roads with flow, including all unmatched dots in
the denominator and excluding dots hidden by closures. The OSM keyed status
reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch
coverage follows a 5×5 ellipsoid sample of the central screen square.
the denominator and excluding dots hidden by closures. It reads 1–99 whenever
both matched and simulated dots are shown, so rounding never turns a few
matches into `(no matches)` or a few simulated dots into full coverage. The
OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`.
Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square.
Horizon rays are capped at 10 km from nadir (reduced if necessary to fit
16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14
tiles provide near detail, with z12 roads beyond them. Cached tiles serve
@@ -5956,8 +6129,14 @@ easier to meet (detection is now on more often), but does not create it.
Only eligible road geometry receives dots;
reverse one-way lines are reversed. The shared tile source caps each view at
16 tiles per pass (32 OpenFreeMap tiles total), four concurrent requests and 4 MiB per response. OpenFreeMap retains
up to 192 decoded tiles/64 MiB (four times body bytes as a storage estimate);
other tile sources retain the 64-tile/24 MiB defaults. Identical source-version/XYZ
up to 192 decoded tiles/32 MiB in one shared decoded cache (four times body
bytes as a storage estimate); traffic and installations each keep their own
slice of those tiles within 12 MiB and outlines within 8 MiB, charged at the
slice's estimated object size. The charged budgets total 64 MiB, the budget
of the single OpenFreeMap source traffic and installations shared before;
they are estimates, not a measured heap bound, and each consumer slice is a
copy (`structuredClone`) made once per cached tile. Other tile sources retain
the 64-tile/24 MiB defaults. Identical source-version/XYZ
requests share one download/decode, with independent caller cancellation. Parsed road views have a separate 24 MiB/64-entry cap; incomplete
views are not retained as complete snapshots. Buffer geometry is clipped and sub-12 m line slivers dropped; tile
road fragments are not stitched. Roads preserve bends and insert
@@ -6083,7 +6262,7 @@ After the initial broadside launch profile, the ascent chase camera stays in a r
During orbit replay, the camera continues following the selected vehicle but eases its look-at target down toward the vehicle's sub-satellite globe anchor. The range expands when necessary for high-altitude missions, keeping both Earth and the tracked label visible through the full revolution. The active replay clock clamps at the final orbital sample rather than wrapping to ascent progress zero; replay completion therefore leaves the final globe/orbit framing in place and does not return to the launch site.
Reconstructed mission orbits use a small downrange launch-to-insertion arc, so their estimated ground track is not artificially drawn directly over the launch pad in top-down views. The ascent remains connected to the ring at its selected insertion point.
Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; DISPLAY may remain open beside CCTV or Context, while CCTV and Context remain mutually exclusive without persisting forced collapses. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely.
Collapsed right-rail controls use the same 176 px width as collapsed left-rail controls, while expanded right-side detail panels retain their independent widths. DISPLAY starts expanded only on first run and then respects persistence; one right-side panel is open at a time, without persisting the collapses that rule forces. Selecting a dedicated Context mode opens its right-side surface and clears unrelated layers after first snapshotting their exact state. Final exit restores the original enabled set and changed parameters. Cockpit View hides the right-side CCTV control because CCTV is not part of the cockpit rail. Airborne cockpit altitude uses the tracked aircraft's reported aviation MSL altitude, never the potentially negative Cesium terrain/ellipsoid render height; confirmed grounded contacts display `0 ft` without rewriting that source field. A cold photoreal floor shows `ACQUIRING SURFACE` for at most five seconds, then uses the source target-height fallback instead of freezing the camera indefinitely.
Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the active thrust ring is visible; stage-recovery handoff uses a pulsing dot.
## Tooling Snapshot
@@ -6117,7 +6296,10 @@ The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28,
source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA
uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
Node 24 (24.14.0 or later) and 26.x; the Docker image and CI pin 24.22.2,
the current 24 LTS patch, which carries the October 21, 2026 Node.js security
fixes first released in 24.22.1 (including CVE-2026-111497). Use
`npm ci` to reproduce the checked-in dependency tree.
### Traffic city navigation
@@ -6259,7 +6441,9 @@ Receivers publish one record shape from `src/sources/adsbRecords.js`
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
`webusb` (browser SDR) or `feed` (decoder feed).
`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/
skyaware978 `aircraft.json` document into the same records. Voice
skyaware978 `aircraft.json` document into the same records; a row without
`baro_rate` reads its vertical rate from `geom_rate`, where a GNSS-sourced rate
is sent. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna").
@@ -6306,6 +6490,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache,
and destroying clears it. Load ownership prevents cancelled work from adding
entities after teardown.
Every Cesium entity from multipart cable or landing-point geometry stays tied
to its source feature, including generated IDs and IDs that collide with
Cesium's multipart suffixes. Each segment gets its own feature's styling, click
destination and horizon-culling reference, and each source feature has one
reference stem. The source collections are not modified.
Natural Earth regions and neighborhood polygon lookup are package exports.
Their existing lazy loaders, retry behavior, bundled datasets and attribution
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
@@ -6446,11 +6636,11 @@ when their footprints touch or contain one another, never across a seam toleranc
### Shared native dropdowns and numeric fields
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
draw colour retain compact density. Radio filter, tuner gain and FM frequency
- Scene and CCTV selects share the default `gev-field` surface. HUD layout
retains compact density; Draw's colour is a row of swatches, not a select. Radio filter, tuner gain and FM frequency
use the same base while retaining their feature-specific skins and layout.
- The CCTV inline number editor uses `createNumberInput` with its existing
precision and inline density. Enter/blur commit, Escape cancellation, frozen
- The CCTV inline number editor is rendered by its pose chip component with
its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen
camera base, camera-switch guards and disposal remain with CCTV controls.
- Options, values, native keyboard/typeahead, disabled attributes and browser
validity remain native. Frequency bounds, tuner commit timing and calibration
+30 -7
View File
@@ -25,17 +25,39 @@ 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.)
Vue views call the same `t()` and `tn()`. The page's vue-i18n instance (`src/ui/i18n.js`), installed in every view, holds the active locale, and `t()`, `tn()` and the formatters read it from there, so a view that translates while rendering renders again when the locale changes. Views don't use vue-i18n's own `t`: English is the key, `context` picks an entry and plural forms follow `Intl.PluralRules`, which only Core's functions do.
`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
Vue views translate in the template, so their text renders in the active locale:
```vue
<span>{{ t('Normal') }}</span>
<button :title="t('Pin visual presets')">…</button>
<option value="all">{{ t('All', { context: 'radio tag' }) }}</option>
```
Static page markup that no view renders (the loading screen in `index.html`, a host's own markup) opts in with attributes instead, and `npm run i18n` rejects them in a Vue template:
```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. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. 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.
`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. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Markup is translated at boot and again after every language switch.
## Switching language
The language picker switches in place, without a reload. A view that calls `t()` while it renders follows by itself. Code that translates text and hands it on (to a store, a published view object, a layer's state, a DOM node) must word it again when the language changes:
- **Present again.** `onLocaleChange(listener)` from `src/i18n/index.js` calls the listener after each change and returns the function that removes it; call that at teardown. The listener presents the view again from what it keeps, as `layerPanel.js` and `cockpitController.js` do.
- **Keep the English, translate when presenting.** Store `msg()` text or a function that words it, not its translation: the voice control takes `update(part, { text: () => t('MIC') })`, the scene director's status and the SDR receiver's messages keep such functions, and `imagerySplit.js` takes its titles that way.
- **Translated constants in a view** go in `computed`, not in a plain `const` set up once.
A toast already on screen keeps its words.
## Rules
@@ -66,16 +88,16 @@ An empty translation is reported, and so is one left as the English, plural form
`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-*`.
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. In a Vue template, static text or a static `title`, `aria-label` or `placeholder` (rather than `t()`), and any `data-i18n*` attribute.
- **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 |
| 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 |
| Page markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element (not in a Vue template) |
| 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.
@@ -84,6 +106,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca
| File | What it holds |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/ui/i18n.js` | the page's vue-i18n instance: the active locale and the catalogs |
| `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 |
+3 -3
View File
@@ -71,9 +71,9 @@ Status: Open (owner-accepted 2026-07-08, documented)
a few stragglers take one more poll.
- **Born-grounded first poll:** a contact first seen on the ground with no altitude
data renders at the geoid for ≤1 poll until its floor cell warms.
- Full context, improvement ideas, and the verification oracle
(`scripts/qa-floor-verify.mjs`):
`docs/superpowers/reports/2026-07-08-height-datum-handover.md`.
- Verify floor-placement behavior with `scripts/qa-floor-verify.mjs` against a
running local app; set `QA_BASE_URL` when it is not on the default port
(`http://localhost:4173`).
---
+4 -4
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works
@@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keye
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 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 applies at once, without a reload.
## The check
@@ -44,7 +44,7 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
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`.
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`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
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.
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
+6
View File
@@ -11,12 +11,18 @@ scene playback and voice. It delegates; new behavior belongs with its owner.
| `cockpitCoordinator.js` | Cockpit entry/rollback, readouts and the single Display portal |
| `visualSettings.js` | Effects, detection overrides, visual restoration and render-loop lifetime |
| `panelChrome.js` | Disclosure, docking, layout and Cockpit panel restoration |
| `panelNavigation.js` | Desktop panel rails: each side's offered panels and the open one |
| `aircraftDisplay.js` | Shared commercial/military 3D display preferences and controls |
| `layerBindings.js` | Data-manager attachment, selected-tracker adoption and camera-entry listeners |
| `displayBindings.js` | Keyboard/display subscriptions and frame-rate monitor lifetime |
| `shareRestoration.js` | Initial share/layer restoration and cancellation |
| `shellFeedback.js` | Loading feedback, notices and toast lifetime |
Owners that present UI do so through Vue: they publish plain view objects to a
Pinia store in `src/ui/stores/`, views in `src/ui/views/` render them, and user
actions return through the handlers the owner connects to that store. See
[Building UI](../CONTRIBUTING.md#building-ui).
Owners receive named services, elements, operations and readers. They do not
receive the application shell itself. Readers resolve a replaceable collaborator
at use time; operations cross an ownership boundary without copying its state.
+368
View File
@@ -0,0 +1,368 @@
# Vue migration
The application UI moves from static templates plus imperative DOM wiring to
Vue 3 components with Pinia stores. The migration keeps the experience the
same: element ids, classes, the global stylesheet, translations, keyboard
shortcuts, voice actions, share links and the phone layout all behave as
before. The one intended change is the panel rail described below.
## Goals
- Views are Vue single-file components; state that several views read lives in
Pinia stores rather than in DOM classes.
- The owners listed in [UI-OWNERSHIP.md](UI-OWNERSHIP.md) keep their
responsibilities. A component replaces DOM construction; it does not add a
second owner of the same state.
- Public methods used by layers, scene playback, voice and host applications
(`setPanelCollapsed`, `setContextMode`, `setStyle`, share and location
subscriptions) keep their signatures.
- Ids and classes used by browser QA and host applications stay stable until a
host can use an explicit extension point instead.
## The panel rail
Each desktop sidebar gets a floating icon rail that lists every panel available
on that side. Selecting an icon opens that panel; selecting it again closes it.
One panel is open per side at a time, so the rail replaces the accordion
stacking. Panels that are not available in the current mode (for example
Weather before a weather layer is active) are shown disabled or omitted as the
panel's own availability rule dictates. The rail hides with the rest of the
chrome in Cockpit, scene playback, recording and the clean view.
Phones keep the existing bottom tab bar and sheets; the rail is desktop only.
Voice and keyboard openings go through the same panel store, so they follow the
one-panel rule too. Share links continue to record which panels are open.
## Component kit
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`,
`UiButton`, `UiPanel`, `UiSegmented`, `UiSlider`, `UiChip`, `UiSelect`,
`UiBadge` and `UiEmpty` so far, with more joining as the controls they serve
are converted. One component
serves every use of a control, with props for the differences (`UiPanel`'s
`sideways`, `glow`, `scroll`), rather than a copy per panel. The components
are styled by the existing stylesheet and custom properties; a kit class such as
`ui-button--icon` carries no Core style yet, and a converted control keeps
its own classes beside it. They contain no application state.
`UiPanel` takes a `title`, slots and `v-model:collapsed`, but its
`collapsed` drives only the button and `ui-panel--collapsed`: the root's
`collapsed` class, which hides the body, is drawn at once by `v-panel-state`
from the panel chrome store, and there is no `closable`, since Core's panels collapse rather than close.
`UiSegmented` adds `keyboard` (`none`, `arrows`, or `radio` with
Home and End), since each of Core's groups keeps its own keyboard policy, and
`itemClass`/`activeClass` for Core's segment classes. A key moves focus at
once and asks for a change only when it lands on another option. Every
segmented group in Core is a `UiSegmented`; the old `segmentedControl.js`
adapter is gone.
`UiButton` adds `pressedClass`, Core's class on a pressed toggle (most use
`active`); its pressed classes toggle through `classList`, so a class other
code adds (a voice highlight) survives a press. Every pressed toggle in the
views is a `UiButton` except Bhote Koshi's cinematic button and the
tilt-map button, whose owners still write their state into the DOM. Core's
pressable chips and pills (rail card chips, map sources, cities and places)
are `UiButton`s too; `UiChip` is a non-interactive label chip, used by
the voice card.
`UiSelect` renders the bare native select (Core's labels sit outside it or
come as an `aria-label`) with `v-model`, `options` and `disabled`;
options that carry their own attributes come through the slot, a caller's
`@change` still reaches the select, and `hold` works as in `UiSlider`.
Without `hold`, a select takes its model back after its own change renders,
so a pick its owner refuses goes back; an owner that answers later (the SDR
gain) takes `hold`. Every select in the views is a `UiSelect`.
`UiBadge` takes a `tone` and carries Core's `gev-status-badge`; its
density variants and colours stay caller classes, and it renders the CCTV
source and calibration badges and the rail card badges. `UiEmpty` takes
a `title` and `hint`, but Core's empty states are one line, so the title
is the text itself rather than a bold heading; the Scene shot list, mission
roster and imagery strip use it. The locale picker's and the mission tables'
empty rows stay a paragraph and table cells.
`UiSlider` keeps the native range input rather than drawing a
`role="slider"` track, so Core's keyboard, form behaviour and styling stay;
it takes `v-model`, `min`, `max`, `step` and `disabled`, emits a
number, and adds `hold` (never rewrite a focused handle, and take the model
back on blur) and `unstyled` (the browser's own range, as Bhote Koshi's
are, without Core's slider surface). A caller's own input, change and blur
handlers still reach the input, and a null model leaves it as it is. It is
a functional render-function component, so the portable `./ui/input` rows
use it without a component compiler. Every range in the views is a
`UiSlider` except the radio tuner's: its owner moves the handle back to the
selected station on a drag's first input without changing the slot, which
a functional component would not render again.
Controls share one skin, so every panel reads the same. Every scrolling
surface carries the kit's `.ui-scroll`, which draws the thin
themed scrollbar from `--scroll-thumb` and `--scroll-track`; the Cyber theme
sets those two tokens, and Cockpit's HUD windows keep their accent tint. A
surface without it would draw the platform's light scrollbar, because the
page declares no dark `color-scheme`. Selects and number fields are
`.gev-field`, which draws its own chevron in place of the platform's arrow
and keeps native spinners dark; ranges are `.gev-quantitative-slider`, with
a feature supplying only its accent.
## Phases
1. **Foundation.** Vue, Pinia and the Vite Vue plugin join the browser build
(`build/vite.js`, so host applications receive them too). One Vue runtime is
created by the shell (`src/ui/vueRuntime.js`) with one Pinia instance that
every view shares; disposing the shell unmounts them. Stores are added
when a view needs one, mirroring state an owner already publishes, so
there is never a second owner of the same state.
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
mirrors each side's panels and the rail renders from it. The rail panels
are `RailPanel`s: the kit's `UiPanel` drawing Core's panel markup, bound to the `gev-panel-chrome`
store, with their titles rendered by `t()` rather than `data-i18n`. Each panel's
collapse button, the dock tray toggles and the
dock pins render the open and pinned state PanelChrome presents to the
`gev-panel-chrome` store, and their clicks call it there. Each panel's
root draws its own state from there too (`v-panel-state`: `collapsed`,
`dock-pinned`, `dock-pinned-top`, `layout-auto-collapsed`,
`accordion-collapsed`): PanelChrome, the rails and the accordion present
it rather than writing classes. The directive applies it in a synchronous
watcher, not on Vue's next render, because the rails measure a panel in
the same task that opens or closes it. PanelChrome and its preference
restore, and the phone shell, read a panel's state back from the store
(`_shownPanel`; the markup's classes before its first presentation), so
once a panel is presented a class written outside the store no longer
counts. The rails, the accordion and the hover disclosure
(`panelDisclosure.js`), which act within the same task as a layout, read
the classes the directive has just drawn. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids,
classes and CSS; text a template renders with `t()` drops its `data-i18n`
marker. The page places each view with a
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
(`src/ui/views/index.js`) renders the view into it before the page is
translated and wired. `createApplication()` calls it too, removing the
hosts of any views its `omitViews` names. Views belong to the page and are
unmounted only when a later call omits them. The loading screen is static markup in the page so it
paints before any script runs. Markup
tests read `renderApplicationHtml()` from `src/testSupport`, which renders
the views on the server. Templates compile with Vue's `preserve`
whitespace, which keeps the whitespace between elements as a single
space, so a view renders as its template did; no view markup sits under a
`white-space: pre` rule. Application views (`src/ui/views/*View.vue`) are
kept out of Prettier; other components are formatted with strict
whitespace sensitivity, so their source spacing is what renders. The
markup has moved, unchanged:
every template except the loading screen is a view.
Next each view takes over its wiring: markup an owner builds with
`createElement` becomes component markup that renders from a Pinia store
the owner publishes to, and user actions come back to the owner through
the store, so the owner keeps every decision. Component text uses
`t()`, which follows the locale as the view renders; the views carry no
`data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and
keep their imperative entry points until their owners are components:
the rail cards, card blocks and timeline (`src/ui/rail/`) render through
Vue's synchronous `render()` behind `createRailCards()`,
`createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and
`SceneShotList` over the `gev-scenes` store; the panel's buttons, status
line, progress bar and the runtime chip render the panel SceneControls
presents there, and the buttons and file chooser call its actions), Weather (done: the owner
renders `WeatherReadout` from the view it computes), Recent imagery
(done: `RecentImageryReadout` and its day cards, the same way),
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
store that `StreetLevelControls` publishes to; the controls keep their
listeners, EXPAND, focus and resizing), CCTV (done: `CctvPanel` renders
the whole panel from the `gev-cctv` store, including the camera picker,
the inline pose editor, the frame preview, source badge and typed
summary; `CctvControls` keeps its listeners, the frame requests and the
live video surface), Layers (done: `LayerPanel` presents each row and
renders `LayerRows` synchronously; keyed chips and list items keep focus
through the refresh their own click triggers, and clicks re-read the live
descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom, sharpen and the
active style's parameter sliders from the `gev-display` store, which
VisualSettings publishes to and answers the sliders through, and the 3D
aircraft group, the Draw controls and the imported file list, which
AircraftDisplay, the draw tool and the file import publish beside it;
the controls call DisplayBindings, the draw tool and the file import
through the store's `connect`/`act`, the newest owner of an action
answering it; `GeoImport` hands keyboard focus on before a focused row
goes, with `v-focus-safe-children`; the
style parameter rows render through Vue behind `createStyleParameters()`,
as a render function so the portable `./ui/input` entry needs no
component compiler; the other groups follow one owner at a time),
Context and Radio (in progress: `RadioPanel` renders the directory row,
station card, transport, volume and playback line from the `gev-radio`
store that RadioControls publishes to and whose controls it answers
through the store's `connect`/`act` (the shared `createOwners()`; a
refused station tag gets the kept tag back from `act`), and `SdrCard` the
local RTL-SDR card that LocalSdrControls publishes beside it and answers
the same way; fields a person edits take their value through
`v-field-value`, which never writes a focused field;
`RadioTuner` renders the directory tuner, where RadioControls keeps the
drag and its slot, takes the slider's pointer, key and value events
through the store and publishes the band, readouts, slider and needle,
and the component lays out the tick tape against the dial's measured
width and lays out the snapped needle before it drops the drag class;
`ContextView` renders the mode switch and the nearby sites
search from the `gev-context` store, which ContextControls reaches only
through its `publish` option, so `./ui/context` needs no Vue;
`AwarenessPanel` renders the Contacts panel that the awareness layer
publishes through `subscribePanel()`, the first layer to hand its rail
content to a view, and `MissionRoster` and `MissionDetail` the Space
Missions roster and selected mission that the launches layer publishes
the same way), the command dock (in progress: the style buttons, the
dock's style mini status and the ACTIVE STYLE corner render the style
VisualSettings publishes to the `gev-display` store; the buttons keep
their owner's listeners; the map source chips and status badge render
what `createMapSourceControls()` publishes beside it, so `./ui/maps`
needs no Vue; the location tray's city and landmark pills, mini status
and search error render what `LocationControls` publishes to the
`gev-location` store, and the search field keeps its listeners; beside
the voice control, the dock teleports each voice pointer reticle to the
body where its `PointerReticle` presents it in the `gev-pointer-reticle`
store), the phone shell (`PhoneTabBar` renders the tabs and open sheet the shell
publishes to the `gev-display` store, teleporting the tab bar and sheet
backdrop to the end of the page from the first publish until the shell
is destroyed; the shell takes their clicks from the document), the
imagery dividers (`ImagerySplitView`, in the scene chrome, teleports each
before/after divider that `createImagerySplit` publishes to the
`gev-imagery-split` store to the body; the split keeps the
drag, keyboard, CSS position and scene split), the status chips
(the top-center loading status and the road network and camera grid
chips render what ShellFeedback and CctvControls publish to the
`gev-status` store; `SplitFlapText` plays the departure-board flip that
`splitFlap.js` plans, writing only the label's one text node; the toast
renders what each owner's `createToast()` publishes there, on that
owner's own timer, and the title bar's FPS readout what
`createFrameRateMonitor()` publishes), the globe actions
(`GlobeActions` renders the top-center bar; its clear, share, tilt,
north-up and reset buttons, and the cockpit's reset, call the shell
through the `gev-globe-actions` store, which also carries a clear or a
reset under way and the camera's tilt and heading
that `bindCameraOrientationControls` publishes each drawn frame it
changes, which only `GlobeCompass` renders; an owner publishes only
while it answers the button, so a shell replaced by a newer one leaves
its state alone), the recording safe frame (the scene chrome renders
the frame `RecordingControls` publishes to the `gev-recording` store),
the Scene actions panel (`SceneInteractions`
teleports what the scene interactions owner publishes to the
`gev-scene-interactions` store into the viewer's container; the owner
keeps the pick handler and focuses the picked action), the data-pack
attribution cards (`DataPacks` teleports what the data-pack presentations
publish to the `gev-data-packs` store into the viewer's container and
stops a removed card's player), the wind inspection marker
(`WindInspectionMarker` teleports the marker into the wind layer's
container where `createWindInspectionMarker()`, now in
`src/layers/wind/inspectionMarker.js` so the portable `./tools` entry
needs no Vue, places it after each rendered frame; an unchanged frame
publishes nothing), the Space Missions replay vehicle
(`MissionReplayOverlay` teleports into the globe container what the
launches layer presents through `subscribeReplayOverlay()`, which the
application shell publishes to the `gev-mission-replay` store, so the
portable `./layers/launches` entry needs no Vue), the Bhote Koshi
reconstruction panel (`BhoteKoshiPanel`, last in the context rail,
renders what the event layer publishes to the `gev-bhote-koshi-panel`
store: each control's text, hidden, disabled, value and attributes as
the layer writes them, unchanged frames sending nothing; the rail takes
`bhote-event-active` while it shows), the full-globe celestial ring
(`CelestialRing` teleports into the globe container what `CelestialRing`
in `src/celestialRing.js` publishes to the `gev-celestial-ring` store and
hands its two canvases back for the owner to paint; the application shell
passes the store in, so the keyhole helpers other packages import need no
Vue), the screen-space map annotations (`ScreenAnnotations` teleports to
the body the board the screen annotation renderer publishes to the
`gev-screen-annotations` store after each rendered frame: each mark's SVG
parts as attribute records, the fading marks until they leave; the view
hands back the callout text and mark groups the renderer measures, and
the tracked-entity fade is measured once the frame is in the document;
the styles it injected now live in `src/ui/styles/annotations.css`), the
visible map target list (the scene chrome renders the buttons and status
the world overlay presents through `subscribeWorldOverlayActions()`, which
the application shell publishes to the `gev-world-overlay-actions` store
and answers with `activateWorldOverlayAction()`; the module needs no Vue
for the portable transit entry, and its card and detection canvases stay
painting surfaces it draws on, like the Cesium canvas), the Global
Context compass and contact bearings (`AwarenessDirection` teleports into
the globe container what the awareness layer presents through
`subscribeDirectionOverlay()`, which the application shell publishes to
the `gev-awareness-direction` store, so the portable `./layers/awareness`
entry needs no Vue), the Intel HUD (the `intel-hud` view renders
`#intel-hud`, its corners, edges and bottom bar from the `gev-hud`
store, which IntelHUD publishes to through its `publish` option: the
readouts, the typing summary, the REC blink, visibility, layout variant
and style colors; the vessel layer presents the selected vessel's
readout through `src/hudReadouts.js` instead of writing into the HUD),
the first-run mission launcher (the welcome view renders the card's
visibility, busy state, status line, copy and scroll fade from the
`gev-first-run` store that `initFirstRunExperience` publishes to, and its
choices, DISMISS and "Don't show this again" call the launcher's
controls; a refused save hands the tick back to the view to revert. The
launcher still reads the rendered card for its hit test, keyboard trap,
focus and overflow measurement), then
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest
contact and weather toggle, `CockpitView` the instruments, destination
arrow and vision selector, and `CockpitRoute` the flight plan card, from
the `gev-cockpit` store that the Cockpit controller publishes to,
starting from `cockpitBriefPages.js`, `cockpitContextState.js` and
`cockpitInstrumentState.js`, which both read; the odometer readouts
(`CockpitRollingValue`) keep their throttle and roll in
`cockpitRollingValue.js`. `CockpitSignalList` renders the live signal
rows; `v-focus-safe-children` turns Vue's move of the focused row into
moves of the rows around it, and hands focus to the shown briefing tab
before the focused row is removed, so a keyed list reorders several times
a second without dropping keyboard focus. The instruments publish on
every HUD update (about 10 Hz); in a dev build their Vue patch costs
about 0.5 ms, close to the DOM writes it replaced. The controller also
publishes which surfaces are shown: the entry button and TR-3B chip in
the Context panel, the HUD with its exit and reset buttons and briefing
card, and the Contact card. Focus moves to the exit button, and back to
the entry, only after Vue renders them. The cards' collapse is part of
that presence too: each card's `data-collapsed` and its toggle's words
and icon render from it. The controls keep the controller's listeners,
and the cards' layout stays with the controller, which reads their
visibility when it lays them out. Its layout pass publishes the utility
strip's measured fit (the HUD's anchor and height limits, whether only the
expanded control fits and which control is hidden from assistive
technology), which `CockpitView` and `CockpitUtilityControls` render; the
briefing and Contact cards' positions stay imperative, because the same
pass measures them straight after writing them.
`CockpitUtilityControls` renders the Display
and Radio launchers, which popover is open and the compact Radio
controls from the `gev-radio` store that RadioControls publishes to and
answers through. The Cockpit display portal publishes the Display
popover's slots to the `gev-display` store while Cockpit is on, and
`DisplayPanel` teleports its HUD, Detection, Parameters and 3D groups into
them and back; the portal keeps each surface's scroll position and puts
back focus the move dropped).
Layers that write into the right rail directly gain a slot instead.
4. **Cleanup.** The build-time template expansion is retired: `index.html`
lists the view hosts and the loading screen itself. Every owner that
showed UI state through hand-written DOM writes now publishes to a store
that a view renders, apart from the imperative parts this document
records surface by surface; [Building UI](../CONTRIBUTING.md#building-ui)
now requires the pattern for new UI. The page-level markup tests read
`renderApplicationHtml()`, the real views rendered on the server, and new
tests mount components; three tests still read a `.vue` file's source
(`cyberTheme`, `panelStorageDocs`, `geoFileImport`). The views translate with `t()`
while rendering, and vue-i18n holds the locale they read; the attribute
translation pass is left for static page markup (the loading screen and a
host's own markup). An explicit API for hosts to register panels and rail items is
deferred until a host adds one: no host does today (the hosted
application's account interface is its own markup), and the first such
panel, the hosted timeline, will shape it.
## Verification
- Unit tests: stores and components run under `npm test`. Single-file
components are compiled by a test loader; component tests render with
`@vue/test-utils` and `happy-dom`. `npm test` registers the loader; to run
one file that reaches a component (including anything importing the
shell), use `node --import=./build/registerVue.js --test <file>`.
- Never pass happy-dom nodes to `assert.equal` or `deepEqual`: a failure
prints both operands, and a node reaches the whole page and window, which
runs the process out of memory. Compare nodes with `assertSameNode()`
(`src/testSupport/domAssert.mjs`).
- Panels lifted out of a view keep its verbatim markup in
`src/ui/views/panels/`, which Prettier leaves alone like the views.
- Visual parity: before/after screenshots of every panel at desktop and phone
widths, in the default and Cyber themes, with the rail as the only expected
difference.
- Browser QA: the selector-based `scripts/qa-*.mjs` suites keep passing.
- `npm run format:check`, `npm run check:boundaries`, `npm run i18n` and
`npm run build`.
+24 -15
View File
@@ -12,7 +12,7 @@ A rail panel uses an outer owner with its existing stable `id`, matching
`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
surface. Keep the header and any outward popup outside the scrolling body:
```html
```vue
<section
id="example-panel"
data-panel-id="example-panel"
@@ -21,14 +21,11 @@ surface. Keep the header and any outward popup outside the scrolling body:
<div data-panel-surface>
<header data-panel-header>
<h2 class="panel-title panel-surface-title">Example</h2>
<button
class="panel-collapse-btn panel-surface-control"
data-collapse-target="example-panel"
aria-expanded="true"
aria-label="Collapse Example"
>
−
</button>
<PanelCollapseButton
class="panel-surface-control"
target="example-panel"
:name="t('Example')"
/>
</header>
<div data-panel-body data-rail-scroller>
<label>Filter <input class="panel-surface-control" type="text" /></label>
@@ -92,15 +89,27 @@ Do not copy the rail example's `.panel-collapse-btn` class into the dock:
existing dock rules intentionally hide that class. The generic surface skin
does not replace the dock's disclosure policy.
The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]`
at initialization and routes state through `setPanelCollapsed`. For additions
A panel with Core's standard header (glow, title, badges, divider, actions
and collapse button) is a `RailPanel` (`src/ui/rail/`): the kit's `UiPanel`
bound to the `gev-panel-chrome` store, with its state classes drawn by
`v-panel-state` and its button's click asking `PanelChrome` to toggle it.
A panel with its own header uses `PanelCollapseButton`, which renders the
collapse button (`.panel-collapse-btn`) from the open state `PanelChrome`
presents to that store; until then, both show their `startCollapsed` state,
which should match the markup's `collapsed` class.
Either way, the click reaches `PanelChrome`'s `setPanelCollapsed`. `PanelChrome` collects the buttons'
targets at initialization and binds each panel's Escape. For additions
present at startup, keep those hooks and the existing chrome lifecycle. A
dynamically mounted panel must bind and dispose its own disclosure using
`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with
the application owning state changes. Do not rerun the whole application chrome
dynamically mounted panel must own its disclosure: its own state, button and
Escape, bound and disposed with `bindPanelDisclosure` /
`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome
initializer just to add one panel. `layoutLeftPanelRail` and
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
host owns layout scheduling, observations and collapse callbacks. Dynamic
host owns layout scheduling, observations and collapse callbacks. A rail
never writes a panel's classes: it calls `onCollapse(panel, {collapsed,
auto})`, and the host draws that state (the application presents it to the
panel chrome store, which each root's `v-panel-state` draws at once, so the
rail can measure the panel in the same task). Dynamic
content that changes size may require an owner-scheduled layout pass or an
owned `ResizeObserver`; adding CSS attributes does not register new observers.
+117
View File
@@ -0,0 +1,117 @@
# Arabic (`ar`)
Catalog: `src/i18n/locales/ar.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation sources: abiyeenzo (#915).
## Style
- Modern Standard Arabic, neutral across regions. Address the user with plain imperatives: «انقر»، «فعّل»، «أعد المحاولة».
- Arabic has no letter case: an UPPERCASE English HUD label becomes the same short Arabic label. Keep labels as short as the English: «مباشر»، «متقادم»، «متوقف».
- Status words in progress use «جارٍ …» («جارٍ التحميل»، «جارٍ الضبط»، «… جارٍ تشغيل اللقطة»), so a status chip never reads as the command next to it («ضبط» is the TUNE button); finished actions use the passive («تم الحفظ»، «أُلغي التحميل»). Data that refreshes itself «يُحدَّث» (يتحدّث means "speaks"). A sentence about a `{placeholder}` whose gender and number vary (tool, layer and cohort names, call signs, vessel names) stays impersonal: «{layers}: تم التفعيل للتو»، ««{name}» قيد الاستخدام»، «{id}: لا بيانات واردة الآن»، «لم يعد {name} ضمن البيانات المحمّلة».
- A label followed by its value takes a colon, so it does not read as a sentence: «الصوت: نشط»، «السياق: {value}»، «الاحتواء: {percent}%». A named mode or control quoted in a sentence takes «»: «يظهر في وضع «كثيف» فقط».
- Arabic punctuation in Arabic text: ، ؛ ؟ and «» for quotes. Keep `·` as the HUD separator, `—` with spaces, and `…`.
- Arrows that show a sequence point the way Arabic reads: «الإشارات ← الأخبار ← المحلي», «{origin} ← {destination}».
- Product names, provider names and codes stay in Latin letters: Google, Cesium ion, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, Street View, Mapillary. Aviation and camera abbreviations stay Latin too: HDG, FOV, BRG, CRS, GS, TRK, V/S, SQUAWK. Local place names from the Bhote Koshi scene (Rasuwagadhi, Bidur, Timure…) stay Latin, and a site known by two names takes one Arabic noun in front: «جسر Mailung / Upper Trishuli»; well-known cities and countries are written in Arabic (أوستن، طوكيو، نيبال).
- Months are the international names used across the Arab world: يناير، فبراير … أغسطس.
## Digits and direction
- **Western digits (0–9)**, as in the catalog. The HUD reads like an instrument panel, and mixing ٠–٩ with Latin codes and coordinates misreads. `intlLocale('ar')` in `src/i18n/locale.js` passes `ar-u-nu-latn` to every Intl formatter, so browsers that default to Arabic-Indic digits for an Arabic region still show 0–9.
- **Right to left.** `ar` is in `RTL_LOCALES`, so the page gets `<html dir="rtl">`. The title, style indicator, both panel stacks and their icon rails swap sides; the dock and the corner row reverse. The intel HUD (classification block, MGRS, coordinates, GSD) stays a left-to-right instrument overlay; its translated lines read right to left inside it. FM seek and the radio dial stay left to right, as on any receiver. The rails' screen-reader names follow the mirrored layout: «Left panels» is «اللوحات اليمنى». Layout rules: `src/ui/styles/rtl.css`.
- Latin runs inside Arabic sentences (product names, codes, `{placeholders}` filled with English data) are isolated by the bidi algorithm; write them as plain text, without manual direction marks.
- Position counters: a translated counter says «من» («القناة {index} من {total}», «الإطار {index} من {total}»); a spaced slash between two isolated numbers swaps them in right-to-left text. Counters the code builds from bare numbers (`01 / 25`, `1 / 3`) are shown left to right by `rtl.css`.
- Code stays left to right: the JSON fields of the scene editor are LTR in every language.
- Comparison signs `<` and `>` are mirrored glyphs in right-to-left text («< 2%» shows as «> 2%»). Write them as words: «أقل من 2%»، «أكثر من 10%»، «أقل من ساعة». A legend's open upper bound («300+», «-40») is isolated by `formatScaleLabel()`, so its sign stays where it is written; `formatScaleRange()` isolates the unit after the range too («°C», not «C°»).
## Units
Distances and speeds take Arabic abbreviations after the number, with a space: كم، م، كم/س، كم/ث، م/ث، ميل/س، عقدة (knots)، قدم (feet)، مم. Time: ث (seconds)، د (minutes)، س (hours)، يوم; compact age labels («3d», «5D AGO») use ي: «{count} ي»، «قبل {count} ي», since a plain string cannot make يوم agree with the count. Area: آكر (acres, the value is not converted; فدان is a different unit). Money: the word دولار after the amount, not a bare `$`: «{amount} مليون دولار». Scientific and radio units stay Latin: MHz، dB، dBFS، dBZ، hPa، MW، MiB. `°` and `%` stay. Hemisphere letters in coordinates: ش (N)، ج (S)، ق (E)، غ (W), as in `21.33ش`.
## Plurals
`Intl.PluralRules('ar')` uses all six CLDR categories, and a `tn()` entry needs every one:
| Form | Counts | Example |
| ------- | --------------------------- | ------------- |
| `zero` | 0 | 0 مصدر |
| `one` | 1 | مصدر واحد (1) |
| `two` | 2 | مصدران (2) |
| `few` | 3–10, 103–110… | 3 مصادر |
| `many` | 11–99, 111–199… | 11 مصدرًا |
| `other` | 100–102, 200–202, fractions | 100 مصدر |
Every form must keep `{count}`. For one and two, Arabic names the quantity with the noun itself (مصدر واحد، مصدران), so the number follows in parentheses. `few` takes the plural noun; `many` the accusative singular (مصدرًا، مفتاحًا); `zero` and `other` the genitive singular. When a word next to the number cannot agree in every form, or a short chip has no room, put the number after a colon instead: «القريبة: {count}»، «الملفات المحددة: {count}». Where neither fits, «من أصل {total} من السجلات» agrees with any number. A standalone unit label («الدقائق») is never glued after a number: the voice-timeout options («3 دقائق»، «15 دقيقة») come from `Intl.NumberFormat` unit formatting, not from the catalog.
## Glossary
| English | العربية |
| ------------------------------------------ | ---------------------------------------------------------- |
| layer / data layer | طبقة / طبقة بيانات |
| feed / source | مصدر بيانات (short: مصدر) / مصدر |
| LIVE (status) / live data | مباشر / البيانات المباشرة (not حي) |
| Live ships, live detections (descriptions) | بيانات مباشرة عن السفن، رصد مباشر (not the adverb مباشرةً) |
| track, tracking | تتبّع (always with the shadda) |
| SIMULATED | محاكى (status) / محاكاة |
| stale / degraded / unavailable | متقادم / متدهور / غير متاح |
| ON / OFF (state) | مفعّل / متوقف |
| retry | إعادة المحاولة |
| key (API key) / POWER UP | مفتاح (مفتاح API) / تشغيل |
| Provider Settings | إعدادات المزوّدين |
| globe / map / map source / basemap | الكرة الأرضية / الخريطة / مصدر الخريطة / الخريطة الأساسية |
| globe map (map source drawn on the globe) | خريطة على الكرة الأرضية (not خريطة كروية) |
| tiles | بلاطات |
| aircraft / flight / military flights | طائرة / رحلة / الرحلات العسكرية |
| Live Flights / Live Vessels (layers) | تتبّع الرحلات / السفن المباشر (not الرحلات المباشرة) |
| call sign | رمز النداء |
| vessel, ship | سفينة |
| satellite | قمر صناعي |
| launch (rocket) / space mission | إطلاق / مهمة فضائية |
| contact (tracked object) | هدف |
| installation, site / mapped | منشأة، موقع / مرسوم |
| camera / CCTV camera | كاميرا / كاميرا CCTV |
| earthquake / active fire / fire perimeter | زلزال / حريق نشط / محيط حريق |
| containment (wildfire) | احتواء (not السيطرة) |
| cyclone advisory | نشرة إعصار |
| scene / shot / scene recipe | مشهد / لقطة / قالب المشهد |
| briefing / panel / dock | إحاطة / لوحة / الشريط السفلي |
| preset / visual preset | إعداد مسبق / نمط بصري |
| Scope (circular viewport mask) / keyhole | المنظار / الفتحة |
| Clean UI / EXIT CLEAN VIEW | واجهة نظيفة / الخروج من الواجهة النظيفة (not العرض) |
| cockpit | القمرة (قمرة القيادة) |
| heading / altitude / ground speed | الاتجاه (HDG) / الارتفاع / السرعة الأرضية |
| Street Level / Street View | مستوى الشارع / Street View |
| Recent Imagery | الصور الحديثة |
| Global Context / Context | السياق العالمي / السياق |
| radio station / tuner / gain | محطة / موالف / الكسب |
| broadcaster (radio stream) | محطة البث (not المحطة المُذيعة) |
| VOLUME (slider) / VOICE | مستوى الصوت / الصوت |
| decoder (SDR) | مفكّك |
| transit / bike share | النقل العام / الدراجات المشتركة |
| Previous / Next | السابق / التالي |
| compass points (N, NNE, …) | شمال، شمال شمال شرق، … |
| forecast / valid (time) / issued | توقع / عند، وقت التوقع (not صالح حتى) / صدر |
| observation / observed | رصد / مرصود |
| heard (aircraft received by the SDR) | الملتقطة |
| upstream (camera feed) | المصدر الأصلي (in every CCTV message, not bare المصدر) |
| field (weather) / model | حقل / نموذج |
| parameters / ellipsoid | المعلمات / المجسّم الإهليلجي |
| scene cloud | غيوم المشهد |
| box (Recent Imagery area) / pin | منطقة / تثبيت |
| width (box size) / view (current camera) | اتساع / العرض الحالي (عرض alone reads as display) |
| N/A | غير متاح (no abbreviation) |
| METRO (view band) / REGIONAL | حضري (not حاضرة) / إقليمي |
| THIS (voice pointer) | هذا |
| outline / mark (map annotation) | مخطط / علامة، تعليم |
| share (a view or link) | مشاركة |
| AI agent | وكيل ذكاء اصطناعي |
| spacecraft | مركبة فضائية (مركبة alone is any vehicle) |
| stage (rocket) / downrange | مرحلة / على امتداد المسار |
| landing position: CONFIRMED / PAD (RTLS) | مؤكَّد / منصة الإطلاق |
| recovery (rocket stage) | استعادة (not استرجاع) |
| Arrive at your destination (route step) | الوصول إلى وجهتك (a step, not وصلت) |
| forecast lead hours | مدة التوقع بالساعات (not ساعات المهلة) |
| cel-shaded | بتظليل كرتوني |
| (debris-)dammed lake | بحيرة خلف سدّ (ركامي) |
| frame (bring into view) | إظهار في الإطار |
| story beat | مرحلة القصة |
+20 -18
View File
@@ -21,31 +21,33 @@
<link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,search,settings,skip_next,skip_previous,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<!-- gev:template scene-chrome -->
<div class="gev-view" data-gev-view="scene-chrome"></div>
<div class="gev-view" data-gev-view="cockpit"></div>
<div class="gev-view" data-gev-view="display-controls"></div>
<div class="gev-view" data-gev-view="command-dock"></div>
<div class="gev-view" data-gev-view="layer-panels"></div>
<div class="gev-view" data-gev-view="context"></div>
<div class="gev-view" data-gev-view="welcome"></div>
<div class="gev-view" data-gev-view="provider-settings"></div>
<!-- Intelligence HUD Overlay -->
<div class="gev-view" data-gev-view="intel-hud"></div>
<!-- gev:template cockpit -->
<!-- gev:template display-controls -->
<!-- gev:template command-dock -->
<!-- gev:template layer-panels -->
<!-- gev:template context -->
<!-- gev:template welcome -->
<!-- gev:template provider-settings -->
<!-- gev:template hud-loading -->
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
+692 -14
View File
@@ -19,9 +19,16 @@
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
"satellite.js": "^6.0.2"
"pinia": "^2.3.1",
"satellite.js": "^6.0.2",
"vue": "^3.5.44",
"vue-i18n": "^11.4.15"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"happy-dom": "^20.14.6",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
@@ -33,6 +40,52 @@
"node": ">=24.14.0 <25 || >=26 <27"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.11"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.11",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@cesium/engine": {
"version": "22.3.0",
"resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-22.3.0.tgz",
@@ -537,6 +590,20 @@
"node": ">=18"
}
},
"node_modules/@happy-dom/global-registrator": {
"version": "20.14.6",
"resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.14.6.tgz",
"integrity": "sha512-Xd2FwCdm0cAZ+EG0upp69tpxBT9jtvVxaZv1lnbgWsNaZyEuPs0PojYgvCmtQ1RRdRCYCyWTeLjn3Z0aSxDchA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"happy-dom": "^20.14.6"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1064,6 +1131,73 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.15",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.15.tgz",
"integrity": "sha512-yHNg7+M6VKQuj+KUO/CrtcBMm3iAspvfFe1H/eKI0TP0pn2T2srBF1gEb/TL3c8RA/BZZGOIySwtZD0i+9/hhw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.15",
"@intlify/message-compiler": "11.4.15",
"@intlify/shared": "11.4.15"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.15",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.15.tgz",
"integrity": "sha512-NFV9LsP720IkR6BHNfUYBSkd/abocWimcOWAS63D3spwczy8zDvXCf9WMyhCVQ7FcYkWyECZke18x8D6UNVBFw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.15",
"@intlify/shared": "11.4.15"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.15",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.15.tgz",
"integrity": "sha512-dV6apms3MXAG3jZ/6bAq+IW2IKDGasmjA/Aoi0JVKCStVi6GbzrPruCVbU/Djn1n41w5EiM3KdnpTuo6I1qe5w==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.15",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.15",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.15.tgz",
"integrity": "sha512-jlTJkM4bMdr267e1iUE2RMNz1LyoYU6IPReKjnQM6RvipEBUVwmMHbeUckDcOmR+WeungEGjaNmSQJdQzJcOfQ==",
"license": "MIT",
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@jtarrio/signals": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@jtarrio/signals/-/signals-0.10.1.tgz",
@@ -1115,6 +1249,13 @@
"node": ">=24.0.0"
}
},
"node_modules/@one-ini/wasm": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz",
"integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==",
"dev": true,
"license": "MIT"
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
@@ -1556,6 +1697,16 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/pako": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz",
@@ -1605,6 +1756,171 @@
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz",
"integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.9",
"@vue/shared": "3.5.44",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz",
"integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz",
"integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.9",
"@vue/compiler-core": "3.5.44",
"@vue/compiler-dom": "3.5.44",
"@vue/compiler-ssr": "3.5.44",
"@vue/shared": "3.5.44",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.29",
"source-map-js": "^1.2.2"
}
},
"node_modules/@vue/compiler-sfc/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz",
"integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz",
"integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz",
"integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz",
"integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.44",
"@vue/runtime-core": "3.5.44",
"@vue/shared": "3.5.44",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz",
"integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.44",
"@vue/runtime-dom": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/shared": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz",
"integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==",
"license": "MIT"
},
"node_modules/@vue/test-utils": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz",
"integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-beautify": "^2.0.0",
"vue-component-type-helpers": "^3.0.0"
},
"peerDependencies": {
"@vue/compiler-dom": "3.x",
"@vue/server-renderer": "3.x",
"vue": "3.x"
},
"peerDependenciesMeta": {
"@vue/server-renderer": {
"optional": true
}
}
},
"node_modules/@zip.js/zip.js": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz",
@@ -1619,6 +1935,16 @@
"node": ">=18.0.0"
}
},
"node_modules/abbrev": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz",
"integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
@@ -1667,18 +1993,54 @@
"pnpm": ">=10.10.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/bitmap-sdf": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz",
"integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/browser-split": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
"license": "MIT"
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
@@ -1762,6 +2124,23 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1843,6 +2222,35 @@
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/editorconfig": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz",
"integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.2.1",
"commander": "^14.0.3",
"minimatch": "~10.2.4",
"semver": "^7.7.4"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=20"
}
},
"node_modules/editorconfig/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1880,6 +2288,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
@@ -1953,7 +2373,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/etag": {
@@ -2056,6 +2475,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
@@ -2079,6 +2516,25 @@
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
"license": "MIT"
},
"node_modules/happy-dom": {
"version": "20.14.6",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.6.tgz",
"integrity": "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
@@ -2138,6 +2594,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true,
"license": "ISC"
},
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
@@ -2157,6 +2620,35 @@
"@types/estree": "*"
}
},
"node_modules/js-beautify": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz",
"integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^3.0.2",
"glob": "^13.0.6",
"js-cookie": "^3.0.8",
"nopt": "^10.0.1"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/js-cookie": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"dev": true,
"license": "MIT"
},
"node_modules/jsep": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
@@ -2216,6 +2708,16 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -2343,6 +2845,32 @@
"dom-walk": "^0.1.0"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -2368,10 +2896,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
@@ -2392,6 +2919,22 @@
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
"license": "MIT"
},
"node_modules/nopt": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz",
"integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==",
"dev": true,
"license": "ISC",
"dependencies": {
"abbrev": "^5.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/nosleep.js": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
@@ -2427,6 +2970,23 @@
"node": ">= 0.8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
@@ -2443,7 +3003,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2459,6 +3018,28 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/polylabel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz",
@@ -2475,10 +3056,9 @@
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"funding": [
{
"type": "opencollective",
@@ -2495,9 +3075,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -2528,6 +3108,13 @@
"node": ">= 0.6.0"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true,
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz",
@@ -2825,7 +3412,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2959,6 +3545,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -3115,6 +3708,81 @@
"rollup": "^2.25.0"
}
},
"node_modules/vue": {
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz",
"integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.44",
"@vue/compiler-sfc": "3.5.44",
"@vue/runtime-dom": "3.5.44",
"@vue/server-renderer": "3.5.44",
"@vue/shared": "3.5.44"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "3.3.14",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.14.tgz",
"integrity": "sha512-Pq3+OnbCiPCHk0R7KSZrx+j/XXaUn2kSQ1epxq60w8CPbByLjC1EJgV51n4WY851sTXUVzsNMuitywdkdVyKjA==",
"dev": true,
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-i18n": {
"version": "11.4.15",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.15.tgz",
"integrity": "sha512-bOoNYoEd/ta4g2EelI9HKxPyMZvTDFoykTNYujDTOGoKDRy5ehKJg8WmMDU7hWqNM5efO7fEGNRuwrBXnwHyDg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.15",
"@intlify/devtools-types": "11.4.15",
"@intlify/shared": "11.4.15",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
@@ -3128,6 +3796,16 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+10 -3
View File
@@ -66,9 +66,16 @@
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
"satellite.js": "^6.0.2"
"pinia": "^2.3.1",
"satellite.js": "^6.0.2",
"vue": "^3.5.44",
"vue-i18n": "^11.4.15"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.14.6",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.5.1",
"happy-dom": "^20.14.6",
"prettier": "3.9.6",
"puppeteer": "^25.16.0",
"sharp": "^0.35.4",
@@ -214,8 +221,8 @@
"./voice/actions": "./src/voice/gevActions.js",
"./annotations": "./src/annotations/index.js",
"./context": "./src/data/contextStore.js",
"./build/html": {
"node": "./build/application-html.js"
"./build/register-vue": {
"node": "./build/registerVue.js"
},
"./application/catalog": "./src/app/catalog.js",
"./standalone/catalog": "./src/standalone/catalog.js",
+46 -34
View File
@@ -111,8 +111,8 @@ const cacheDir = path.resolve(
);
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
async function fetchPinned(name) {
const { url, sha256 } = SOURCES[name];
export async function fetchPinned(name, source = SOURCES[name]) {
const { url, sha256 } = source;
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
let bytes = await readFile(file).catch(() => null);
if (!bytes) {
@@ -137,7 +137,7 @@ const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
function ringAreaKm2(ring) {
export function ringAreaKm2(ring) {
const n = ring.length;
if (n < 3) return 0;
let sum = 0;
@@ -151,7 +151,7 @@ function ringAreaKm2(ring) {
}
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
function signedArea(ring) {
export function signedArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
@@ -209,15 +209,16 @@ function douglasPeucker(points, tolerance) {
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
* farthest vertex from the start so Douglas-Peucker has two anchored halves
* (a closed ring has no chord to measure against). Returns null when fewer
* than four distinct vertices survive.
* than `minVertices` distinct vertices survive (four by default; a pack that
* keeps triangles passes three).
*/
function simplifyRing(ring, tolerance, decimals) {
function simplifyRing(ring, tolerance, decimals, minVertices = 4) {
let open = ring.slice();
const first = open[0];
const last = open[open.length - 1];
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
open = open.slice(0, -1);
if (open.length < 4) return null;
if (open.length < minVertices) return null;
let far = 1;
let farDist = -1;
for (let i = 1; i < open.length; i++) {
@@ -247,7 +248,7 @@ function simplifyRing(ring, tolerance, decimals) {
out[0][1] === out[out.length - 1][1]
)
out.pop();
return out.length >= 4 ? out : null;
return out.length >= minVertices ? out : null;
}
/**
@@ -255,16 +256,17 @@ function simplifyRing(ring, tolerance, decimals) {
* area floors (the largest part always stays), orient outers
* counter-clockwise and holes clockwise, sort parts by area descending.
*/
function simplifyPolygons(polygons, params, tolerance, decimals) {
export function simplifyPolygons(polygons, params, tolerance, decimals) {
const minVertices = params.minRingVertices ?? 4;
const parts = [];
for (const poly of polygons) {
const outer = simplifyRing(poly[0], tolerance, decimals);
const outer = simplifyRing(poly[0], tolerance, decimals, minVertices);
if (!outer) continue;
const area = ringAreaKm2(outer);
if (signedArea(outer) < 0) outer.reverse();
const holes = [];
for (const hole of poly.slice(1)) {
const ring = simplifyRing(hole, tolerance, decimals);
const ring = simplifyRing(hole, tolerance, decimals, minVertices);
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
if (signedArea(ring) > 0) ring.reverse();
holes.push(ring);
@@ -287,18 +289,22 @@ function simplifyPolygons(polygons, params, tolerance, decimals) {
* and a fifth of the minimum tolerance, or failing that unsimplified at two
* more decimals; the caller records `d`.
*/
function simplifyUnit(polygons, params) {
export function unitTolerance(polygons, params) {
const sourceArea = polygons.reduce(
(sum, poly) => sum + ringAreaKm2(poly[0]),
0,
);
const tolerance = Math.min(
return Math.min(
params.maxToleranceDeg,
Math.max(
params.minToleranceDeg,
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
),
);
}
export function simplifyUnit(polygons, params) {
const tolerance = unitTolerance(polygons, params);
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
const fine = simplifyPolygons(
@@ -334,7 +340,7 @@ function encodeRing(ring, decimals) {
return out;
}
function encodeUnit({ polygons, decimals }) {
export function encodeUnit({ polygons, decimals }) {
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
}
@@ -362,7 +368,7 @@ function bboxOf(polygons) {
// ── zip / shapefile / dbf ───────────────────────────────────────────────
/** Read named entries from a zip archive (stored or deflated). */
function unzip(buffer) {
export function unzip(buffer) {
let eocd = buffer.length - 22;
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('zip: end of central directory not found');
@@ -394,7 +400,7 @@ function unzip(buffer) {
}
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
function readShp(buffer) {
export function readShp(buffer) {
const shapes = [];
let offset = 100;
while (offset < buffer.length) {
@@ -431,7 +437,7 @@ function readShp(buffer) {
}
/** dBASE records as plain objects (character and numeric fields). */
function readDbf(buffer) {
export function readDbf(buffer) {
const count = buffer.readUInt32LE(4);
const headerLength = buffer.readUInt16LE(8);
const recordLength = buffer.readUInt16LE(10);
@@ -458,7 +464,7 @@ function readDbf(buffer) {
}
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
function shpPolygons(rings) {
export function shpPolygons(rings) {
const outers = [];
const holes = [];
for (const ring of rings) {
@@ -475,7 +481,7 @@ function shpPolygons(rings) {
return polygons;
}
function pointInRing(ring, x, y) {
export function pointInRing(ring, x, y) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
@@ -742,18 +748,24 @@ async function writePack(relative, pack) {
);
}
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
}
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env node
/**
* Build the bundled US places pack read by `src/data/placeBoundaries.js`:
*
* src/data/local_data/us_census_places/index.json state list, boxes, meta
* src/data/local_data/us_census_places/<ST>.json one file per state
* src/data/local_data/us_census_places/files.js their URLs, for bundlers
*
* Source: US Census Bureau 2025 cartographic boundary places
* (cb_2025_us_place_500k, public domain), pinned by URL and SHA-256, so a
* rerun reproduces the pack byte for byte. The download is cached outside the
* repository (default: the system temp directory).
*
* Geometry uses the county pack's simplification and encoding
* (scripts/build-admin-packs.mjs) with two differences: triangles are kept,
* and a unit whose simplified rings cross themselves or each other is
* simplified again at half the tolerance (finer precision last). Then
* `scripts/pack_geometry.py repair census` checks every feature as a whole
* MultiPolygon under GEOS and repairs the ones that are still invalid (rings
* touching themselves, overlapping parts), so it needs Python with
* shapely==2.1.2 on GEOS 3.13.1.
*
* Usage:
* node scripts/build-census-places.mjs [--cache <dir>] [--python <exe>]
*/
import { execFileSync } from 'node:child_process';
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PARAMS as ADMIN_PARAMS,
encodeUnit,
fetchPinned,
readDbf,
readShp,
shpPolygons,
simplifyPolygons,
unitTolerance,
unzip,
} from './build-admin-packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'src/data/local_data/us_census_places');
export const SOURCE = Object.freeze({
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_place_500k.zip',
sha256: 'ce0e4019ecd4123d03d53aaa936eed0459b82e3e14b89a3dcd4d5e8b3308627d',
});
/** County-pack parameters; triangles are valid rings here. */
export const PARAMS = Object.freeze({
...ADMIN_PARAMS.counties,
minRingVertices: 3,
maxRepairSteps: 8,
});
/** Census LSAD codes for census designated places (unincorporated). */
const CDP_LSAD = new Set(['55', '57', '62']);
// ── validity ─────────────────────────────────────────────────────────────
function orient(ax, ay, bx, by, cx, cy) {
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
return v > 0 ? 1 : v < 0 ? -1 : 0;
}
function onSegment(ax, ay, bx, by, px, py) {
return (
Math.min(ax, bx) <= px &&
px <= Math.max(ax, bx) &&
Math.min(ay, by) <= py &&
py <= Math.max(ay, by)
);
}
/** Whether segments ab and cd share any point (touching counts). */
export function segmentsIntersect(a, b, c, d) {
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 !== o2 && o3 !== o4) return true;
if (o1 === 0 && onSegment(a[0], a[1], b[0], b[1], c[0], c[1])) return true;
if (o2 === 0 && onSegment(a[0], a[1], b[0], b[1], d[0], d[1])) return true;
if (o3 === 0 && onSegment(c[0], c[1], d[0], d[1], a[0], a[1])) return true;
if (o4 === 0 && onSegment(c[0], c[1], d[0], d[1], b[0], b[1])) return true;
return false;
}
/**
* Whether segments of two different rings cross or overlap. Rings may touch
* at a point (a hole meeting its outer ring at a vertex is valid).
*/
function segmentsCross(s, t) {
const [a, b, c, d] = [s.a, s.b, t.a, t.b];
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 && o2 && o3 && o4) return o1 !== o2 && o3 !== o4;
if (o1 === 0 && o2 === 0) {
// Collinear: overlapping by more than a point.
const key = a[0] !== b[0] ? 0 : 1;
const lo = Math.max(Math.min(a[key], b[key]), Math.min(c[key], d[key]));
const hi = Math.min(Math.max(a[key], b[key]), Math.max(c[key], d[key]));
return hi > lo;
}
// One endpoint on the other segment: a crossing only when the rings pass
// through each other there; a touch at a point is allowed.
return false;
}
/**
* Whether the rings of one part ([outer, ...holes], open, integer
* coordinates) are simple: no ring crosses or touches itself away from its
* shared vertices, no two rings cross or overlap (they may touch at a point),
* and no ring is flat.
*/
export function partIsValid(rings) {
const segments = [];
for (let r = 0; r < rings.length; r++) {
const ring = rings[r];
if (ring.length < 3) return false;
let twice = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
twice += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
if (twice === 0) return false;
for (let i = 0; i < ring.length; i++) {
const a = ring[i];
const b = ring[(i + 1) % ring.length];
segments.push({
r,
i,
n: ring.length,
a,
b,
minX: Math.min(a[0], b[0]),
maxX: Math.max(a[0], b[0]),
});
}
}
segments.sort((s, t) => s.minX - t.minX);
for (let x = 0; x < segments.length; x++) {
const s = segments[x];
for (let y = x + 1; y < segments.length; y++) {
const t = segments[y];
if (t.minX > s.maxX) break;
if (s.r === t.r) {
const gap = Math.abs(s.i - t.i);
// Neighbours share exactly one vertex; that is not a crossing — unless
// they fold back over each other.
if (gap === 1 || gap === s.n - 1) {
if (s.n > 3 && collinearOverlap(s, t)) return false;
continue;
}
}
if (segmentsCross(s, t)) return false;
}
}
return true;
}
function collinearOverlap(s, t) {
const shared =
s.a === t.a || s.a === t.b ? s.a : s.b === t.a || s.b === t.b ? s.b : null;
if (!shared) return false;
const p = s.a === shared ? s.b : s.a;
const q = t.a === shared ? t.b : t.a;
if (orient(shared[0], shared[1], p[0], p[1], q[0], q[1]) !== 0) return false;
// Collinear and pointing the same way from the shared vertex: a spike.
return (
(p[0] - shared[0]) * (q[0] - shared[0]) +
(p[1] - shared[1]) * (q[1] - shared[1]) >
0
);
}
function toIntegers(polygons, decimals) {
const f = 10 ** decimals;
return polygons.map((poly) =>
poly.map((ring) =>
ring.map(([lon, lat]) => [Math.round(lon * f), Math.round(lat * f)]),
),
);
}
export function polygonsValid(polygons, decimals) {
return toIntegers(polygons, decimals).every(partIsValid);
}
/**
* Simplify a place like `simplifyUnit`, then halve the tolerance until every
* part is valid; then one more decimal; then the source rings at two more.
* @returns {{polygons: Array, decimals: number, repaired: boolean, valid: boolean}}
*/
export function simplifyPlace(source, params = PARAMS) {
let tolerance = unitTolerance(source, params);
const attempts = [];
for (let step = 0; step <= params.maxRepairSteps; step++) {
attempts.push([tolerance, params.decimals]);
tolerance /= 2;
}
attempts.push([params.minToleranceDeg / 5, params.decimals + 1]);
attempts.push([0, params.decimals + 1]);
attempts.push([0, params.decimals + 2]);
let first = null;
for (let i = 0; i < attempts.length; i++) {
const [tol, decimals] = attempts[i];
const polygons = simplifyPolygons(source, params, tol, decimals);
if (!polygons.length) continue;
first ||= { polygons, decimals };
if (polygonsValid(polygons, decimals))
return { polygons, decimals, repaired: i > 0, valid: true };
}
return first
? { ...first, repaired: false, valid: false }
: {
polygons: [],
decimals: params.decimals,
repaired: false,
valid: false,
};
}
// ── names ────────────────────────────────────────────────────────────────
const GOVERNMENT =
/\s+(?:metropolitan government|unified government|consolidated government|metro government|city and county|urban county)\b.*$/i;
/**
* Everyday names for records whose Census name is formal: "Nashville-Davidson
* metropolitan government (balance)" is also "Nashville"; "Urban Honolulu"
* is "Honolulu".
*/
export function placeAliases(name) {
const out = new Set();
let base = String(name).replace(/\s*\(balance\)\s*$/i, '');
if (base !== name) out.add(base);
const stripped = base.replace(GOVERNMENT, '');
if (stripped !== base) {
out.add(stripped);
base = stripped;
}
if (base !== name && /[-/]/.test(base)) out.add(base.split(/[-/]/)[0].trim());
const urban = /^Urban (.+)$/.exec(name);
if (urban) out.add(urban[1]);
out.delete(name);
return [...out].filter(Boolean).sort();
}
// ── pack ─────────────────────────────────────────────────────────────────
function bboxOf(polygons) {
let w = Infinity;
let s = Infinity;
let e = -Infinity;
let n = -Infinity;
for (const poly of polygons)
for (const [lon, lat] of poly[0]) {
if (lon < w) w = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
return [w, s, e, n];
}
function union(boxes) {
return [
Math.min(...boxes.map((b) => b[0])),
Math.min(...boxes.map((b) => b[1])),
Math.max(...boxes.map((b) => b[2])),
Math.max(...boxes.map((b) => b[3])),
];
}
/**
* The box of a state's places. Alaska crosses the antimeridian: its box is
* taken with western longitudes shifted past 180 when that is narrower.
*/
function unionBox(boxes) {
const plain = union(boxes);
const shifted = union(
boxes.map(([w, s, e, n]) =>
e < 0 ? [w + 360, s, e + 360, n] : [w, s, e, n],
),
);
return shifted[2] - shifted[0] < plain[2] - plain[0] ? shifted : plain;
}
async function build() {
const params = PARAMS;
const source = await fetchPinned('places', SOURCE);
const files = unzip(source.bytes);
const base = path.basename(new URL(SOURCE.url).pathname, '.zip');
const shapes = readShp(files.get(`${base}.shp`));
const records = readDbf(files.get(`${base}.dbf`));
if (shapes.length !== records.length)
throw new Error('places: shp/dbf record counts differ');
const states = new Map();
const stats = { records: records.length, repaired: [], invalid: [] };
records.forEach((record, index) => {
const unit = simplifyPlace(shpPolygons(shapes[index]), params);
if (!unit.polygons.length) throw new Error(`no geometry: ${record.GEOID}`);
if (unit.repaired) stats.repaired.push(record.GEOID);
if (!unit.valid) stats.invalid.push(record.GEOID);
const alt = placeAliases(record.NAME);
const lsad = record.NAMELSAD.startsWith(record.NAME)
? record.NAMELSAD.slice(record.NAME.length).trim()
: '';
let state = states.get(record.STUSPS);
if (!state) {
state = {
st: record.STUSPS,
fips: record.STATEFP,
name: record.STATE_NAME,
features: [],
boxes: [],
};
states.set(record.STUSPS, state);
}
state.boxes.push(bboxOf(unit.polygons));
state.features.push({
geoid: record.GEOID,
name: record.NAME,
...(alt.length ? { alt } : {}),
full: record.NAMELSAD,
...(lsad ? { lsad } : {}),
...(CDP_LSAD.has(record.LSAD) ? { cdp: true } : {}),
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
polygons: encodeUnit(unit),
});
});
if (stats.invalid.length)
throw new Error(`places still invalid: ${stats.invalid.join(', ')}`);
await mkdir(OUT, { recursive: true });
for (const file of await readdir(OUT))
if (/^[A-Z]{2}\.json$/.test(file)) await rm(path.join(OUT, file));
const list = [...states.values()].sort((a, b) => a.st.localeCompare(b.st));
let bytes = 0;
for (const state of list) {
state.features.sort((a, b) => a.geoid.localeCompare(b.geoid));
const text =
JSON.stringify({
st: state.st,
state: state.name,
fips: state.fips,
features: state.features,
}) + '\n';
bytes += Buffer.byteLength(text);
await writeFile(path.join(OUT, `${state.st}.json`), text);
}
const round = (v) => Math.round(v * 1e4) / 1e4;
const index = {
meta: {
title: 'US Census Bureau cartographic boundary places',
source: base,
url: SOURCE.url,
sha256: source.digest,
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
schema:
'states[]: st (USPS), fips, name, count, bbox [w, s, e, n] (e > 180 across the antimeridian). <st>.json: st, state, fips, features[]: geoid, name (NAME), alt?[] (everyday names for formal records), full (NAMELSAD), lsad? (city, town, CDP, …), cdp? (census designated place), d? (decimals when not meta.decimals), label? [lon, lat] (verified filled anchor in degrees when the outer centroid is unfilled), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
decimals: params.decimals,
curation: params,
script: 'scripts/build-census-places.mjs',
},
states: list.map((s) => ({
st: s.st,
fips: s.fips,
name: s.name,
count: s.features.length,
bbox: unionBox(s.boxes).map(round),
})),
};
await writeFile(
path.join(OUT, 'index.json'),
JSON.stringify(index, null, 1) + '\n',
);
await writeFile(path.join(OUT, 'files.js'), filesModule(list));
const count = list.reduce((n, s) => n + s.features.length, 0);
console.log(
`us_census_places: ${count} places in ${list.length} files, ${bytes} bytes; ` +
`${stats.repaired.length} re-simplified to stay valid`,
);
const args = process.argv.slice(2);
const python = args.includes('--python')
? args[args.indexOf('--python') + 1]
: 'python3';
execFileSync(
python,
[path.join(ROOT, 'scripts/pack_geometry.py'), 'repair', 'census'],
{ stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } },
);
}
/**
* The file list as literal URLs, so a bundler emits every state file as an
* asset and the loader can fetch one by its code.
*/
function filesModule(list) {
const lines = [
'// Generated by scripts/build-census-places.mjs; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const STATE_URLS = {',
...list.map(
(s) => ` ${s.st}: () => new URL('./${s.st}.json', import.meta.url),`,
),
'};',
];
return lines.join('\n') + '\n';
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) await build();
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Maintainer-only Who's On First neighborhood pack build (Shapely 2.1).
Reads the pinned Who's On First neighbourhood, macrohood and microhood
archives and the WOF sources registry, keeps current polygon records whose
geometry source matches the reviewed open-licence allowlist, simplifies them
to about 15 m, then applies pinned source/ID exclusions after partitioning, and
writes src/data/local_data/wof_neighborhoods/:
index.json format, licences per source, tile list with boxes
<tile>.json records whose box centre falls in that quadtree tile
files.js their URLs, for bundlers
ATTRIBUTION.md per-source credits
Usage:
python scripts/build-wof-neighborhoods.py --cache /tmp/wof-neighborhoods [--verify]
Requires shapely==2.1.2 on GEOS 3.13.1 (checked). --verify rebuilds in memory
and fails unless every shipped file matches byte for byte.
Downloads go to --cache and are checked against the pinned SHA-256. The
neighbourhood archive is about 400 MB.
"""
import argparse
import collections
import hashlib
import json
import math
import subprocess
import tarfile
from pathlib import Path
from shapely import make_valid
from shapely.affinity import scale
from shapely.geometry import MultiPolygon, shape
import pack_geometry
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / 'src/data/local_data/wof_neighborhoods'
# The legacy distribution is frozen (last published 2025-10-14) and only
# offers `-latest` names; the SHA-256 pins make a changed file fail loudly.
DIST = 'https://data.geocode.earth/wof/dist/legacy'
ARCHIVES = {
'neighbourhood': {
'url': DIST + '/whosonfirst-data-neighbourhood-latest.tar.bz2',
'sha256': '8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455',
},
'macrohood': {
'url': DIST + '/whosonfirst-data-macrohood-latest.tar.bz2',
'sha256': 'f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679',
},
'microhood': {
'url': DIST + '/whosonfirst-data-microhood-latest.tar.bz2',
'sha256': 'e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac',
},
}
# The registry at a fixed commit.
SOURCES = {
'url': 'https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/'
'e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json',
'sha256': 'd8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39',
}
TOLERANCE_M = 15
PRECISION = 5
TILE_MAX_BYTES = 200_000
TILE_MAX_DEPTH = 16
LICENSE_POLICY = json.loads((ROOT / 'scripts/wof-open-licenses.json').read_text())
ALLOWED_LICENSES = frozenset(LICENSE_POLICY['licenses'])
REVIEWED_SOURCES = LICENSE_POLICY.get('reviewedSources', {})
RECORD_EXCLUSIONS = LICENSE_POLICY['recordExclusions']
FIELDS = ['id', 'name', 'country', 'type', 'bbox', 'aliases', 'source', 'rings', 'label']
def fetch(cache, name, url, sha256):
path = cache / name
if not path.exists():
print('Downloading', url, flush=True)
# Plain urllib requests are refused by the WOF host; curl is not.
subprocess.run(['curl', '--fail', '-L', '-o', str(path), url], check=True)
digest = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(1 << 20), b''):
digest.update(chunk)
if digest.hexdigest() != sha256:
raise ValueError(f'{name}: SHA-256 {digest.hexdigest()} is not the pinned {sha256}')
return path
# ── geometry ────────────────────────────────────────────────────────────
def simplify(geometry):
"""Simplify at TOLERANCE_M in local metres, snap to the pack grid, keep it
valid, and order parts largest first. Returns (rings, label) where the
label is a point inside the largest part, or ([], None)."""
geometry = MultiPolygon(pack_geometry.polygon_parts(make_valid(geometry) if not geometry.is_valid else geometry))
if geometry.is_empty:
return [], None
lat = geometry.centroid.y
sx = 111320 * max(0.001, math.cos(math.radians(lat)))
sy = 110574
metric = scale(geometry, xfact=sx, yfact=sy, origin=(0, 0))
metric = metric.simplify(TOLERANCE_M, preserve_topology=True)
geometry = scale(metric, xfact=1 / sx, yfact=1 / sy, origin=(0, 0))
# To whole pack units (10^-PRECISION degrees), where GEOS is exact.
units = scale(geometry, xfact=10 ** PRECISION, yfact=10 ** PRECISION, origin=(0, 0))
parts = pack_geometry.ordered_parts(pack_geometry.settle(units))
if not parts:
return [], None
rings = [
[pack_geometry.encode_polyline(pack_geometry.open_ring(p.exterior.coords))]
+ [pack_geometry.encode_polyline(pack_geometry.open_ring(r.coords)) for r in p.interiors]
for p in parts
]
# Check what ships: the geometry decoded from the encoded strings.
decoded = [[pack_geometry.decode_polyline(r) for r in poly] for poly in rings]
why = pack_geometry.problems(decoded)
if why:
raise ValueError(f'encoded geometry invalid: {why}')
point = parts[0].representative_point()
point = shape({'type': 'Point', 'coordinates': [round(point.x) / 10 ** PRECISION, round(point.y) / 10 ** PRECISION]})
return rings, [point.x, point.y]
# ── records ─────────────────────────────────────────────────────────────
def read_archive(path, records):
with tarfile.open(path, 'r|bz2') as archive:
for member in archive:
if not member.name.endswith('.geojson') or '-alt-' in member.name:
continue
feature = json.load(archive.extractfile(member))
if feature['geometry']['type'] not in ('Polygon', 'MultiPolygon'):
records.pop(feature['properties']['wof:id'], None)
continue
records[feature['properties']['wof:id']] = feature
def keep(properties):
deprecated = properties.get('edtf:deprecated', 'uuuu') not in ('uuuu', '', None)
superseded = bool(properties.get('wof:superseded_by', []))
return properties.get('mz:is_current', -1) != 0 and not deprecated and not superseded
def source_table(spec):
lookup = {}
for source in spec.values():
for key in (source.get('name'), source.get('prefix')):
if key:
lookup[key] = source
return lookup
def verify_source_reviews(lookup):
"""Pin exceptional source decisions to the exact reviewed registry evidence."""
registry_commit = SOURCES['url'].split('/')[-3]
for key, review in REVIEWED_SOURCES.items():
source = lookup.get(key)
if not source:
raise ValueError(f'{key}: reviewed source is absent from the pinned registry')
if review.get('registryCommit') != registry_commit:
raise ValueError(f'{key}: review does not match the pinned registry commit')
if review.get('registrySha256') != SOURCES['sha256']:
raise ValueError(f'{key}: review does not match the pinned registry SHA-256')
if review.get('rawLicenseType') != source.get('license_type'):
raise ValueError(f'{key}: reviewed raw licence no longer matches the registry')
text = source.get('license_text') or ''
if review.get('licenseTextSha256') != hashlib.sha256(text.encode()).hexdigest():
raise ValueError(f'{key}: reviewed licence text no longer matches the registry')
if review['rawLicenseType'] not in ALLOWED_LICENSES:
raise ValueError(f'{key}: reviewed raw licence is not admitted by policy')
def licence_allowed(value):
"""Only exact raw registry strings reviewed in the policy are accepted."""
return isinstance(value, str) and value in ALLOWED_LICENSES
def source_allowed(key, source):
return (key != 'unknown' and bool(source) and
licence_allowed(source.get('license_type')))
def build_records(archives, lookup):
raw = {}
for kind in ('neighbourhood', 'macrohood', 'microhood'):
read_archive(archives[kind], raw)
print(kind, len(raw), flush=True)
features = []
excluded = collections.Counter()
for wof_id in sorted(raw):
properties = raw[wof_id]['properties']
if not keep(properties):
continue
key = properties.get('src:geom', 'unknown')
if not source_allowed(key, lookup.get(key)):
excluded[key] += 1
continue
name = properties['wof:name']
aliases = sorted({
alias
for k, values in properties.items()
if k.startswith('name:') and isinstance(values, list)
for alias in values
if isinstance(alias, str) and alias != name
})
if not name.strip() and not aliases:
continue # nothing to ask for it by
geometry = shape(raw[wof_id]['geometry'])
if geometry.is_empty:
continue
rings, label = simplify(geometry)
if not rings:
continue
country = properties.get('iso:country', properties.get('wof:country'))
features.append([
wof_id, name, country, properties['wof:placetype'],
[round(v, PRECISION) for v in geometry.bounds], aliases, key, rings, label,
])
return features, excluded
# ── tiles ───────────────────────────────────────────────────────────────
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
def tiles(features, key='', box=(-180.0, -90.0, 180.0, 90.0)):
size = sum(len(compact(f).encode()) for f in features)
if size <= TILE_MAX_BYTES or len(key) >= TILE_MAX_DEPTH:
yield key or 'world', features
return
west, south, east, north = box
mx, my = (west + east) / 2, (south + north) / 2
quads = [[], [], [], []]
for f in features:
x = (f[4][0] + f[4][2]) / 2
y = (f[4][1] + f[4][3]) / 2
quads[(1 if x >= mx else 0) + (2 if y >= my else 0)].append(f)
boxes = [(west, south, mx, my), (mx, south, east, my), (west, my, mx, north), (mx, my, east, north)]
for i, quad in enumerate(quads):
if quad:
yield from tiles(quad, key + str(i), boxes[i])
def union_box(features):
return [
min(f[4][0] for f in features), min(f[4][1] for f in features),
max(f[4][2] for f in features), max(f[4][3] for f in features),
]
def exclude_records(partition):
"""Filter approved source+IDs AFTER the original byte-sized partition."""
if RECORD_EXCLUSIONS['archives'] != {k: a['sha256'] for k, a in ARCHIVES.items()}:
raise ValueError('record exclusions do not match the pinned archives')
if RECORD_EXCLUSIONS['registrySha256'] != SOURCES['sha256']:
raise ValueError('record exclusions do not match the pinned registry')
expected = {}
for source, rule in RECORD_EXCLUSIONS['sources'].items():
if rule['count'] != len(rule['ids']) or len(set(rule['ids'])) != len(rule['ids']):
raise ValueError(f'{source}: invalid exclusion membership')
for record_id in rule['ids']:
if record_id in expected:
raise ValueError(f'{record_id}: duplicate exclusion')
expected[record_id] = source
seen = set()
kept = []
for key, members in partition:
retained = []
for row in members:
record_id, source = row[0], row[6]
if record_id in expected:
if source != expected[record_id] or record_id in seen:
raise ValueError(f'{record_id}: exclusion source or membership changed')
seen.add(record_id)
elif source in RECORD_EXCLUSIONS['sources']:
raise ValueError(f'{record_id}: unreviewed exclusion-source member')
else:
retained.append(row)
if not retained:
raise ValueError(f'{key}: exclusion would change the tile layout')
kept.append((key, retained))
if seen != set(expected):
raise ValueError(f'missing excluded records: {sorted(set(expected) - seen)}')
return kept
# ── credits ─────────────────────────────────────────────────────────────
def licence_label(key, source):
licence = source['license_type']
if key == 'karmashapes':
return 'CC BY 4.0'
return {
'CC-BY': 'CC BY', 'Attribution 3.0 Unported': 'CC BY 3.0', 'Public domain': 'Public Domain',
'Open Government License - British Columbia, v2.0': 'Open Government Licence - British Columbia, v2.0',
}.get(licence, licence)
def exclusion_note():
groups = ', '.join(f'{rule["count"]} `{key}` records' for key, rule in RECORD_EXCLUSIONS['sources'].items())
lead = f'This derived pack excludes {groups}. ' if groups else ''
return f'{lead}{RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
def attribution(counts, lookup):
lines = [
'# Neighborhood boundary credits',
'',
"Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licenses/) "
'and contributors, used under the licences below. Modified: boundaries simplified to about 15 m, repaired and re-encoded.',
'',
'| Source | Records | Licence | Terms |',
'|---|---:|---|---|',
]
for key, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
source = lookup[key]
name = source.get('fullname') or key
terms = source.get('license') or ''
link = f'[terms]({terms})' if terms.startswith('http') else terms
lines.append(f'| {name} (`{key}`) | {count:,} | {licence_label(key, source)} | {link} |')
via = [(key, lookup[key]) for key in sorted(counts) if lookup[key].get('src:via')]
if via:
lines += ['', '## Upstream sources', '']
for key, source in via:
lines += [f'### {source.get("fullname") or key}', '']
for v in source['src:via']:
note = f' ({v["source_note"]})' if v.get('source_note') else ''
context = f'{v["context"]}: ' if v.get('context') else ''
lines.append(f'- {context}{v.get("source_name", "")}{note} {v.get("source_link", "")}'.rstrip())
lines.append('')
while lines[-1] == '':
lines.pop()
lines += ['', '## Source-specific exclusions', '', exclusion_note()]
return '\n'.join(lines).rstrip() + '\n'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--verify', action='store_true', help='rebuild in memory and compare with the shipped pack')
args = parser.parse_args()
pack_geometry.require_pinned_tooling()
args.cache.mkdir(parents=True, exist_ok=True)
archives = {
kind: fetch(args.cache, Path(a['url']).name, a['url'], a['sha256'])
for kind, a in ARCHIVES.items()
}
spec = json.loads(fetch(args.cache, 'sources-spec-latest.json', SOURCES['url'], SOURCES['sha256']).read_text())
lookup = source_table(spec)
verify_source_reviews(lookup)
features, excluded = build_records(archives, lookup)
if len({f[0] for f in features}) != len(features):
raise ValueError('duplicate WOF id')
out = {}
tile_list = []
# Partition the full pinned input first: early filtering can collapse tiles.
partition = exclude_records(list(tiles(features)))
features = [row for _, members in partition for row in members]
for key, members in partition:
members.sort(key=lambda f: f[0])
out[f'{key}.json'] = compact({'tile': key, 'features': members}) + '\n'
tile_list.append({'key': key, 'count': len(members), 'bbox': union_box(members)})
counts = collections.Counter(f[6] for f in features)
index = {
'meta': {
'title': "Who's On First neighbourhood, macrohood and microhood polygons",
'archives': ARCHIVES,
'sourcesRegistry': SOURCES,
'recordExclusions': {key: rule['count'] for key, rule in RECORD_EXCLUSIONS['sources'].items()},
'recordExclusionReason': RECORD_EXCLUSIONS['reason'],
'license': 'Per source; see sources and ATTRIBUTION.md',
'format': 'neighborhood-polylines-v1',
'fields': FIELDS,
'precision': PRECISION,
'coordinateOrder': 'lon,lat',
'toleranceM': TOLERANCE_M,
'schema': (
'tiles[]: key, count, bbox [w, s, e, n] of its records. <key>.json: tile, features[] as '
'fields: WOF id, name, ISO country, placetype, bbox, aliases[] (other-language names), '
'geometry source key, rings (polygons, largest first, each [outer, ...holes]; each ring '
'an encoded polyline string: zigzag varint deltas of lon then lat at 10^-precision '
'degrees), label [lon, lat] (a point inside the largest polygon)'
),
'sources': {
key: {
'name': lookup[key].get('fullname') or key,
'license': licence_label(key, lookup[key]),
'terms': lookup[key].get('license') or '',
'records': counts[key],
}
for key in sorted(counts)
},
'script': 'scripts/build-wof-neighborhoods.py',
},
'tiles': tile_list,
}
out['index.json'] = json.dumps(index, ensure_ascii=False, indent=1) + '\n'
out['ATTRIBUTION.md'] = attribution(counts, lookup)
# Literal URLs, so a bundler emits every tile as an asset.
out['files.js'] = '\n'.join([
'// Generated by scripts/build-wof-neighborhoods.py; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const TILE_URLS = {',
*[f" t{t['key']}: () => new URL('./{t['key']}.json', import.meta.url)," for t in tile_list],
'};',
]) + '\n'
if args.verify:
# Reproducibility: a rebuild must match what ships, byte for byte.
shipped = {p.name for p in PACK.glob('*.json')} | {'ATTRIBUTION.md', 'files.js'}
differ = sorted(
name for name in shipped | set(out)
if not (PACK / name).exists() or name not in out
or (PACK / name).read_bytes() != out[name].encode('utf-8')
)
if differ:
raise SystemExit(f'rebuild differs from the shipped pack: {differ[:10]}')
print(f'wof_neighborhoods: rebuild matches all {len(out)} shipped files')
return
PACK.mkdir(parents=True, exist_ok=True)
for old in PACK.glob('*.json'):
old.unlink()
for name, text in out.items():
(PACK / name).write_text(text, encoding='utf-8')
print(f'wof_neighborhoods: {len(features)} records in {len(tile_list)} tiles; '
f'{sum(rule["count"] for rule in RECORD_EXCLUSIONS["sources"].values())} source-specific record exclusions; '
f'excluded {sum(excluded.values())} with an unapproved licence: {dict(excluded)}', flush=True)
if __name__ == '__main__':
main()
+10 -3
View File
@@ -1,6 +1,8 @@
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
import { build, normalizePath } from 'vite';
/** Build each browser or Node export group and reject imports outside its declared ownership. */
@@ -99,15 +101,20 @@ export async function checkPackageBoundaries(root) {
ssr: node ? { noExternal: true } : undefined,
logLevel: 'silent',
plugins: [
vue(VUE_PLUGIN_OPTIONS),
{
name: 'check-package-ownership',
moduleParsed(info) {
if (!allowed.has(info.id)) {
// Plugin helpers are virtual; a single-file component's style and
// template blocks belong to the component file itself.
if (info.id.startsWith('\0')) return;
const id = info.id.split('?')[0];
if (!allowed.has(id)) {
throw new Error(
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
);
}
seen.add(info.id);
seen.add(id);
},
},
],
+91 -6
View File
@@ -29,6 +29,8 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMarks.test.mjs",
"src/annotations/drawMode.js",
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
@@ -68,6 +70,7 @@
"server/standalone/vite.config.js",
"vite.config.js",
"src/tooling/viteBuild.test.mjs",
"src/tooling/vueLoader.test.mjs",
"src/tooling/allowedHosts.test.mjs",
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -180,6 +183,8 @@
"src/ui/panelDisclosure.js",
"src/ui/imagerySplit.js",
"src/ui/imagerySplit.test.mjs",
"src/ui/imagerySplitView.test.mjs",
"src/ui/stores/imagerySplit.js",
"src/ui/surfaceKeyboard.js",
"src/ui/surfaceKeyboard.test.mjs",
"src/ui/panelRails.js",
@@ -197,9 +202,7 @@
"scripts/qa-scope-hud.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
"src/ui/styles/segmented-control.css",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
"src/ui/effects.js",
@@ -297,6 +300,8 @@
"src/ui/styles.js",
"src/ui/shellLifecycle.test.mjs",
"src/testSupport/readStylesheet.mjs",
"src/testSupport/voiceUi.mjs",
"src/testSupport/voiceView.mjs",
"src/testSupport/streetLevelFakes.mjs",
"src/splitFlap.js",
"style.css",
@@ -534,6 +539,7 @@
"scripts/qa-traffic-navigation.mjs",
"src/ui/frameRateMonitor.js",
"src/ui/frameRateMonitor.test.mjs",
"src/ui/frameRateView.test.mjs",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js",
@@ -658,7 +664,6 @@
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sources.js",
"src/ui/composition.js",
"src/services/application.js",
@@ -749,6 +754,8 @@
"server/providers/openai/toolDescriptions.js",
"src/voice/session.js",
"src/voice/sessionCommands.js",
"src/ui/stores/pointerReticle.js",
"src/voice/pointerReticleView.test.mjs",
"src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js",
"src/voice/session.test.mjs",
@@ -908,6 +915,7 @@
"scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs",
@@ -920,6 +928,8 @@
"src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs",
"src/ui/localSdrControls.test.mjs",
"src/ui/sdrCardActions.test.mjs",
"src/ui/radioPanelActions.test.mjs",
"src/ui/localSdrMarkup.test.mjs",
"src/ui/localSdrPresentation.test.mjs",
"src/data/localReceiversProxy.test.mjs",
@@ -1105,7 +1115,6 @@
"server/providers/overpass/requestAdmission.test.mjs",
"src/tooling/trackArrivalObservation.test.mjs",
"src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs",
@@ -1115,12 +1124,13 @@
"src/hudSummaryGate.test.mjs",
"src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs",
"src/voice/panel/preferencesView.test.mjs",
"src/voice/voiceControlView.test.mjs",
"src/voice/panel/host.test.mjs",
"src/voice/panel/manualPanel.test.mjs",
"src/voice/panel/nativeActivity.test.mjs",
"src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs",
"src/voice/panel/testDom.mjs",
"src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
@@ -1129,5 +1139,80 @@
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs"
"src/share/shareLinkPath.test.mjs",
"src/ui/kit/UiRail.test.mjs",
"src/ui/kit/UiSegmented.vue",
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/kit/UiChip.vue",
"src/ui/kit/UiChip.test.mjs",
"src/ui/globeActions.test.mjs",
"src/ui/safeFrameView.test.mjs",
"src/ui/kit/UiSelect.js",
"src/ui/kit/UiSelect.test.mjs",
"src/ui/kit/UiBadge.vue",
"src/ui/kit/UiBadge.test.mjs",
"src/ui/kit/UiEmpty.vue",
"src/ui/rail/RailChips.vue",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs",
"src/ui/toast.js",
"src/ui/toastView.test.mjs",
"src/ui/stores/sceneInteractions.js",
"src/scenes/dataPacks/presentationView.test.mjs",
"src/ui/stores/dataPacks.js",
"src/scenes/interactionsView.test.mjs",
"src/layers/wind/inspectionMarker.js",
"src/layers/wind/inspectionMarker.test.mjs",
"src/ui/stores/windInspection.js",
"src/ui/stores/missionReplay.js",
"src/layers/launches/replayOverlay.test.mjs",
"src/ui/stores/bhoteKoshiPanel.js",
"src/data/bhoteKoshiPanelView.test.mjs",
"src/ui/stores/celestialRing.js",
"src/celestialRingView.test.mjs",
"src/ui/stores/screenAnnotations.js",
"src/ui/stores/worldOverlayActions.js",
"src/overlays/worldOverlayActionsView.test.mjs",
"src/ui/stores/awarenessDirection.js",
"src/layers/awareness/directionOverlay.test.mjs",
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/views/intelHudView.test.mjs",
"src/testSupport/hudReadouts.mjs",
"src/ui/stores/firstRun.js",
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs"
]
+1 -1
View File
@@ -39,7 +39,7 @@ export async function discoverRuntimeFormatFiles(root) {
const files = [];
for (const name of new Set(candidates)) {
if (
!/\.[cm]?js$/.test(name) ||
!/\.([cm]?js|vue)$/.test(name) ||
/\.test\.[cm]?js$/.test(name) ||
!existsSync(path.join(root, name))
)
+61 -6
View File
@@ -8,7 +8,7 @@
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// plus the markup opted in with data-i18n* in index.html and the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
@@ -119,10 +119,10 @@ export function extractMessages() {
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const markup = markupFiles();
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
const html = readMarkup(file);
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
@@ -156,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* data-i18n-runtime (page markup whose text code sets through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
@@ -404,9 +404,12 @@ export function unmarkedScript(file, original) {
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
for (const file of markupFiles()) {
found.push(...unmarkedMarkup(file, readMarkup(file)));
}
for (const file of trackedFiles('src/**/*.vue'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
@@ -423,6 +426,57 @@ export function findUnmarked() {
});
}
/**
* data-i18n* in a Vue template: views translate with t() as they render, so
* their text follows the locale; the attributes are for static page markup.
* Reads the whole single-file component, so lines are the file's own.
*/
export function markedTemplate(file, source) {
const start = source.indexOf('<template>');
if (start === -1) return [];
const end = source.lastIndexOf('</template>');
// Comments and attribute values are blanked, keeping every offset.
const blank = (text) => text.replace(/[^\n]/g, ' ');
const template = source
.slice(0, end === -1 ? undefined : end)
.replace(/<!--[\s\S]*?-->/g, blank);
const found = [];
const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
TAG.lastIndex = start + '<template>'.length;
for (let tag; (tag = TAG.exec(template));) {
const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
const offset = tag.index + tag[0].length - 1 - tag[1].length;
for (const match of attrs.matchAll(
/(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
))
found.push({
where: `${file}:${lineAt(source, offset + match.index)}`,
text: `${match[1]}: use t() in a Vue template`,
});
}
return found;
}
/** Markup checked for unmarked text: the page and its views. */
function markupFiles() {
return trackedFiles('index.html', 'src/ui/views/*.vue');
}
/**
* A file's markup; for a single-file component, its template block without
* interpolations, which are code: their t() calls are read with the sources.
*/
function readMarkup(file) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
if (!file.endsWith('.vue')) return source;
const start = source.indexOf('<template>');
const end = source.lastIndexOf('</template>');
if (start === -1) return '';
return source
.slice(start + '<template>'.length, end)
.replace(/\{\{[\s\S]*?\}\}/g, '');
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
@@ -433,6 +487,7 @@ function sourceFiles() {
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
'src/**/*.vue',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Maintainer-only topology check and repair for the bundled outline packs.
python scripts/pack_geometry.py check census|wof every shipped feature,
decoded as a whole MultiPolygon, is valid under GEOS, keeps at least
three distinct vertices per ring, and lists its largest part first
python scripts/pack_geometry.py repair census rewrite the Census place
files so every feature passes `check` (run by build-census-places.mjs)
Geometry is checked and repaired in the packs' own integer units (10^-d
degrees), where GEOS arithmetic is exact: a vertex that lies on another edge
after rounding is seen as the touch it is.
Requires Shapely 2.1.2 on GEOS 3.13.1, pinned so repairs are reproducible.
"""
import argparse
import json
import math
import sys
from pathlib import Path
import shapely
from shapely import make_valid, set_precision
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.polygon import orient
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / 'src/data/local_data'
SHAPELY_VERSION = '2.1.2'
GEOS_VERSION = (3, 13, 1)
def require_pinned_tooling():
if shapely.__version__ != SHAPELY_VERSION or shapely.geos_version != GEOS_VERSION:
raise RuntimeError(
f'Use shapely=={SHAPELY_VERSION} on GEOS {".".join(map(str, GEOS_VERSION))}; '
f'found {shapely.__version__} on GEOS {shapely.geos_version}'
)
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
# ── encodings ────────────────────────────────────────────────────────────
def decode_deltas(encoded):
"""[x0, y0, dx1, dy1, …] → [(x, y), …] in pack units (Census/admin packs)."""
x = y = 0
ring = []
for i in range(0, len(encoded) - 1, 2):
x += encoded[i]
y += encoded[i + 1]
ring.append((x, y))
return ring
def encode_deltas(coords):
out = []
px = py = 0
for fx, fy in coords:
x, y = round(fx), round(fy)
out += [x - px, y - py]
px, py = x, y
return out
def decode_polyline(encoded):
"""Zigzag varint deltas, 5-bit groups offset by 63, lon then lat; pack units."""
values = []
last = [0, 0]
i = 0
while i < len(encoded):
n = shift = 0
while True:
b = ord(encoded[i]) - 63
i += 1
n |= (b & 31) << shift
shift += 5
if b < 32:
break
axis = len(values) % 2
last[axis] += ~(n >> 1) if n & 1 else n >> 1
values.append(last[axis])
return list(zip(values[::2], values[1::2]))
def encode_polyline(coords):
out = []
last = [0, 0]
for x, y in coords:
for i, value in enumerate([round(x), round(y)]):
n = value - last[i]
last[i] = value
n = (n << 1) ^ (n >> 63)
while n >= 32:
out.append(chr((32 | (n & 31)) + 63))
n >>= 5
out.append(chr(n + 63))
return ''.join(out)
# ── geometry ─────────────────────────────────────────────────────────────
def polygon_parts(geometry):
if geometry.geom_type == 'Polygon':
return [] if geometry.is_empty else [geometry]
if hasattr(geometry, 'geoms'):
return [p for g in geometry.geoms for p in polygon_parts(g)]
return []
def open_ring(coords):
coords = [tuple(c[:2]) for c in coords]
return coords[:-1] if len(coords) > 1 and coords[0] == coords[-1] else coords
def ordered_parts(geometry):
"""Polygon parts oriented (outer counter-clockwise), largest area first."""
parts = [orient(p, 1.0) for p in polygon_parts(geometry)]
return sorted(parts, key=lambda p: (-p.area, p.bounds))
def on_grid(geometry):
"""Snap to whole pack units keeping the result valid; polygon parts only."""
snapped = set_precision(geometry, 1, mode='valid_output')
return MultiPolygon(polygon_parts(snapped))
def settle(geometry):
"""A valid MultiPolygon of `geometry` (in pack units) on whole units."""
if not geometry.is_valid:
geometry = make_valid(geometry)
geometry = on_grid(MultiPolygon(polygon_parts(geometry)))
for _ in range(3):
if geometry.is_valid:
break
geometry = on_grid(make_valid(geometry))
return geometry
def rings_ok(polygons):
return all(len(set(ring)) >= 3 for poly in polygons for ring in poly)
def problems(polygons):
"""Why decoded `polygons` ([[outer, *holes], …]) fail the pack contract."""
if not polygons or not rings_ok(polygons):
return 'ring with fewer than three distinct vertices'
try:
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons])
except Exception as error: # noqa: BLE001 - malformed input is a finding
return f'unbuildable: {error}'
if not geometry.is_valid:
return shapely.is_valid_reason(geometry)
areas = [part.area for part in geometry.geoms]
if max(areas) > areas[0]:
return 'largest part is not first'
return None
# ── packs ────────────────────────────────────────────────────────────────
def census_features():
folder = DATA / 'us_census_places'
decimals = json.loads((folder / 'index.json').read_text())['meta']['decimals']
for path in sorted(folder.glob('[A-Z][A-Z].json')):
pack = json.loads(path.read_text(encoding='utf-8'))
yield path, pack, decimals
def census_polygons(feature):
return [[decode_deltas(r) for r in poly] for poly in feature['polygons']]
def census_degrees(feature, decimals):
factor = 10 ** feature.get('d', decimals)
return [[[(x / factor, y / factor) for x, y in ring]
for ring in poly] for poly in census_polygons(feature)]
def ring_contains(ring, lat, lon):
"""Match adminBoundaries.pointInRing on final degree-decoded vertices."""
inside = False
for i in range(len(ring)):
xi, yi = ring[i]
xj, yj = ring[i - 1]
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def label_is_filled(polygons, label):
if not isinstance(label, (list, tuple)) or len(label) != 2:
return False
lon, lat = label
if not all(isinstance(n, (int, float)) and math.isfinite(n) for n in label):
return False
return any(ring_contains(poly[0], lat, lon)
and not any(ring_contains(hole, lat, lon) for hole in poly[1:])
for poly in polygons)
def census_label(feature, polygons):
"""The effective runtime label, including its area-weighted fallback."""
if isinstance(feature.get('label'), list):
return feature['label']
ring = polygons[0][0]
area = cx = cy = 0
for i in range(len(ring)):
x0, y0 = ring[i - 1]
x1, y1 = ring[i]
cross = x0 * y1 - x1 * y0
area += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
return [cx / (3 * area), cy / (3 * area)] if area else list(ring[0])
def wof_features():
folder = DATA / 'wof_neighborhoods'
index = json.loads((folder / 'index.json').read_text(encoding='utf-8'))
for tile in index['tiles']:
pack = json.loads((folder / f'{tile["key"]}.json').read_text(encoding='utf-8'))
for feature in pack['features']:
yield feature, index['meta']['precision']
def check(pack):
bad = []
total = 0
if pack == 'census':
for _, data, decimals in census_features():
for feature in data['features']:
total += 1
why = problems(census_polygons(feature))
if not why:
polygons = census_degrees(feature, decimals)
if not label_is_filled(polygons, census_label(feature, polygons)):
why = 'effective label is outside filled geometry'
if why:
bad.append((feature['geoid'], feature['name'], why))
else:
for feature, precision in wof_features():
total += 1
polygons = [[decode_polyline(r) for r in poly] for poly in feature[7]]
why = problems(polygons)
if why:
bad.append((feature[0], feature[1], why))
for row in bad[:20]:
print('invalid', *row)
print(f'{pack}: {total} features, {len(bad)} failing')
return not bad
def repair_census():
repaired = 0
labelled = 0
for path, data, decimals in census_features():
changed = False
for feature in data['features']:
polygons = census_polygons(feature)
if problems(polygons):
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons if len(set(p[0])) >= 3])
parts = ordered_parts(settle(geometry))
feature['polygons'] = [
[encode_deltas(open_ring(p.exterior.coords))]
+ [encode_deltas(open_ring(r.coords)) for r in p.interiors]
for p in parts
]
feature.pop('label', None)
polygons = census_polygons(feature)
why = problems(polygons)
if why:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: still {why}')
repaired += 1
changed = True
decoded = census_degrees(feature, decimals)
if label_is_filled(decoded, census_label(feature, decoded)):
continue
factor = 10 ** feature.get('d', decimals)
for poly in polygons:
point = Polygon(poly[0], poly[1:]).representative_point()
label = [point.x / factor, point.y / factor]
if label_is_filled(decoded, label):
feature['label'] = label
labelled += 1
changed = True
break
else:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: no verified filled label')
if changed:
path.write_text(compact(data) + '\n', encoding='utf-8')
print(f'us_census_places: {repaired} topology repairs, {labelled} verified interior labels added')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('command', choices=['check', 'repair'])
parser.add_argument('pack', choices=['census', 'wof'])
args = parser.parse_args()
require_pinned_tooling()
if args.command == 'repair':
if args.pack != 'census':
parser.error('the WOF build repairs its own geometry')
repair_census()
sys.exit(0 if check('census') else 1)
sys.exit(0 if check(args.pack) else 1)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
+27 -16
View File
@@ -1720,6 +1720,9 @@ try {
const priorSignalMaxHeight = signal.style.maxHeight;
signal.style.maxHeight = '150px';
manager._setCockpitDisclosure(kind, true);
// Let Vue render the disclosure, but not the scheduled layout frame: the
// first syncSignalLayout below must be the first pass.
await Promise.resolve();
// One pass must be enough. Record the result of the FIRST layout call, then
// confirm a second changes nothing, so a convergence regression cannot hide
// behind a repeated call.
@@ -1796,7 +1799,7 @@ try {
manager._setCockpitDisplayPortalActive(false);
document.body.classList.remove('cockpit-mode');
await waitFrames();
standard.classList.remove('collapsed');
manager._presentPanel(standard, { collapsed: false });
standard.style.height = '120px';
standard.style.maxHeight = '120px';
standard.style.overflowY = 'auto';
@@ -1831,7 +1834,7 @@ try {
cockpit.style.height = priorCockpitHeight;
cockpit.style.maxHeight = priorCockpitMaxHeight;
cockpit.style.overflowY = priorCockpitOverflow;
standard.classList.toggle('collapsed', standardWasCollapsed);
manager._presentPanel(standard, { collapsed: standardWasCollapsed });
return {
standardBefore,
standardAfter,
@@ -1851,7 +1854,7 @@ try {
JSON.stringify(portalScroll),
);
const boundary = await page.evaluate(() => {
const boundary = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const hud = document.getElementById('cockpit-hud');
const signal = document.getElementById('cockpit-signal-stream');
@@ -1866,6 +1869,7 @@ try {
signal.hidden = false;
manager._setCockpitDisclosure('radio', false);
manager._setCockpitDisclosure('display', true);
await new Promise(requestAnimationFrame);
const priorSignalMaxHeight = signal.style.maxHeight;
const priorSignalHeight = signal.style.height;
// The strip is pinned to the viewport ceiling once the briefing card grows
@@ -2083,7 +2087,7 @@ try {
}
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical'));
const signalTransition = await page.evaluate(() => {
const signalTransition = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const cockpit = manager.cockpitView;
const signalToggle = document.getElementById('cockpit-signal-toggle');
@@ -2110,6 +2114,8 @@ try {
manager._setCockpitDisclosure('radio', true);
manager._setCockpitDisclosure('radio', false);
const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed;
// The toggle renders the published collapse on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion,
@@ -2136,7 +2142,7 @@ try {
&& signalTransition.signalAriaExpanded === 'false',
JSON.stringify(signalTransition),
);
const contextTransition = await page.evaluate(() => {
const contextTransition = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
const priorCollapsed = cockpit.contextCollapsed;
const contextToggle = document.getElementById('cockpit-context-toggle');
@@ -2148,6 +2154,8 @@ try {
cockpit.setContextCollapsed(false);
const afterExpansion = expandedEvents;
cockpit.setContextCollapsed(false);
// The toggle renders the published collapse on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
return {
afterExpansion,
afterRepeatedExpansion: expandedEvents,
@@ -2168,33 +2176,34 @@ try {
JSON.stringify(contextTransition),
);
// Use the real connected DOM to catch focus loss caused by moving live rows.
const signalFocus = await page.evaluate(() => {
const signalFocus = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
// The rows render on Vue's next update; a frame is past it.
const show = async (items) => {
cockpit.signalItems = items;
cockpit.publishCockpitSignals();
await new Promise(requestAnimationFrame);
};
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-a'), item('focus-b')]);
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
cockpit.renderCockpitSignals();
await show([item('focus-c'), item('focus-b'), item('focus-a')]);
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-b')]);
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')];
cockpit.renderCockpitSignals();
await show([item('focus-a'), item('focus-b')]);
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
cockpit.signalItems = originalItems;
cockpit.renderCockpitSignals();
await show(originalItems);
cockpit.setSignalCollapsed(wasCollapsed);
}
});
@@ -2260,12 +2269,14 @@ try {
signal.hidden = false;
manager._setCockpitDisclosure('display', false);
manager._setCockpitDisclosure('radio', false);
await new Promise(requestAnimationFrame);
manager.cockpitView.syncSignalLayout();
const collapsed = {
display: displayControl.getBoundingClientRect().toJSON(),
radio: radioControl.getBoundingClientRect().toJSON(),
};
display.click();
await new Promise(requestAnimationFrame);
manager.cockpitView.syncSignalLayout();
return {
collapsed,
+12 -2
View File
@@ -474,12 +474,22 @@ async function contacts() {
// SEARCH NEARBY SITES, as the user presses it.
await page.evaluate(() => {
document.getElementById('toast').textContent = '';
const toast = document.getElementById('toast');
const before = toast.firstChild;
document.getElementById('installations-search-btn').click();
window.__qaToastBefore = before;
});
// Every toast show writes a new text node, even for a repeated message.
await page
.waitForFunction(
() => document.getElementById('toast').textContent.trim().length > 0,
() => {
const toast = document.getElementById('toast');
return (
toast.classList.contains('visible') &&
toast.firstChild !== window.__qaToastBefore &&
toast.textContent.trim().length > 0
);
},
{ timeout: 20_000 },
)
.catch(() => {});
+12 -10
View File
@@ -107,7 +107,7 @@ const layerState = () =>
const manager = gev.dataManager;
const module = manager.layers.get('directions').module;
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const controls = module.getRowControls();
const viewer = gev.viewer;
@@ -152,7 +152,7 @@ const layerState = () =>
const clickChip = async (chipId) => {
const clicked = await page.evaluate((id) => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
if (!button || button.disabled) return false;
@@ -232,24 +232,26 @@ try {
});
await sleep(3000);
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
const opened = await page.evaluate(() => {
const panel = document.getElementById('data-panel');
const panel = document.getElementById('utilities-panel');
if (!panel) return false;
if (panel.classList.contains('collapsed')) {
panel
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
}
return !document
.getElementById('data-panel')
.getElementById('utilities-panel')
.classList.contains('collapsed');
});
await sleep(700);
check('the DATA LAYERS panel opens from its own header control', opened);
check('the UTILITIES panel opens from its own header control', opened);
const toggled = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector('.data-toggle-btn');
if (!button) return false;
@@ -397,7 +399,7 @@ try {
);
const focused = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const items = row.querySelectorAll('.data-row-list-item');
items[2]?.focus();
@@ -776,7 +778,7 @@ try {
}
}
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
return {
entities: left.length,
+3
View File
@@ -427,6 +427,9 @@ try {
shot.interactions = [];
fields[0].value = JSON.stringify(scene);
fields[1].value = JSON.stringify(shot);
// Typing fires input; the dialog reads its fields from those events.
for (const field of fields)
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.click('[data-director-apply-details]');
await page.waitForFunction(
+198 -25
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A clip of the globe canvas, clear of the panels on every side. */
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
// dims when its row's shape is hidden.
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
/** The canvas clip as a PNG, for comparing two moments of the same view. */
const canvasShot = () =>
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
/**
* Amber pixels in one shot that are not amber in another of the same view:
* a mark's own pixels, apart from imagery and panels that both shots share.
*/
function amberOnlyIn(shotA, shotB) {
return page.evaluate(
async (a, b) => {
const pixels = async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
};
const amber = (d, i) =>
d[i] > 150 &&
d[i + 1] > 90 &&
d[i + 1] < 220 &&
d[i + 2] < 130 &&
d[i] - d[i + 2] > 70 &&
d[i] - d[i + 1] > 20;
const [da, db] = [await pixels(a), await pixels(b)];
let only = 0;
for (let i = 0; i < da.length; i += 4)
if (amber(da, i) && !amber(db, i)) only += 1;
return only;
},
shotA,
shotB,
);
}
/**
* How many amber pixels the clip contains. The drawn area's stroke and fill are
* the only amber in this scene, so the count going UP after a shape is finished
@@ -196,12 +237,14 @@ try {
);
check('visible tile content settles before drawing', settled);
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
// UTILITIES starts collapsed on a first run: open it the way a user does.
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -210,7 +253,7 @@ try {
{ timeout: 10_000 },
);
check(
'the Draw control is on the DISPLAY rail and starts off',
'the Draw control is in the UTILITIES panel and starts off',
await page.evaluate(() => {
const toggle = document.getElementById('draw-toggle');
return (
@@ -298,8 +341,6 @@ try {
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
@@ -313,7 +354,8 @@ try {
// ── a line along a road, with Backspace ─────────────────────────────────
await page.click('.pp-mode-btn[data-shape="line"]');
await clickWorld(360, 300);
// Clear of the open UTILITIES panel, which sits beside the left rail.
await clickWorld(420, 300);
await clickWorld(470, 380);
await clickWorld(600, 470);
const beforeBackspace = (await drawState()).vertices;
@@ -326,16 +368,9 @@ try {
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check('a line finished with Enter lands on the board', await waitForMarks(2));
const lineMark = (await marks()).find((mark) => mark.type === 'route');
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
@@ -347,10 +382,8 @@ try {
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
await page.click('.draw-color[data-color="amber"]');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
@@ -373,14 +406,34 @@ try {
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
const rowIds = () =>
page.evaluate(() =>
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
(row) => row.dataset.markId,
),
);
const ids = await rowIds();
check('every drawn shape has a row under Draw', ids.length === 3, ids);
const areaId = ids[2];
const rowControl = (id, control) =>
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
await page.click(rowControl(areaId, 'name'));
await page.keyboard.type('Civic Center block');
await page.keyboard.press('Enter');
await wait(300);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
'the name typed in its row and the colour picked before drawing are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
check(
'naming a shape keeps Draw on and the shape in progress untouched',
(await drawState()).active === true,
);
await wait(1200);
const amberAfter = await amberPixels();
check(
@@ -389,6 +442,118 @@ try {
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
// ── the row's own controls: recolour, hide and show, fly to ─────────────
const markState = (id) =>
page.evaluate((id) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
);
}, id);
// Wait for a mark to reach a state rather than for a fixed time.
const markReaches = (id, want) =>
page
.waitForFunction(
(id, want) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark &&
Object.entries(want).every(([key, value]) => mark[key] === value)
);
},
{ timeout: 10_000 },
id,
want,
)
.then(
() => true,
() => false,
);
await page.click(rowControl(areaId, 'swatch'));
await page.click(rowControl(areaId, 'color-green'));
await markReaches(areaId, { color: 'green' });
check(
'a colour picked in the row recolours that shape',
(await markState(areaId))?.color === 'green',
);
await page.click(rowControl(areaId, 'toggle'));
await markReaches(areaId, { hidden: true, alpha: 0 });
const hidden = await markState(areaId);
check(
'hiding a shape keeps it on the board but draws it at zero alpha',
hidden?.hidden === true && hidden.alpha === 0,
JSON.stringify(hidden),
);
await page.click(rowControl(areaId, 'toggle'));
check(
'showing it again brings it straight back',
await markReaches(areaId, { hidden: false, alpha: 1 }),
);
// Fly-to frames the line by its whole path: once the flight settles, the
// middle of the line sits near the middle of the view.
await page.click(rowControl(ids[1], 'fly'));
await page
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
timeout: 15_000,
})
.catch(() => {});
await wait(300);
const framed = await page.evaluate((id) => {
const { viewer } = window.__godsEyeView;
// No Cesium global in the page: borrow Cartesian3 from a live object.
const Cartesian3 = viewer.camera.positionWC.constructor;
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
const lons = mark.path.map((p) => p.lon);
const lats = mark.path.map((p) => p.lat);
const middle = Cartesian3.fromDegrees(
(Math.min(...lons) + Math.max(...lons)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
);
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
const canvas = viewer.scene.canvas;
return (
at && {
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
}
);
}, ids[1]);
check(
'fly to frames that shape: its middle is near the middle of the view',
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
JSON.stringify(framed),
);
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
// colour, so the check is the pixels, not the mark's state ─────────────
const lineId = ids[1];
await page.click(rowControl(lineId, 'swatch'));
await page.click(rowControl(lineId, 'color-amber'));
await markReaches(lineId, { color: 'amber', alpha: 1 });
await wait(800);
const shown = await canvasShot();
await page.click(rowControl(lineId, 'toggle'));
await markReaches(lineId, { hidden: true, alpha: 0 });
await wait(800);
const hiddenShot = await canvasShot();
// Amber the shown line had and the hidden one lost; amber that appears
// only once it is hidden is noise, and must stay small beside it.
const gone = await amberOnlyIn(shown, hiddenShot);
const noise = await amberOnlyIn(hiddenShot, shown);
check(
'a hidden Line is no longer painted',
gone > 40 && noise < gone * 0.2,
`${gone} amber pixels went with the line, ${noise} appeared`,
);
await page.click(rowControl(lineId, 'toggle'));
await wait(1200);
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
check(
'and paints again when shown',
back > gone * 0.5,
`${back} of ${gone} line pixels back`,
);
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
@@ -419,6 +584,12 @@ try {
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
await page.click(rowControl(ids[0], 'remove'));
await waitForMarks(2);
check(
'remove in a row takes that one shape off the board and out of the list',
(await rowIds()).join() === ids.slice(1).join(),
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
@@ -480,10 +651,12 @@ try {
// ── Clear wipes the board ───────────────────────────────────────────────
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
+96 -55
View File
@@ -25,12 +25,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
const TESTS = [
'src/firstRunExperience.test.mjs',
'src/standalone/startupChrome.test.mjs',
'src/firstRunLauncherView.test.mjs',
];
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceDescriptions: path.join(
@@ -53,7 +59,8 @@ const MUTATIONS = [
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
},
{
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
defect:
'the checkbox is ignored, so nothing can ever suppress the launcher',
file: 'module',
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
to: ' ? false',
@@ -77,7 +84,8 @@ const MUTATIONS = [
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
defect:
'?welcome=1 no longer outranks suppression, so demos cannot replay it',
file: 'module',
from: "if (params.get('welcome') === '1') return true;",
to: '// replay hatch removed',
@@ -139,8 +147,9 @@ const MUTATIONS = [
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
file: 'module',
defect:
'cockpit drops off the exclusive list and can stack with the launcher',
file: 'surfaces',
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
},
@@ -157,19 +166,22 @@ const MUTATIONS = [
to: 'dismiss();',
},
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
defect:
'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
defect:
'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
file: 'module',
from: ' return Boolean(hit) && !root.contains(hit);',
to: ' return !root.contains(hit);',
},
{
defect: 'the launcher stops honouring a key another surface already claimed',
defect:
'the launcher stops honouring a key another surface already claimed',
file: 'keyboard',
from: ' || event.defaultPrevented',
to: ' /* belt removed */',
@@ -177,25 +189,29 @@ const MUTATIONS = [
{
// Anchored past the call so it lands on the Cockpit utility disclosure's
// Escape claim and nowhere else.
defect: 'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
defect:
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
file: 'radio',
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
defect:
'the "no timer" decision is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
to: ' * (note removed)',
},
{
defect: 'a bounded reveal timer punches the card through a recording in progress',
defect:
'a bounded reveal timer punches the card through a recording in progress',
file: 'module',
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
},
{
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
defect:
'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
file: 'docs',
from: 'a surface class that never clears means no launcher for that page',
to: 'the launcher always turns up eventually for that page',
@@ -203,10 +219,11 @@ const MUTATIONS = [
// ── The checkbox may not promise what storage refused ─────────────────────
{
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
defect:
'a refused write leaves the box ticked, promising a suppression nobody stored',
file: 'module',
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
from: ' if (setFirstRunSuppressed(Boolean(wanted), storage)) return true;',
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
},
{
defect: 'a missing storage area reports the write as saved',
@@ -215,14 +232,16 @@ const MUTATIONS = [
to: ' store?.setItem?.(key, value);\n return true;',
},
{
defect: 'the scroll fade promises more list on a card where everything fits',
defect:
'the scroll fade promises more list on a card where everything fits',
file: 'module',
from: 'choiceList.dataset.scrollable = String(overflows);',
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
from: 'present({ scrollable: String(overflows) });',
to: "present({ scrollable: 'true' }); void overflows;",
},
{
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
defect:
'the DISPLAY rail opens on first run again, stealing the impression',
file: 'ui',
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
@@ -236,13 +255,15 @@ const MUTATIONS = [
// ── Mission definitions ────────────────────────────────────────────────────
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
defect:
'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
defect:
'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
file: 'module',
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
to: "layerIds: Object.freeze(['local-firms']),",
@@ -256,11 +277,11 @@ const MUTATIONS = [
{
defect: 'the tile stops promising the fires it actually turns on',
file: 'html',
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
from: " 'Live earthquakes and active fires, from USGS and NASA',",
to: " 'Live earthquakes worldwide, straight from USGS',",
},
{
defect: "the owner-authored first-run line is quietly rewritten",
defect: 'the owner-authored first-run line is quietly rewritten',
file: 'html',
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
@@ -302,21 +323,24 @@ const MUTATIONS = [
to: 'if (!mission) return { ok: true, choice };',
},
{
defect: 'the fires/quakes tile name stops being switchable from one constant',
file: 'module',
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
defect:
'the fires/quakes tile name stops being switchable from one constant',
file: 'copy',
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
to: 'ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
},
// ── Defaults interplay (the ruling this whole integration turns on) ────────
{
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
defect:
'mission layer enables stop persisting, unlike the clicks they stand for',
file: 'module',
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
to: 'dataManager.setEnabled(layerId, true)',
},
{
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
defect:
'a mission hand-edits detection and kills the style auto-preset contract',
file: 'module',
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
@@ -328,13 +352,15 @@ const MUTATIONS = [
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
},
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
defect:
'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
defect:
'the decision table is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
to: ' * (notes removed)',
@@ -342,10 +368,11 @@ const MUTATIONS = [
// ── Accessibility and the transplant defects ──────────────────────────────
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
defect:
'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'html',
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -384,19 +411,22 @@ const MUTATIONS = [
to: 'body.never-matches-anything #first-run-launcher,',
},
{
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
defect:
'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
file: 'css',
from: '#first-run-launcher[hidden] {\n display: none;\n}',
to: '/* [hidden] rule removed */',
},
{
defect: 'the card loses its viewport cap and clips on a short landscape phone',
defect:
'the card loses its viewport cap and clips on a short landscape phone',
file: 'css',
from: ' max-height: calc(100dvh - 1.5rem);',
to: ' /* cap removed */',
},
{
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
defect:
'the mission list stops scrolling, so tiles overflow the capped card',
file: 'css',
from: ' min-height: 0;\n overflow-y: auto;',
to: ' /* scroll removed */',
@@ -415,19 +445,19 @@ const MUTATIONS = [
{
defect: 'a mission tile is dropped from the menu',
file: 'html',
from: ' <button type="button" data-first-run-choice="space-missions">',
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
from: ' <button type="button" data-first-run-choice="space-missions"',
to: ' <button type="button" data-first-run-choice-disabled="space-missions"',
},
{
defect: 'the menu order stops matching the owner\'s',
defect: "the menu order stops matching the owner's",
file: 'html',
from: '<button type="button" data-first-run-choice="environmental">',
to: '<button type="button" data-first-run-choice="zzz-environmental">',
from: '<button type="button" data-first-run-choice="environmental"',
to: '<button type="button" data-first-run-choice="zzz-environmental"',
},
{
defect: 'the "don\'t show again" checkbox is removed',
file: 'html',
from: '<input type="checkbox" data-first-run-suppress />',
from: '<input type="checkbox" data-first-run-suppress @change="onSuppress" />',
to: '<span data-first-run-suppress-removed></span>',
},
{
@@ -439,8 +469,8 @@ const MUTATIONS = [
{
defect: 'the launcher is no longer announced as a dialog',
file: 'html',
from: '<aside id="first-run-launcher" role="dialog"',
to: '<aside id="first-run-launcher"',
from: 'id="first-run-launcher" role="dialog"',
to: 'id="first-run-launcher"',
},
{
defect: 'the launcher is revealed before the loading cover yields',
@@ -449,7 +479,8 @@ const MUTATIONS = [
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
defect:
'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
@@ -463,7 +494,8 @@ const MUTATIONS = [
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
defect:
'the instruction mapping is dropped, so voice cannot reach the modes',
file: 'voiceInstructions',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
@@ -471,21 +503,25 @@ const MUTATIONS = [
];
const originals = new Map();
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
for (const [key, file] of Object.entries(FILES))
originals.set(key, fs.readFileSync(file, 'utf8'));
/** Write only on a real change: a no-op write still wakes every file watcher. */
const write = (file, next) => {
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
};
const restoreAll = () => {
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
for (const [key, file] of Object.entries(FILES))
write(file, originals.get(key));
};
process.on('exit', restoreAll);
let caught = 0;
const missed = [];
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
console.log(
`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`,
);
for (const { defect, file, from, to } of MUTATIONS) {
const original = originals.get(file);
if (!original.includes(from)) {
@@ -497,10 +533,15 @@ for (const { defect, file, from, to } of MUTATIONS) {
let red = false;
let by = '';
try {
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
execFileSync(
'node',
['--import=./build/registerVue.js', '--test', ...TESTS],
{ cwd: ROOT, encoding: 'utf8', stdio: 'pipe' },
);
} catch (error) {
red = true;
const failed = String(error.stdout || '').split('\n')
const failed = String(error.stdout || '')
.split('\n')
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
.map((line) => line.trim().slice(2).split(' (')[0]);
by = [...new Set(failed)].slice(0, 2).join('; ');
+3 -24
View File
@@ -39,14 +39,13 @@ try {
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
selects.length === 5 &&
selects.every((node) => node.classList.contains('gev-field')),
native: selects.every(
(node) =>
@@ -58,7 +57,7 @@ try {
};
});
check(
'all six panel dropdowns retain native select semantics and shared surface',
'all five panel dropdowns retain native select semantics and shared surface',
inventory.shared && inventory.native,
inventory,
);
@@ -282,13 +281,6 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
for (const lang of ['en']) {
await page.evaluate((lang) => {
document.documentElement.lang = lang;
@@ -296,12 +288,7 @@ try {
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
const layout = await page.evaluate(() => {
const ids = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
];
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
return ids.map((id) => {
const node = document.getElementById(id),
rect = node.getBoundingClientRect(),
@@ -432,19 +419,11 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
].map((id) => document.getElementById(id));
return (
document.documentElement.lang === 'ru' &&
+7 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
@@ -269,10 +269,12 @@ try {
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -283,7 +285,7 @@ try {
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
'Import is in the UTILITIES panel with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
+15 -3
View File
@@ -87,17 +87,21 @@ try {
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
]);
const ui = window.__godsEyeView.styleManager;
ui._clearSelectedLayersBtn.click();
const clearButton = document.getElementById('clear-selected-layers');
clearButton.click();
// The bar renders the busy state on Vue's next flush.
await Promise.resolve();
result.push([
'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
clearButton.getAttribute('aria-busy') === 'true' &&
!!ui._contextControls._clearSelectedLayersPromise,
]);
await ui._contextControls._clearSelectedLayersPromise;
await Promise.resolve();
result.push([
'clear settles through the existing layer transaction',
!manager.isEnabled(id) &&
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
clearButton.getAttribute('aria-busy') === 'false',
]);
await manager.setEnabled(id, true);
const final = row().querySelector('.data-toggle-btn');
@@ -122,6 +126,14 @@ try {
'ordinary layer rows return after reassembly',
container.querySelectorAll('.data-toggle-row').length > 10,
]);
result.push([
'Directions returns to the Utilities list after reassembly',
Boolean(
document.querySelector(
'#utilities-toggles [data-layer-id="directions"]',
) && !container.querySelector('[data-layer-id="directions"]'),
),
]);
return result;
});
for (const [name, passed] of results) check(name, passed);
+25 -10
View File
@@ -62,13 +62,17 @@ try {
);
check(
'orbit controls and indicator agree',
await page.evaluate(() => {
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
// Vue renders on a microtask; a task later the indicator shows it.
const rendered = () => new Promise((resolve) => setTimeout(resolve));
ui.setOrbit(true);
await rendered();
const active =
ui.orbitController.active &&
document.getElementById('orbit-indicator').classList.contains('active');
ui.setOrbit(false);
await rendered();
return (
active &&
!ui.orbitController.active &&
@@ -120,8 +124,11 @@ try {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') &&
ui._locationMiniPoi.textContent === '104.9903° W'
document
.getElementById('location-mini-city')
.textContent.includes('39.7392° N') &&
document.getElementById('location-mini-poi').textContent ===
'104.9903° W'
);
}),
);
@@ -194,8 +201,11 @@ try {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === 'Second landmark, Test city' &&
ui._locationMiniCity.textContent.includes('Second landmark') &&
ui._locationMiniPoi.textContent === 'Test city' &&
document
.getElementById('location-mini-city')
.textContent.includes('Second landmark') &&
document.getElementById('location-mini-poi').textContent ===
'Test city' &&
ui.activeStyle === 'normal' &&
!ui._locationSearch.classList.contains('searching')
);
@@ -227,8 +237,10 @@ try {
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return (
!ui._poiRow.classList.contains('expanded') &&
!ui._locationBarDivider.classList.contains('visible')
!document.getElementById('poi-row').classList.contains('expanded') &&
!document
.getElementById('location-bar-divider')
.classList.contains('visible')
);
}, cityId),
);
@@ -284,7 +296,7 @@ try {
'native reset returns through the existing camera handoff',
await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager;
ui._resetGlobeBtn.click();
document.getElementById('reset-globe-view').click();
const pending = ui._globeResetPromise;
if (!pending) return false;
await pending;
@@ -301,8 +313,11 @@ try {
ui._locationControls.destroy();
ui._locationLookup.destroy();
const before = ui._navigationGeneration;
ui._locationPills.querySelector('button').click();
ui._resetGlobeBtn.click();
document
.getElementById('location-pills')
.querySelector('button')
?.click();
document.getElementById('reset-globe-view').click();
ui._locationSearch.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
+24 -14
View File
@@ -69,9 +69,10 @@ try {
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
return (
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === 'osm' &&
manager._mapStackStatus.textContent ===
document
.getElementById('map-stack-chips')
.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' &&
document.getElementById('map-stack-status').textContent ===
manager.mapStackController.getState().activeStack.shortLabel
);
}),
@@ -81,32 +82,38 @@ try {
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const result = await manager.setMapStack('esri-imagery');
// The dock renders the published row on Vue's next flush.
await new Promise((resolve) => setTimeout(resolve, 0));
const active = manager.mapStackController.getActiveId();
return (
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
.stackId === active &&
document
.getElementById('map-stack-chips')
.querySelector('[aria-pressed="true"]')?.dataset.stackId === active &&
(result.ok || !!manager.mapStackController.getState().lastError)
);
}),
);
check(
'refresh revokes retained old chips',
await page.evaluate(() => {
'refresh re-presents the chips without duplicating them',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
manager._mapSourceControls.refresh();
const before = window.__qaMapSelections.length;
old.click();
return window.__qaMapSelections.length === before;
await new Promise((resolve) => setTimeout(resolve, 0));
return (
document.querySelectorAll('#map-stack-chips [data-stack-id="osm"]')
.length === 1
);
}),
);
check(
'refreshed chips still select once',
await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
document
.getElementById('map-stack-chips')
.querySelector('[data-stack-id="osm"]')
.click();
await Promise.resolve();
return window.__qaMapSelections.length === before + 1;
}),
@@ -188,7 +195,10 @@ try {
const manager = window.__godsEyeView.styleManager;
const before = window.__qaMapSelections.length;
manager._mapSourceControls.destroy();
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
document
.getElementById('map-stack-chips')
.querySelector('[data-stack-id="osm"]')
.click();
const result = await manager._mapSourceControls.select('esri-imagery');
return result === null && window.__qaMapSelections.length === before;
}),
+4
View File
@@ -871,6 +871,9 @@ try {
return originalSetStack(stackId);
};
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
// The dock renders the published row on Vue's next flush.
const rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
await rendered();
const during = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
@@ -879,6 +882,7 @@ try {
};
release();
await switchPromise;
await rendered();
const after = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env node
/**
* Traffic-warm OpenFreeMap outline acceptance.
*
* Each sample opens a fresh application page, loads the Texas Capitol z14 tile
* through Street Traffic, then circles a building from that same tile. It
* records application fetches separately from browser transfer bytes,
* body-read/decode/extraction/install measures, and Long Tasks. A second ask
* on the page proves the outline source's own consumer-cache path. A separate
* page holds traffic's target response while the outline consumer subscribes,
* proving that overlap still produces one application fetch and one decode.
*
* Acceptance records actual owned synchronous segments (16.7 ms each), full
* eight-probe completion within 2 s, and separate inclusive latency. No sample may create a
* >=50 ms Long Task, and neither the first nor second outline ask may fetch or
* decode the traffic-warm tile again. The first explicit cold ask's target
* projection must fit one frame; Traffic does no outline projection and the
* warm ask reuses it. Traffic activation Long Tasks are reported separately.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const valueAfter = (flag, fallback) => {
const index = args.indexOf(flag);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
if (args.includes('--help') || args.includes('-h')) {
console.log(
'Usage: node scripts/qa-openfreemap-same-tile.mjs [--url http://localhost:4173] [--runs 5] [--headful] [--out qa-shots/openfreemap-same-tile.json]',
);
process.exit(0);
}
const APP_URL = valueAfter('--url', 'http://localhost:4173');
const RUNS = Number(valueAfter('--runs', '5'));
const OUTPUT = path.resolve(
valueAfter('--out', 'qa-shots/openfreemap-same-tile.json'),
);
const FRAME_BUDGET_MS = 16.7;
const LONG_TASK_MS = 50;
const TARGET = Object.freeze({
latitude: 30.27472,
longitude: -97.74035,
z: 14,
x: 3743,
y: 6745,
});
const TARGET_PATH = `/${TARGET.z}/${TARGET.x}/${TARGET.y}.pbf`;
if (!Number.isInteger(RUNS) || RUNS < 3 || RUNS > 20)
throw new Error('--runs must be an integer from 3 through 20');
const targetUrl = new URL(APP_URL);
if (!['http:', 'https:'].includes(targetUrl.protocol))
throw new Error('--url must be HTTP(S)');
const percentile = (values, p) => {
if (!values.length) return null;
const sorted = values.slice().sort((a, b) => a - b);
return sorted[Math.ceil(p * sorted.length) - 1];
};
const round = (value) =>
value == null ? null : Number(Number(value).toFixed(3));
const measures = (entries, name) =>
entries.filter((entry) => entry.name === name).map((entry) => entry.ms);
const sum = (values) => values.reduce((total, value) => total + value, 0);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Numeric DEV evidence from the actual owners; waited frames are latency, not CPU.
function workEvidence(entries, label) {
const work = entries.filter(
(entry) => entry.name === 'outlines:install-work',
);
assert.equal(work.length, 1, `${label}: one completed owned-work trace`);
const trace = work[0].detail;
assert.equal(
trace?.status,
'installed',
`${label}: processed install required`,
);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.dropped, 0, `${label}: no lost work intervals`);
assert.equal(trace.probes, 8, `${label}: all original probes required`);
assert.ok(Number.isFinite(trace.timeOrigin));
assert.ok(
Number.isFinite(trace.samplerStart) && Number.isFinite(trace.completedAt),
);
assert.ok(
Number.isFinite(trace.callerStart) &&
trace.callerStart <= trace.samplerStart,
);
const phases = {
'engine-schedule': 1,
'ground-step': 8,
'hybrid-complete': 1,
'engine-process': 1,
'engine-finalize': 1,
'engine-settle': 1,
};
assert.equal(
trace.slices?.length,
13,
`${label}: complete disjoint owner intervals`,
);
for (const [phase, count] of Object.entries(phases))
assert.equal(
trace.slices.filter((slice) => slice.phase === phase).length,
count,
`${label}: ${phase}`,
);
let end = -Infinity;
const durations = trace.slices.map((slice) => {
assert.ok(
Number.isFinite(slice.start) &&
Number.isFinite(slice.end) &&
slice.end >= slice.start,
);
assert.ok(
slice.start >= end,
`${label}: no nested or double-counted owner intervals`,
);
end = slice.end;
const duration = slice.end - slice.start;
assert.ok(
duration <= FRAME_BUDGET_MS,
`${label}: ${slice.phase} work slice ${duration} exceeds ${FRAME_BUDGET_MS} ms`,
);
return duration;
});
const completionMs = trace.completedAt - trace.samplerStart;
assert.ok(
completionMs >= 0 && completionMs <= 2000,
`${label}: processed completion ${completionMs} ms exceeds 2000 ms`,
);
// This sum describes owned tail work, never the complete browser task/frame.
const tailStart = trace.slices
.filter((slice) => slice.phase === 'ground-step')
.at(-1).start;
return {
timeOrigin: trace.timeOrigin,
slices: trace.slices,
maxSliceMs: Math.max(...durations),
ownedTailSumMs: trace.slices
.filter((slice) => slice.start >= tailStart)
.reduce((total, slice) => total + slice.end - slice.start, 0),
completionMs,
callerCompletionMs: trace.completedAt - trace.callerStart,
probes: trace.probes,
dropped: trace.dropped,
};
}
await fs.mkdir(path.dirname(OUTPUT), { recursive: true });
const browser = await puppeteer.launch({
headless: args.includes('--headful') ? false : 'new',
protocolTimeout: 240_000,
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
...(args.includes('--headful')
? []
: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,1000',
],
});
const report = {
appUrl: APP_URL,
target: TARGET,
targetPath: TARGET_PATH,
acceptance: {
maxOwnedSliceMs: FRAME_BUDGET_MS,
samplerCompletionMaxMs: 2000,
legacyInclusive: 'reported-not-gated',
maxLongTaskMs: LONG_TASK_MS,
coldProjectionMaxMs: FRAME_BUDGET_MS,
coldApplicationFetches: 0,
coldOutlineTransferBytes: 0,
coldDecodes: 0,
warmApplicationFetches: 0,
warmDecodes: 0,
overlapApplicationFetches: 1,
overlapDecodes: 1,
},
renderer: null,
samples: [],
errors: [],
};
function relevantMeasures(raw) {
return raw
.filter(
(entry) =>
entry.name.startsWith('roads:') || entry.name.startsWith('outlines:'),
)
.map((entry) => ({
name: entry.name,
ms: entry.duration,
at: entry.startTime,
detail: entry.detail || {},
}));
}
async function beginOutline(page, label) {
await page.evaluate(
({ label, target }) => {
performance.clearMeasures();
window.__qaOutlineLongTasks.length = 0;
window.__qaOutlineStarted = performance.now();
window.__qaOutlinePromise = Promise.resolve(
window.__gevVoiceCommands.runner('annotate_map', {
annotations: [
{
type: 'area',
target: 'this building',
label,
entityKind: 'building',
latitude: target.latitude,
longitude: target.longitude,
},
],
}),
);
},
{ label, target: TARGET },
);
}
async function settleOutline(page, label) {
return page.evaluate(async (label) => {
const result = await window.__qaOutlinePromise;
let annotation = null;
const deadline = performance.now() + 30_000;
while (performance.now() < deadline) {
annotation = (window.__gevAnnotations?.list?.() || []).find(
(item) => item.label === label,
);
if (annotation && !annotation.pendingOutline) break;
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (!annotation || annotation.pendingOutline)
throw new Error(`${label}: outline did not settle`);
const { scene } = window.__godsEyeView.viewer;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 1500);
const remove = scene.postRender.addEventListener(() => {
clearTimeout(timer);
remove();
resolve();
});
scene.requestRender();
});
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
commandToRenderMs: performance.now() - window.__qaOutlineStarted,
result,
annotation: {
pending: Boolean(annotation.pendingOutline),
unavailable: Boolean(annotation.outlineUnavailable),
synthesized: Boolean(annotation.synthesized),
footprintKind: annotation.footprintKind || null,
ringPoints: annotation.ring?.length || 0,
parts: annotation.polygons?.length || (annotation.ring ? 1 : 0),
},
measures: performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
longTasks: window.__qaOutlineLongTasks.slice(),
};
}, label);
}
async function driveOutline(page, label) {
await beginOutline(page, label);
return settleOutline(page, label);
}
try {
for (let run = 0; run <= RUNS; run += 1) {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000 });
let overlapMode = false;
let overlapApplicationRequests = 0;
let heldTargetRequest = null;
let resolveHeldTarget;
const heldTarget = new Promise((resolve) => {
resolveHeldTarget = resolve;
});
if (run === 0) {
await page.setRequestInterception(true);
page.on('request', (request) => {
let isTarget = false;
try {
isTarget = new URL(request.url()).pathname.endsWith(TARGET_PATH);
} catch {
// Invalid URLs are passed through for the browser to handle.
}
if (overlapMode && isTarget) {
overlapApplicationRequests += 1;
if (!heldTargetRequest) {
heldTargetRequest = request;
resolveHeldTarget();
return;
}
}
void request.continue();
});
}
await page.evaluateOnNewDocument(() => {
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
window.__qaOutlineLongTasks = [];
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
window.__qaOutlineLongTasks.push({
at: entry.startTime,
ms: entry.duration,
});
}).observe({ type: 'longtask', buffered: false });
window.__qaLongTaskSupported = true;
} catch {
window.__qaLongTaskSupported = false;
}
});
let phase = 'startup';
const network = [];
const pending = new Map();
const client = await page.createCDPSession();
await client.send('Network.enable');
client.on('Network.requestWillBeSent', (event) => {
let url;
try {
url = new URL(event.request.url);
} catch {
return;
}
if (
url.hostname !== 'tiles.openfreemap.org' ||
!url.pathname.endsWith('.pbf')
)
return;
const entry = {
requestId: event.requestId,
phase,
url: url.origin + url.pathname,
targetTile: url.pathname.endsWith(TARGET_PATH),
cached: false,
transferBytes: 0,
responseBytes: 0,
finished: false,
};
pending.set(event.requestId, entry);
network.push(entry);
});
client.on('Network.requestServedFromCache', ({ requestId }) => {
const entry = pending.get(requestId);
if (entry) entry.cached = true;
});
client.on('Network.responseReceived', ({ requestId, response }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.status = response.status;
entry.cached ||= Boolean(
response.fromDiskCache ||
response.fromServiceWorker ||
response.fromPrefetchCache,
);
});
client.on('Network.dataReceived', ({ requestId, dataLength }) => {
const entry = pending.get(requestId);
if (entry) entry.responseBytes += dataLength;
});
client.on('Network.loadingFinished', ({ requestId, encodedDataLength }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.transferBytes = entry.cached ? 0 : encodedDataLength;
entry.finished = true;
pending.delete(requestId);
});
client.on('Network.loadingFailed', ({ requestId, errorText }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.failure = errorText;
entry.finished = true;
pending.delete(requestId);
});
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(String(error.message)));
const navigation = new URL(APP_URL);
navigation.searchParams.set('welcome', '0');
navigation.searchParams.set('trafficDebug', '1');
navigation.searchParams.set('trafficRoads', 'osm');
await page.goto(navigation.href, {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
window.__godsEyeView?.dataManager &&
window.__gevVoiceCommands?.runner &&
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 120_000, polling: 250 },
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setDetection({ enabled: false }),
);
await page.evaluate(async (target) => {
const { viewer, dataManager } = window.__godsEyeView;
await dataManager.setEnabled('traffic', false);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
target.longitude,
target.latitude,
700,
),
orientation: {
heading: 0,
pitch: (-65 * Math.PI) / 180,
roll: 0,
},
});
viewer.camera.moveEnd.raiseEvent();
window.__gevAnnotations.clear();
performance.clearMeasures();
}, TARGET);
await sleep(750);
if (run === 0) {
phase = 'overlap';
overlapMode = true;
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await Promise.race([
heldTarget,
sleep(30_000).then(() => {
throw new Error('overlap phase did not intercept the target tile');
}),
]);
await beginOutline(page, 'QA Capitol overlap');
await sleep(100);
await heldTargetRequest.continue();
const overlap = await settleOutline(page, 'QA Capitol overlap');
await sleep(750);
const trafficStats = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats(),
);
assert.equal(trafficStats.error, null, 'overlap traffic source error');
overlap.measures = relevantMeasures(overlap.measures);
overlap.work = workEvidence(overlap.measures, 'overlap');
overlap.network = network.filter(
(entry) => entry.phase === 'overlap' && entry.targetTile,
);
const overlapDecodes = overlap.measures
.filter(
(entry) =>
entry.name === 'roads:decode' &&
String(entry.detail?.key).endsWith(
`:${TARGET.z}/${TARGET.x}/${TARGET.y}`,
),
)
.map((entry) => entry.ms);
assert.equal(
overlapApplicationRequests,
1,
'overlapping traffic and outline consumers issue one application request',
);
assert.equal(
overlap.network.length,
1,
'overlapping traffic and outline consumers emit one target network request',
);
assert.equal(
overlapDecodes.length,
1,
'overlapping traffic and outline consumers decode once',
);
assert.equal(overlap.annotation.unavailable, false);
assert.equal(overlap.annotation.footprintKind, 'building');
report.overlap = {
applicationFetches: overlapApplicationRequests,
targetNetworkRequests: overlap.network.length,
transferBytes: sum(overlap.network.map((entry) => entry.transferBytes)),
decodes: overlapDecodes.length,
decodeMs: round(sum(overlapDecodes)),
longTasks: overlap.longTasks.length,
longTaskEntries: overlap.longTasks,
work: overlap.work,
};
await page.close();
continue;
}
phase = 'traffic';
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await page.waitForFunction(
() => {
const stats = window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats();
return stats.count > 0 && !stats.loading && !stats.error;
},
{ timeout: 180_000, polling: 250 },
);
await sleep(1500);
const trafficMeasures = relevantMeasures(
await page.evaluate(() =>
performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
),
);
const trafficLongTasks = await page.evaluate(() =>
window.__qaOutlineLongTasks.slice(),
);
const trafficTarget = network.filter(
(entry) => entry.phase === 'traffic' && entry.targetTile,
);
const targetTile = `${TARGET.z}/${TARGET.x}/${TARGET.y}`;
const targetProjectionMeasures = trafficMeasures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
const targetProjectionMs = sum(
targetProjectionMeasures.map((entry) => entry.ms),
);
assert.ok(
trafficTarget.length >= 1,
`sample ${run}: traffic did not load ${TARGET_PATH}`,
);
assert.equal(
trafficMeasures.filter((entry) =>
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
).length,
0,
`sample ${run}: traffic must leave outline projection to the explicit ask`,
);
phase = 'outline-cold';
const cold = await driveOutline(page, `QA Capitol cold ${run}`);
await sleep(750);
cold.measures = relevantMeasures(cold.measures);
cold.work = workEvidence(cold.measures, 'cold');
cold.network = network.filter(
(entry) => entry.phase === 'outline-cold' && entry.targetTile,
);
cold.longTasks = cold.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
await page.evaluate(() => window.__gevAnnotations.clear());
phase = 'outline-warm';
const warm = await driveOutline(page, `QA Capitol warm ${run}`);
await sleep(750);
warm.measures = relevantMeasures(warm.measures);
warm.work = workEvidence(warm.measures, 'warm');
warm.network = network.filter(
(entry) => entry.phase === 'outline-warm' && entry.targetTile,
);
warm.longTasks = warm.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
const supported = await page.evaluate(() => window.__qaLongTaskSupported);
assert.equal(supported, true, `sample ${run}: Long Tasks unsupported`);
assert.deepEqual(pageErrors, [], `sample ${run}: browser errors`);
assert.equal(cold.annotation.unavailable, false);
assert.equal(cold.annotation.synthesized, false);
assert.equal(cold.annotation.footprintKind, 'building');
assert.ok(cold.annotation.ringPoints >= 4);
assert.equal(warm.annotation.footprintKind, 'building');
assert.ok(warm.annotation.ringPoints >= 4);
const coldDecodes = measures(cold.measures, 'roads:decode');
const coldInstalls = measures(cold.measures, 'outlines:install');
const warmDecodes = measures(warm.measures, 'roads:decode');
const coldProjectionMeasures = cold.measures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
cold.targetProjectionMs = sum(
coldProjectionMeasures.map((entry) => entry.ms),
);
assert.equal(
cold.network.length,
0,
'shared decoded tile performs no second application fetch',
);
assert.equal(sum(cold.network.map((entry) => entry.transferBytes)), 0);
assert.equal(
coldDecodes.length,
0,
'shared decoded tile is not parsed again',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:street-extract',
).length,
1,
'the first explicit cold ask owns target street extraction exactly once',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:polygon-extract',
).length,
1,
'the first explicit cold ask owns target polygon extraction exactly once',
);
assert.ok(
cold.targetProjectionMs <= FRAME_BUDGET_MS,
`sample ${run}: cold target projection ${cold.targetProjectionMs} ms exceeds ${FRAME_BUDGET_MS} ms`,
);
assert.equal(
measures(warm.measures, 'outlines:street-extract').length,
0,
'warm reuse performs no street extraction',
);
assert.equal(
measures(warm.measures, 'outlines:polygon-extract').length,
0,
'warm reuse performs no polygon extraction',
);
assert.ok(coldInstalls.length >= 1, 'cold outline installs geometry');
assert.equal(warm.network.length, 0, 'decoded-cache hit performs no fetch');
assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode');
report.renderer ||= await page.evaluate(() => {
const gl = window.__godsEyeView.viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return debug
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER);
});
if (args.includes('--headful')) {
assert.doesNotMatch(
String(report.renderer),
/swiftshader|software rasterizer|llvmpipe/i,
`sample ${run}: headful evidence requires a hardware renderer`,
);
}
report.samples.push({
run,
pageErrors,
traffic: {
applicationFetches: trafficTarget.length,
transferBytes: sum(trafficTarget.map((entry) => entry.transferBytes)),
decodes: measures(trafficMeasures, 'roads:decode').length,
targetProjectionMs: round(targetProjectionMs),
longTasks: trafficLongTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
})),
measures: trafficMeasures,
network: trafficTarget,
},
cold: {
...cold,
applicationFetches: cold.network.length,
transferBytes: sum(cold.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(coldDecodes)),
bodyReadMs: round(sum(measures(cold.measures, 'roads:tile-body-read'))),
streetExtractMs: round(
sum(measures(cold.measures, 'outlines:street-extract')),
),
polygonExtractMs: round(
sum(measures(cold.measures, 'outlines:polygon-extract')),
),
installMs: round(sum(coldInstalls)),
legacyInclusiveMs: round(
sum(coldDecodes) +
sum(measures(cold.measures, 'outlines:street-extract')) +
sum(measures(cold.measures, 'outlines:polygon-extract')) +
sum(coldInstalls),
),
},
warm: {
...warm,
applicationFetches: warm.network.length,
transferBytes: sum(warm.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(warmDecodes)),
installMs: round(sum(measures(warm.measures, 'outlines:install'))),
},
});
await page.close();
}
const coldInclusive = report.samples.map(
(sample) => sample.cold.legacyInclusiveMs,
);
const outlineLongTasks = report.samples.flatMap((sample) => [
...sample.cold.longTasks,
...sample.warm.longTasks,
]);
const trafficLongTasks = report.samples.flatMap(
(sample) => sample.traffic.longTasks,
);
report.summary = {
samples: report.samples.length,
overlapApplicationFetches: report.overlap?.applicationFetches ?? null,
overlapDecodes: report.overlap?.decodes ?? null,
legacyInclusiveP50Ms: round(percentile(coldInclusive, 0.5)),
legacyInclusiveP95Ms: round(percentile(coldInclusive, 0.95)),
legacyInclusiveMaxMs: round(Math.max(...coldInclusive)),
coldCommandToRenderP95Ms: round(
percentile(
report.samples.map((sample) => sample.cold.commandToRenderMs),
0.95,
),
),
coldApplicationFetches: sum(
report.samples.map((sample) => sample.cold.applicationFetches),
),
coldTransferBytes: sum(
report.samples.map((sample) => sample.cold.transferBytes),
),
coldDecodes: sum(
report.samples.map(
(sample) => measures(sample.cold.measures, 'roads:decode').length,
),
),
warmApplicationFetches: sum(
report.samples.map((sample) => sample.warm.applicationFetches),
),
warmDecodes: sum(
report.samples.map(
(sample) => measures(sample.warm.measures, 'roads:decode').length,
),
),
coldProjectionMaxMs: Math.max(
...report.samples.map((sample) => sample.cold.targetProjectionMs),
),
trafficBaselineLongTasks: trafficLongTasks.length,
trafficBaselineMaxLongTaskMs: round(
trafficLongTasks.length
? Math.max(...trafficLongTasks.map((entry) => entry.ms))
: 0,
),
longTasks: outlineLongTasks.length,
maxLongTaskMs: round(
outlineLongTasks.length
? Math.max(...outlineLongTasks.map((entry) => entry.ms))
: 0,
),
};
const coldSlices = report.samples.map(
(sample) => sample.cold.work.maxSliceMs,
);
report.summary.coldOwnedSliceP95Ms = round(percentile(coldSlices, 0.95));
report.summary.maxOwnedSliceMs = Math.max(
report.overlap.work.maxSliceMs,
...report.samples.flatMap((sample) => [
sample.cold.work.maxSliceMs,
sample.warm.work.maxSliceMs,
]),
);
report.summary.maxSamplerCompletionMs = Math.max(
report.overlap.work.completionMs,
...report.samples.flatMap((sample) => [
sample.cold.work.completionMs,
sample.warm.work.completionMs,
]),
);
assert.ok(report.summary.maxOwnedSliceMs <= FRAME_BUDGET_MS);
assert.ok(report.summary.maxSamplerCompletionMs <= 2000);
assert.ok(
report.summary.coldProjectionMaxMs <= FRAME_BUDGET_MS,
'cold outline projection exceeded one frame',
);
assert.equal(report.summary.longTasks, 0, 'outline ask created a Long Task');
assert.equal(report.summary.coldTransferBytes, 0);
assert.equal(report.summary.coldApplicationFetches, 0);
assert.equal(report.summary.coldDecodes, 0);
assert.equal(report.summary.warmApplicationFetches, 0);
assert.equal(report.summary.warmDecodes, 0);
assert.equal(report.summary.overlapApplicationFetches, 1);
assert.equal(report.summary.overlapDecodes, 1);
report.passed = true;
console.table(
report.samples.map((sample) => ({
run: sample.run,
coldProjectionMs: sample.cold.targetProjectionMs,
appFetches: sample.cold.applicationFetches,
transferBytes: sample.cold.transferBytes,
bodyReadMs: sample.cold.bodyReadMs,
decodeMs: sample.cold.decodeMs,
streetMs: sample.cold.streetExtractMs,
polygonMs: sample.cold.polygonExtractMs,
installMs: sample.cold.installMs,
legacyInclusiveMs: sample.cold.legacyInclusiveMs,
maxOwnedSliceMs: sample.cold.work.maxSliceMs,
renderMs: round(sample.cold.commandToRenderMs),
longTasks: sample.cold.longTasks.length,
})),
);
console.log('OpenFreeMap same-tile acceptance passed:', report.summary);
} catch (error) {
report.passed = false;
report.failure = String(error?.stack || error);
console.error(error);
process.exitCode = 1;
} finally {
await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`);
await browser.close();
}
+1 -1
View File
@@ -310,7 +310,7 @@ try {
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
// Drive the actual sharpen slider: input event → handler →
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
// _setSharpenIntensity → governorRequestRender. Proves the wiring,
// not just the facade.
const slider = document.getElementById('sharpen-intensity-slider');
if (!slider) { resolve({ ok: false }); return; }
+44 -12
View File
@@ -1100,7 +1100,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationFalseMessage = document.getElementById('toast').textContent;
const installationFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) {
@@ -1111,7 +1111,7 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationEnableRejectionMessage = document.getElementById('toast').textContent;
const installationEnableRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationEnableRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
dataManager.setEnabled = async (layerId, shouldEnable) => {
if (layerId === 'military-installations' && shouldEnable) enabled.add(layerId);
@@ -1123,13 +1123,13 @@ async function main() {
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationRejectionMessage = document.getElementById('toast').textContent;
const installationRejectionReleased = !document.getElementById('installations-search-btn').disabled;
const installationRejectionReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
if (installations) installations.searchNearby = async () => false;
document.getElementById('installations-search-btn').click();
await new Promise((resolve) => setTimeout(resolve, 50));
const installationSearchFalseMessage = document.getElementById('toast').textContent;
const installationSearchFalseReleased = !document.getElementById('installations-search-btn').disabled;
const installationSearchFalseReleased = document.getElementById('installations-search-btn').getAttribute('aria-busy') === 'false';
await new Promise((resolve) => setTimeout(resolve, 0));
return {
semanticFalseMessage,
@@ -1716,7 +1716,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
// The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
}
for (const [id, state] of Object.entries(prior.stacks)) {
const stack = document.getElementById(id);
@@ -1974,7 +1981,7 @@ async function main() {
manager._setCockpitDisplayPortalActive(true);
hud.hidden = false;
signal.hidden = false;
dataPanel.classList.remove('collapsed');
manager._presentPanel(dataPanel, { collapsed: false });
window.__godsEyeView.styleManager._syncLeftPanelAdaptiveLayout();
window.__godsEyeView.styleManager.cockpitView.syncSignalLayout();
if (display.getAttribute('aria-expanded') === 'true') display.click();
@@ -2059,8 +2066,7 @@ async function main() {
contextCard.hidden = false;
// Expand the panel the same way this block already does at entry, so the
// portaled Display groups measured further down keep their DOM home.
dataPanel.classList.remove('collapsed', 'layout-auto-collapsed');
manager._syncPanelCollapseButton(dataPanel);
manager._presentPanel(dataPanel, { collapsed: false, auto: null });
manager._syncLeftPanelAdaptiveLayout();
await waitForLayout();
const panelRect = dataPanel.getBoundingClientRect();
@@ -2154,16 +2160,20 @@ async function main() {
.every((control) => control.closest('.pp-toggle-group')?.parentElement
?.closest('#cockpit-display-panel'));
display.click();
await waitForLayout();
manager.cockpitView.syncSignalLayout();
radio.click();
await waitForLayout();
const radioOpened = radio.getAttribute('aria-expanded') === 'true'
&& !document.getElementById('cockpit-radio-panel').hidden;
const mutuallyExclusive = display.getAttribute('aria-expanded') === 'false';
radio.click();
await waitForLayout();
manager.cockpitView.syncSignalLayout();
radio.click();
await waitForLayout();
const radioNavigationAvailable = [
document.getElementById('cockpit-radio-prev-btn'),
@@ -2283,7 +2293,14 @@ async function main() {
else panel.setAttribute('style', state.style);
if (state.ariaHidden === null) panel.removeAttribute('aria-hidden');
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
// The restored classes are the panel's state again.
const has = (name) => panel.classList.contains(name);
manager._presentPanel(panel, {
collapsed: has('collapsed'),
pinned: has('dock-pinned'),
pinnedTop: has('dock-pinned-top'),
auto: has('layout-auto-collapsed') ? 'layout' : has('accordion-collapsed') ? 'accordion' : null,
});
}
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear();
@@ -2700,14 +2717,20 @@ async function main() {
};
const playsBefore = window.__qaRadioPlayCalls.length;
const frozenSignature = style._radioControls._radioTunerBandSignature;
// The tuner renders from the Radio store; let Vue patch before reading.
const rendered = () => Promise.resolve();
pointer('pointerdown', xFor(0));
await rendered();
const left = snapshot();
const centerCoordinate = (count - 1) / 2;
pointer('pointermove', xFor(centerCoordinate));
await rendered();
const center = snapshot();
pointer('pointermove', xFor(centerCoordinate + 2));
await rendered();
const shifted = snapshot();
pointer('pointermove', xFor(count - 1));
await rendered();
const right = snapshot();
const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x]));
const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index));
@@ -2764,7 +2787,7 @@ async function main() {
tapeDelta: absoluteTuner.tapeDelta,
}),
);
const tunerCancelRestore = await page.evaluate(() => {
const tunerCancelRestore = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const style = gev.styleManager;
const radio = gev.dataManager.layers.get('radio').module;
@@ -2797,11 +2820,14 @@ async function main() {
const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2;
pointer('pointerdown', before.slot);
pointer('pointermove', targetIndex);
// The tuner renders from the Radio store; let Vue patch before reading.
await Promise.resolve();
const shiftedSlot = Number(slider.value);
const previewBeforeCancel = gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const flyToCallsBeforeCancel = flyToCalls.length;
pointer('pointercancel', targetIndex);
await Promise.resolve();
const afterStations = style._radioControls._radioTunerStations;
const afterState = radio.getUIState();
const after = {
@@ -2957,7 +2983,7 @@ async function main() {
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
});
const tunerRefreshRelease = await page.evaluate((target) => {
const tunerRefreshRelease = await page.evaluate(async (target) => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
@@ -2970,6 +2996,10 @@ async function main() {
clientY: target.clientY,
}));
const state = gev.dataManager.layers.get('radio').module.getUIState();
// The playback line renders from the Radio store once Vue flushes.
const message = await Promise.resolve().then(
() => document.getElementById('radio-playback-state').textContent,
);
return {
displayedBeforeRelease,
frozenGeneration,
@@ -2978,7 +3008,7 @@ async function main() {
plays: window.__qaRadioPlayCalls.length,
value: document.getElementById('radio-tuner-value').textContent,
stationText: document.getElementById('radio-tuner-station').textContent,
message: document.getElementById('radio-playback-state').textContent,
message,
targetId: target.id,
};
}, tunerRefreshTarget);
@@ -3025,6 +3055,8 @@ async function main() {
pointer('pointermove', targetIndex);
const previewPlays = window.__qaRadioPlayCalls.length;
pointer('pointerup', targetIndex);
// The tuner renders from the Radio store; sample once Vue has patched.
await Promise.resolve();
const samples = [needle.getBoundingClientRect().left];
await new Promise((resolve) => requestAnimationFrame(resolve));
samples.push(needle.getBoundingClientRect().left);
+8 -5
View File
@@ -442,12 +442,12 @@ try {
return '{"scenes":[]}';
},
});
// A shot row held across disposal must not act once it is revoked.
const retainedLabel = document.querySelector('.scene-shot-label');
const stopping = director.destroy();
const notificationsAtStop = window.__qaSceneState.length;
const stoppedSynchronously =
controls.destroyed &&
controls.removers.length === 0 &&
controls.rowRemovers.length === 0;
const stoppedSynchronously = controls.destroyed;
retainedLabel?.click();
document.getElementById('scene-new-btn').click();
controls.run('capture');
finish();
@@ -456,6 +456,7 @@ try {
await director.destroy();
return {
stoppedSynchronously,
heldRowAction: Boolean(retainedLabel),
stateStopped: window.__qaSceneState.length === notificationsAtStop,
sameProject: director._project === project,
noLateStatus:
@@ -465,7 +466,9 @@ try {
});
check(
'Disposal revokes fixed and row actions before waiting for scene work',
teardown.stoppedSynchronously && teardown.rowsGone,
teardown.stoppedSynchronously &&
teardown.heldRowAction &&
teardown.rowsGone,
);
check(
'Late import completion cannot replace the disposed project or status',
+29 -25
View File
@@ -4,7 +4,7 @@
* file dropped from the desktop, proved in a real browser.
*
* The dialog draws a translated row over the native file input
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* (src/ui/views/panels/SceneDialog.vue). The input must still cover the row,
* or drops miss it. Drops a file on the row's centre via CDP and expects
* `input.files` to hold it.
*
@@ -15,31 +15,34 @@
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import puppeteer from 'puppeteer';
import { createServer } from 'vite';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
// The scene panels view draws the dialog; Vite compiles it for the page.
const PAGE = `<!doctype html><html lang="en"><body>
<div id="view"></div>
<script type="module">
import { createApp, nextTick } from 'vue';
import { applicationPinia } from '/src/ui/vueRuntime.js';
import LayerPanelsView from '/src/ui/views/LayerPanelsView.vue';
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
createApp(LayerPanelsView).use(applicationPinia()).mount('#view');
window.openDialog = async () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
dialog.input('Choose data-pack files', '', { type: 'file', multiple: true });
await nextTick();
const input = document.querySelector('[data-director-dialog] input[type=file]');
window.filesInput = input;
// Centre of the visible row (the label's last child).
const r = input.parentElement.lastElementChild.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
@@ -56,21 +59,21 @@ async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
const vite = await createServer({
root: ROOT,
configFile: false,
logLevel: 'error',
plugins: [vue(VUE_PLUGIN_OPTIONS)],
server: { middlewareMode: true, hmr: false },
appType: 'custom',
});
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
vite.middlewares(req, res, () => res.writeHead(404).end());
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
@@ -99,6 +102,7 @@ async function main() {
} finally {
await browser.close();
server.close();
await vite.close();
await rm(dir, { recursive: true, force: true });
}
}
+38 -7
View File
@@ -35,15 +35,21 @@ try {
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'utilities-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
// Colour swatches are one-of-many too, but chips, not segments: they are
// checked on their own below.
const groups = [
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
@@ -69,6 +75,31 @@ try {
JSON.stringify(surface),
);
const swatches = await page.evaluate(() => {
const group = document.getElementById('draw-colors');
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
return {
count: chips.length,
round: chips.every(
(chip) => getComputedStyle(chip).borderRadius === '50%',
),
colours: new Set(
chips.map((chip) => getComputedStyle(chip).backgroundColor),
).size,
checked: chips.filter(
(chip) => chip.getAttribute('aria-checked') === 'true',
).length,
};
});
check(
"Draw's colour swatches are five round chips, one checked",
swatches.count === 5 &&
swatches.round &&
swatches.colours === 5 &&
swatches.checked === 1,
JSON.stringify(swatches),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
+14 -12
View File
@@ -69,8 +69,6 @@ try {
calDirty: false,
},
});
window.__qaBadgeText =
document.getElementById('cctv-source-badge').textContent;
});
await page.waitForSelector('.weather-coverage.gev-status-badge');
const inventory = await page.evaluate(() => {
@@ -120,20 +118,22 @@ try {
);
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
const result = await page.evaluate((state) => {
const result = await page.evaluate(async (state) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
? 'true'
: '';
owner._cctvFrameWrap.classList.toggle(
'has-frame',
state === 'retained',
);
const frame = owner._frame;
owner._frame = {
...frame,
loading: state === 'loading',
error: ['error', 'retained'].includes(state),
hasFrame: state === 'retained',
};
owner._syncCctvSourceBadge(
owner._cctvState.activeCamera,
state !== 'idle',
);
owner._frame = frame;
// The panel view renders the badge on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-source-badge');
return {
state: badge.dataset.frameState,
@@ -164,7 +164,7 @@ try {
['curated', true, 'rgb(255, 208, 138)'],
]) {
const result = await page.evaluate(
({ calBadge, dirty }) => {
async ({ calBadge, dirty }) => {
const owner = window.__godsEyeView.styleManager._cctvControls;
document.getElementById('cctv-next-btn').focus();
owner._renderCctvState({
@@ -175,6 +175,8 @@ try {
calDirty: dirty,
},
});
// The panel view renders the chip on Vue's next flush.
await new Promise((resolve) => requestAnimationFrame(resolve));
const badge = document.getElementById('cctv-quality-chip');
return {
state: badge.dataset.calBadge,
+17 -4
View File
@@ -40,6 +40,8 @@ try {
cancelable: true,
}),
);
// The title bar renders the readout on Vue's next flush.
await Promise.resolve();
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
@@ -118,10 +120,18 @@ try {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
const { setSplitFlapText } = await import('/src/splitFlap.js');
// Through the owner, so its copy of the label and the store agree.
const feedbackLabel = document.getElementById('global-loading-label');
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
ui._feedback._publishLoading({
hidden: false,
state: 'loading',
label,
detail: '',
});
// Vue renders on a microtask; a task later the label shows it.
await new Promise((resolve) => setTimeout(resolve));
}
const permanentText =
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0;
@@ -202,6 +212,7 @@ try {
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
permanentText &&
permanentText.data === 'LOAD COMPLETE' &&
document.getElementById('global-loading-status').hidden &&
!feedbackLabel.classList.contains('gev-flap-active') &&
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
0,
@@ -211,7 +222,9 @@ try {
ui._lifetime.timers.size === 0 &&
ui._lifetime.removers.size === 0,
frameRateReleased:
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
fpsWasVisible &&
!fpsReadout.isConnected &&
!document.querySelector('.frame-rate-readout'),
stateStopped: stateNotifications === 0,
idempotent: once === JSON.stringify(counts),
};
+5 -3
View File
@@ -57,7 +57,7 @@ try {
persist: false,
syncShare: false,
});
manager._sliderPanel.classList.remove('collapsed');
manager._presentPanel(manager._sliderPanel, { collapsed: false });
const slider = manager._sliderContainer.querySelector('input');
window.__qaOldSlider = slider;
const name = 'pixelation';
@@ -140,9 +140,11 @@ try {
hudBefore,
),
);
const displayBefore = await page.evaluate(() => {
const displayBefore = await page.evaluate(async () => {
const manager = window.__godsEyeView.styleManager;
manager._setBloomEnabled(true);
// The Display view shows the slider row on its next render.
await new Promise((resolve) => requestAnimationFrame(resolve));
manager._bloomSlider.value = '80';
manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true }));
manager._bloomSlider.focus();
@@ -263,7 +265,7 @@ try {
'destroyed Display controls cannot change settings',
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._displayControls.destroy();
manager._displayBindings._detachDisplayControls();
const before = manager.bloomEnabled;
const contrast = manager._bloomStage.uniforms.contrast;
manager._bloomBtn.click();
+6 -2
View File
@@ -12,10 +12,14 @@ import { after, before, test } from 'node:test';
import puppeteer from 'puppeteer';
const root = new URL('../', import.meta.url);
const template = await readFile(
new URL('src/ui/templates/provider-settings.html', root),
const view = await readFile(
new URL('src/ui/views/ProviderSettingsView.vue', root),
'utf8',
);
const template = view.slice(
view.indexOf('<template>') + '<template>'.length,
view.lastIndexOf('</template>'),
);
const assets = new Set([
'/src/keySetup.js',
'/src/voice/cloudVoiceAuth.js',
+303 -2
View File
@@ -23,6 +23,9 @@
* node scripts/qa-voice-routing.mjs # both layers, :4415
* node scripts/qa-voice-routing.mjs --layer routing --budget 120
* node scripts/qa-voice-routing.mjs --layer behavior --url http://localhost:4173
* node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines
* node scripts/qa-voice-routing.mjs --layer behavior --behavior acceptance [--headful] [--shots qa-shots/outlines]
* # operator-only: may spend the configured guarded Nominatim allowance
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
@@ -72,6 +75,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
const TURN_BUDGET = Number(getOpt('--budget', '120'));
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
const ONLY = getOpt('--only', null); // substring filter on phrase text
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
@@ -1023,7 +1027,7 @@ async function runDeixisBehavior(page, run, settle) {
};
const beginAt = (x, y, said = null) =>
page.evaluate(
(px, py, text) => {
async (px, py, text) => {
const voice = window.__gevVoiceCommands;
if (text) {
// What the session emits for a spoken turn: a new turn, then the caption.
@@ -1043,6 +1047,8 @@ async function runDeixisBehavior(page, run, settle) {
voice.pointer.hold('keydown');
const snapshot = voice.beginPointerTurn('keydown');
voice.pointer.releaseHold();
// The voice card and the reticle render on Vue's next flush.
await Promise.resolve();
const chip = document.getElementById('gev-voice-card-pointer');
const reticle = document.querySelector('.gev-pointer-reticle');
return {
@@ -1323,7 +1329,10 @@ async function runBehaviorLayer() {
// Overpass queries through the app and any direct request to an Overpass
// host (the status probe is not a query).
const overpassRequests = [];
const packTimes = [];
const requestStarted = new Map();
page.on('request', (request) => {
requestStarted.set(request, Date.now());
const target = new URL(request.url());
const viaApp =
target.origin === new URL(APP_URL).origin &&
@@ -1334,12 +1343,26 @@ async function runBehaviorLayer() {
`${request.method()} ${target.origin}${target.pathname}`,
);
});
page.on('requestfinished', (request) => {
if (/us_census_places|wof_neighborhoods/.test(request.url()))
packTimes.push({
file: request.url().split('/').pop().split('?')[0],
ms: Date.now() - (requestStarted.get(request) ?? Date.now()),
});
requestStarted.delete(request);
});
const resolverLog = [];
page.on('console', (message) => {
const text = message.text();
if (text.startsWith('[Resolver]')) resolverLog.push(text);
});
try {
// The point-and-ask block skips the first-run launcher so its evidence
// screenshots show the map; the full run keeps the historical URL.
const behaviorUrl = new URL(APP_URL);
if (DEIXIS_ONLY) behaviorUrl.searchParams.set('welcome', '0');
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
behaviorUrl.searchParams.set('welcome', '0');
await page.goto(behaviorUrl.href, {
waitUntil: 'domcontentloaded',
timeout: 60000,
@@ -1382,6 +1405,22 @@ async function runBehaviorLayer() {
});
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
if (BEHAVIOR === 'outlines') {
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
return;
}
if (BEHAVIOR === 'acceptance') {
await runOutlineAcceptance({ page, run, settle, overpassRequests });
return;
}
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
// while the camera is local — the resolver's proximity gate rejects far
// targets BY DESIGN (annotating Austin from over the Alps must fail).
@@ -2293,6 +2332,15 @@ async function runBehaviorLayer() {
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
);
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
const shotDir = path.join(ROOT, 'qa-shots');
fs.mkdirSync(shotDir, { recursive: true });
await page.screenshot({
@@ -2316,6 +2364,259 @@ async function runBehaviorLayer() {
}
}
/**
* Area asks that the bundled packs answer with no boundary lookup: a US
* Census place, the San Francisco DataSF pack, and Who's On First
* neighborhoods. Each teleports the camera over the place, draws the area,
* and checks the outline, the pack that answered, and that no request went
* to Overpass.
*/
const BUNDLED_OUTLINES = [
{ target: 'Austin', lat: 30.2672, lon: -97.7431, pack: 'us-census' },
{ target: 'the Mission District', lat: 37.7599, lon: -122.4148, pack: 'datasf', entityKind: 'district' },
{ target: 'Notting Hill', lat: 51.5118, lon: -0.2046, pack: 'wof' },
{ target: 'Williamsburg', lat: 40.7081, lon: -73.9571, pack: 'wof' },
{ target: 'Le Marais', lat: 48.8575, lon: 2.358, pack: 'wof' },
];
async function runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
}) {
const timings = [];
for (const scenario of BUNDLED_OUTLINES) {
const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`;
await page.evaluate(({ lat, lon }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height: 9000,
}),
});
}, scenario);
await run('clear_annotations', {});
await settle(1500);
const overpassBefore = overpassRequests.length;
const logBefore = resolverLog.length;
const packsBefore = packTimes.length;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{
type: 'area',
target: scenario.target,
label: scenario.target,
...(scenario.entityKind ? { entityKind: scenario.entityKind } : {}),
}],
});
let ringPoints = 0;
for (let i = 0; i < 40 && ringPoints < 4; i += 1) {
ringPoints = await page.evaluate((target) => {
const items = window.__gevAnnotations?.list?.() || [];
const item = items.find((it) => it.label === target);
return Array.isArray(item?.ring) ? item.ring.length : 0;
}, scenario.target);
if (ringPoints < 4) await settle(250);
}
const ms = Date.now() - started;
const line = resolverLog.slice(logBefore).find((l) => l.includes('bundled')) || '';
const pack = /\((us-census|datasf|wof|natural-earth)[,)]/.exec(line)?.[1] || 'none';
const packFiles = packTimes.slice(packsBefore);
const overpass = overpassRequests.slice(overpassBefore);
timings.push({ target: scenario.target, ms, pack, overpass: overpass.length });
report((r?.drawn ?? 0) >= 1 && ringPoints >= 4 && pack === scenario.pack && overpass.length === 0,
label,
`drawn=${r?.drawn} ring=${ringPoints} pack=${pack} outlineMs=${ms} overpass=${overpass.length} ` +
`packFiles=${packFiles.map((f) => `${f.file}:${f.ms}ms`).join(',') || 'cached'}`);
}
await run('clear_annotations', {});
report(
overpassRequests.length === 0,
'behavior: no request reached Overpass during the run',
`overpass=${overpassRequests.length} ${overpassRequests.slice(0, 2).join(' ')}`,
);
console.log(` bundled outline timings: ${JSON.stringify(timings)}`);
console.log(` pack files loaded: ${packTimes.map((f) => `${f.file}:${f.ms}ms`).join(' ')}`);
}
/**
* Outline acceptance across six countries: each ask teleports the camera
* over the place, draws the area once cold and once warm (after clearing),
* and records the rung that answered, the parts drawn, the latency, and how
* many public Nominatim requests the server spent (its daily count in
* .gev-cache/nominatim/state.json). Nothing may reach Overpass.
*/
const OUTLINE_ASKS = [
// group, target, camera lat/lon/height(m), extra annotation args, shot name
['country', 'Switzerland', 46.8, 8.2, 1_200_000],
['country', 'Iran', 32.4, 53.7, 3_500_000],
['state', 'Texas', 31.0, -99.0, 2_000_000],
['state', 'Bavaria', 48.9, 11.4, 900_000],
['city', 'Austin', 30.2672, -97.7431, 60_000, {}, 'austin-city'],
['city', 'Paris', 48.8566, 2.3522, 40_000],
['city', 'Tokyo', 35.6762, 139.6503, 90_000],
['city', 'São Paulo', -23.5505, -46.6333, 90_000],
['city', 'Mumbai', 19.076, 72.8777, 60_000],
['city', 'Lagos', 6.5244, 3.3792, 60_000],
['neighborhood', 'the Mission District', 37.7599, -122.4148, 8000, { entityKind: 'district' }],
['neighborhood', 'the Marina', 37.8037, -122.4368, 8000, { entityKind: 'district' }],
['neighborhood', 'Notting Hill', 51.5118, -0.2046, 8000, { entityKind: 'district' }, 'notting-hill'],
['neighborhood', 'Williamsburg', 40.7081, -73.9571, 8000, { entityKind: 'district' }],
['neighborhood', 'SoHo', 40.7233, -74.003, 8000, { entityKind: 'district' }],
['neighborhood', 'Le Marais', 48.8575, 2.358, 8000, { entityKind: 'district' }],
['neighborhood', 'Shibuya', 35.6595, 139.7005, 8000, { entityKind: 'district' }],
['landmark', 'the Texas Capitol grounds', 30.2747, -97.7404, 3000, { entityKind: 'compound' }],
['landmark', 'Central Park', 40.7829, -73.9654, 6000, {}, 'central-park'],
['landmark', 'the Eiffel Tower', 48.8584, 2.2945, 3000],
['landmark', 'Stanford', 37.4275, -122.1697, 8000],
['landmark', 'Stanford University', 37.4275, -122.1697, 8000],
['landmark', 'Lady Bird Lake', 30.2638, -97.7526, 6000],
['street', 'Congress Avenue', 30.2672, -97.7425, 3000, { entityKind: 'street' }, 'congress-avenue'],
['street', 'Lombard Street', 37.8021, -122.4187, 3000, { entityKind: 'street' }],
['building', 'this building', 30.27472, -97.74035, 1200,
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
function nominatimCount() {
try {
return JSON.parse(fs.readFileSync(NOMINATIM_STATE, 'utf8')).count || 0;
} catch {
return 0;
}
}
async function runOutlineAcceptance({
page,
run,
settle,
overpassRequests,
}) {
const trace = [];
page.on('console', (msg) => {
const text = msg.text();
if (/^\[(Outline|Resolver)\]/.test(text)) trace.push(text);
});
const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length;
if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true });
const teleport = (ask) => page.evaluate(({ lat, lon, height }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height,
}),
});
}, ask);
const rungOf = (lines, state) => {
for (const line of lines) {
const m = /→ (openfreemap|nominatim)\b/.exec(line) ||
/bundled → ([a-z-]+)/.exec(line) ||
/\((us-census|datasf|wof|natural-earth),/.exec(line);
if (m) return m[1];
}
if (state?.synthesized) return 'approximate';
return state?.ring >= 4 ? 'other' : 'point';
};
const once = async (ask) => {
await run('clear_annotations', {});
await teleport(ask);
await settle(1200);
trace.length = 0;
const before = nominatimCount();
const label = `QA ${ask.target}`;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{ type: 'area', target: ask.target, label, ...ask.extra }],
});
let state = null;
for (let i = 0; i < 120; i += 1) {
state = await page.evaluate((l) => {
const it = (window.__gevAnnotations?.list?.() || []).find((a) => a.label === l);
if (!it) return null;
return {
ring: Array.isArray(it.ring) ? it.ring.length : 0,
parts: Array.isArray(it.polygons) && it.polygons.length ? it.polygons.length : (it.ring ? 1 : 0),
synthesized: Boolean(it.synthesized),
unavailable: Boolean(it.outlineUnavailable),
pending: Boolean(it.pendingOutline),
};
}, label);
if (state && !state.pending) break;
if (!state && r?.drawn === 0 && i > 8) break;
await settle(250);
}
const ms = Date.now() - started;
return {
ms,
drawn: r?.drawn ?? 0,
state,
rung: rungOf(trace, state),
nominatim: nominatimCount() - before,
error: r?.error || r?.results?.find?.((x) => x?.error)?.error || null,
};
};
const table = [];
// --only "Paris|Stanford" reruns a few rows.
const only = ONLY ? ONLY.split('|') : null;
for (const ask of OUTLINE_ASKS.filter((a) => !only || only.some((o) => a.target.includes(o)))) {
const cold = await once(ask);
if (SHOT_DIR && ask.shot) {
await settle(HEADFUL ? 6000 : 2500);
await page.screenshot({ path: path.resolve(ROOT, SHOT_DIR, `${ask.shot}.png`) });
}
const warm = await once(ask);
const rendered = Boolean(cold.state?.ring >= 4) && !cold.state?.synthesized;
const row = {
group: ask.group,
ask: ask.target,
rung: cold.rung,
rendered,
approximate: Boolean(cold.state?.synthesized),
parts: cold.state?.parts ?? 0,
coldMs: cold.ms,
warmMs: warm.ms,
warmRung: warm.rung,
nominatim: cold.nominatim + warm.nominatim,
};
table.push(row);
const detail = `rung=${row.rung} parts=${row.parts} cold=${row.coldMs}ms warm=${row.warmMs}ms ` +
`nominatim=${row.nominatim}${row.approximate ? ' approximate' : ''}` +
(rendered ? '' : ` trace=${trace.slice(-2).join(' | ').slice(0, 300)}`);
if (rendered) report(true, `outlines: ${ask.group} "${ask.target}"`, detail);
else skipped(`outlines: ${ask.group} "${ask.target}" (no exact outline)`, detail);
}
await run('clear_annotations', {});
const overpass = overpassRequests.slice(overpassStart);
report(overpass.length === 0, 'outlines: no request reached Overpass during the run',
`overpass=${overpass.length} ${overpass.slice(0, 2).join(' ')}`);
const used = nominatimCount() - nominatimStart;
report(used <= 30, 'outlines: public Nominatim requests within the run budget (30)', `used=${used}`);
console.log('\n | Group | Ask | Rung | Outline | Parts | Cold ms | Warm ms | Nominatim |');
console.log(' |---|---|---|---|---|---|---|---|');
for (const row of table) {
const outline = row.rendered ? 'yes' : row.approximate ? 'approximate' : 'no (point)';
console.log(` | ${row.group} | ${row.ask} | ${row.rung} | ${outline} | ${row.parts} | ${row.coldMs} | ${row.warmMs} | ${row.nominatim} |`);
}
if (SHOT_DIR)
fs.writeFileSync(path.resolve(ROOT, SHOT_DIR, 'outline-table.json'), JSON.stringify(table, null, 2));
}
// ── Main ────────────────────────────────────────────────────
console.log('\nVoice-surface QA harness');
console.log(` App URL : ${APP_URL}`);
+1 -1
View File
@@ -261,7 +261,7 @@ try {
for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled)
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
document.querySelector('#data-panel')?.classList.remove('collapsed');
gev.styleManager._presentPanel('data-panel', { collapsed: false });
});
// 1. Earth view, clean.
+7 -1
View File
@@ -71,8 +71,14 @@ export function allocationTestArgs(file) {
return ['--expose-gc', '--test', '--test-concurrency=1', file];
}
/** Node flags every unit-test process receives. */
export const UNIT_TEST_NODE_FLAGS = Object.freeze([
// Single-file components compile on import.
'--import=./build/registerVue.js',
]);
function runTests(args) {
const result = spawnSync(process.execPath, args, {
const result = spawnSync(process.execPath, [...UNIT_TEST_NODE_FLAGS, ...args], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
+1 -1
View File
@@ -57,7 +57,7 @@ export function classifyNodeVersion(version = process.versions.node) {
// NEWER than this release has verified is a warning, never a refusal: a
// future Node must not brick a no-terminal install with advice its user
// cannot follow. Too-old stays an error above — old runtimes genuinely fail.
return { level: 'warn', summary: 'newer than this release has verified; Node 24.14.x or 26.x is the tested path' };
return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' };
}
/** Verify that every direct package declared by this checkout is present. */
+1
View File
@@ -612,6 +612,7 @@ async function main() {
if (models3dButton) {
if (!models3dButton.classList.contains('active')) models3dButton.click();
models3dButton.click(); // → OFF: both layers must follow the one control
await new Promise((resolve) => setTimeout(resolve)); // the view renders
models3dOff.button = models3dButton.classList.contains('active');
models3dOff.fl = fl.getParams().models3d;
models3dOff.mil = mil.getParams().models3d;
+51
View File
@@ -0,0 +1,51 @@
{
"reviewedSources": {
"zs": {
"name": "Zetashapes",
"registryCommit": "e17af153c144fe6e08aa8db2607b4bf8aecd448d",
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"rawLicenseType": "Public domain",
"licenseTextSha256": "91757f96f74f6dfb4a7206486b78586da0d039e9611e224fc793c7167dcfe615",
"decision": "Approved from the pinned WOF registry text: no added restrictions beyond public-domain US TIGER/Line data and Flickr-derived data; retain both upstream credits."
}
},
"licenses": [
"Attribution 3.0 Unported",
"CC BY",
"CC BY 2.0",
"CC BY 3.0",
"CC BY 3.0 US",
"CC BY 4.0",
"CC-BY",
"CC0",
"CC0 1.0 Universal",
"ODC-By, v1.0",
"ODC-By v1.0",
"OGL - Canada",
"OGL - City of New Westminster",
"OGL - City of Surrey",
"Open Government Licence v3.0",
"Open Government Licence - British Columbia, v2.0",
"Open Government Licence - Canada",
"Open Government Licence - City of Calgary",
"Open Government Licence - City of Regina",
"Open Government Licence - Edmonton",
"Open Government Licence - Ontario, v1.0",
"Open Government License - British Columbia, v2.0",
"Open Government License - Winnipeg",
"PDDL 1.0",
"Public domain",
"Public Domain",
"Singapore Open Data Licence v1"
],
"recordExclusions": {
"reason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"archives": {
"neighbourhood": "8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455",
"macrohood": "f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679",
"microhood": "e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac"
},
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"sources": {}
}
}
+16 -3
View File
@@ -1,3 +1,4 @@
import path from 'node:path';
import { openSkyProxy } from './aircraft/opensky.js';
import { celestrakProxy, rocketLaunchesProxy } from './space.js';
import { tomtomProxy } from './traffic.js';
@@ -7,7 +8,7 @@ import { adsbdbProxy } from './aircraft/enrichment.js';
import { overpassProxy } from './overpass.js';
import { militaryInstallationsProxy } from './military-installations.js';
import { regionalBriefProxy } from './regional/briefing.js';
import { geocodeProxy } from './regional/place.js';
import { geocodeProxy, NOMINATIM_CACHE_DIR } from './regional/place.js';
import { weatherEffectsProxy } from './regional/weather-effects.js';
import { cctvProxy } from './cctv.js';
import { defaultSourceRoot } from './common/source-root.js';
@@ -34,8 +35,20 @@ import { windProxy } from './wind.js';
/**
* Construct the local provider plugins in their established order.
* `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live.
* `nominatimStorageDir` binds an owned absolute directory before construction;
* omission retains the durable per-source cache and policy state.
*/
function localProviderPlugins({ realtime, gemini } = {}) {
function localProviderPlugins({
realtime,
gemini,
nominatimStorageDir = NOMINATIM_CACHE_DIR,
} = {}) {
if (
typeof nominatimStorageDir !== 'string' ||
!nominatimStorageDir.trim() ||
!path.isAbsolute(nominatimStorageDir)
)
throw new TypeError('nominatimStorageDir must be an absolute directory');
return [
openSkyProxy(),
celestrakProxy(),
@@ -47,7 +60,7 @@ function localProviderPlugins({ realtime, gemini } = {}) {
overpassProxy(),
militaryInstallationsProxy(),
regionalBriefProxy(),
geocodeProxy(),
geocodeProxy({ storageDir: nominatimStorageDir }),
weatherEffectsProxy(),
cctvProxy({ sourceRoot: defaultSourceRoot }),
radioBrowserProxy(),
Loaded 100 of 1060 files, more files were not shown because too many files have changed in this diff. Show more