mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into feat/cctv-model-specs-refresh
This commit is contained in:
1249 files changed
+105057
-21970
No files matched your search
+50
-2
@@ -61,8 +61,15 @@ CESIUM_ION_TOKEN=
|
||||
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
|
||||
# Realtime client secret. Do not prefix with VITE_.
|
||||
OPENAI_API_KEY=
|
||||
# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider
|
||||
# Settings; missing or expired Codex sign-in then fails closed.
|
||||
# Typed chat uses Chat Completions, independently of the voice model. It can
|
||||
# only bill OPENAI_API_KEY, so GEV_PREFER_CODEX_OAUTH below turns it off.
|
||||
OPENAI_CHAT_MODEL=gpt-4.1-mini
|
||||
# Typed chat requests (/api/openai/chat) per IP per minute, counted apart from
|
||||
# voice. Unset, it takes GEV_RATELIMIT_OPENAI_PER_MIN's cap; 0 disables it.
|
||||
# Not a billing cap.
|
||||
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=30
|
||||
# Optional: forbid API-key voice sessions and OpenAI typed chat. Select ChatGPT
|
||||
# OAuth in Provider Settings; missing or expired Codex sign-in then fails closed.
|
||||
# GEV_PREFER_CODEX_OAUTH=true
|
||||
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
||||
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
||||
@@ -256,6 +263,9 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
||||
# CCTV_HOUSTON_ENABLED= # off by default; set 1 to load Houston TranStar cameras only if you hold TxDOT's written permission (TranStar's Content Usage terms prohibit copying, distributing or framing its images without it; see DATA_SOURCES.md)
|
||||
# CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first)
|
||||
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
@@ -263,9 +273,47 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
|
||||
# Also enables voice place search (osm_query, "hospitals in Kathmandu").
|
||||
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.
|
||||
# GEV_EMBED_FRAME_ANCESTORS=
|
||||
|
||||
# Gemini typed chat uses generateContent; voice continues to use Gemini Live.
|
||||
GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
|
||||
# Typed chat requests (/api/gemini/chat) per IP per minute. Unset, it takes
|
||||
# GEV_RATELIMIT_GEMINI_PER_MIN's cap (default 30); 0 disables it.
|
||||
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=30
|
||||
|
||||
# Optional keyless Radio Browser coverage: up to 8 ISO alpha-2 countries.
|
||||
# Each reserves up to 40 stations within the existing 750-station directory.
|
||||
# Restart after changing. Leave blank for the existing global selection.
|
||||
# RADIO_PRIORITY_COUNTRIES=IN,PL
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -2,5 +2,11 @@
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"endOfLine": "lf"
|
||||
"endOfLine": "lf",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.vue",
|
||||
"options": { "htmlWhitespaceSensitivity": "strict" }
|
||||
}
|
||||
]
|
||||
}
|
||||
+85
-7
@@ -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
|
||||
|
||||
+24
-2
@@ -40,6 +40,7 @@ How to read this:
|
||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
|
||||
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
||||
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
|
||||
@@ -69,6 +70,7 @@ How to read this:
|
||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||
| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | No licence. The [TranStar disclaimer](https://traffic.houstontranstar.org/disclaimer.aspx) (Content Usage) prohibits copying or distribution, and external framing or linking to specific images, without TxDOT's prior written permission. **Off by default**; enable only if you hold TxDOT's written permission | "Houston TranStar" (courtesy) |
|
||||
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
||||
@@ -98,6 +100,8 @@ How to read this:
|
||||
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
| **OpenSnowMap** (`www.opensnowmap.org`) | Ski Pistes layer: raster overlay of ski runs and lifts (#10) | Tiles CC BY-SA; data ODbL 1.0 via OpenStreetMap; bulk tile download forbidden without prior agreement | "Ski pistes © www.opensnowmap.org (CC BY-SA) · Data © OpenStreetMap contributors (ODbL)" |
|
||||
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
|
||||
|
||||
### Local receiver input (not fetched from a service)
|
||||
@@ -118,6 +122,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
<a id="gnss-integrity"></a>
|
||||
@@ -177,16 +182,22 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
|
||||
|
||||
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
- **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.
|
||||
- **Voice areas and place search.** Voice areas (`resolve_area`) use the same outline sources as voice outlines. Voice place search (`osm_query`, `/api/osm/features`) runs only on an operator-configured Overpass: a fixed list of kinds, a bounded box and result count, one query at a time, answers cached for a day. It never uses Nominatim.
|
||||
- **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.
|
||||
|
||||
@@ -208,6 +219,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) | — | — |
|
||||
@@ -307,6 +320,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
**Local setup needs no account or API keys.** The app opens 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
|
||||
@@ -211,7 +212,7 @@ Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front o
|
||||
|
||||
On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`.
|
||||
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
||||
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). The map routes compare the browser's `Origin` with the `Host` the server sees, so the proxy must pass the original `Host` and, when it terminates TLS, `X-Forwarded-Proto`. Caddy does both by default; for nginx add `proxy_set_header Host $http_host;`, `proxy_set_header X-Forwarded-Proto $scheme;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. On an install outside Docker, that forwarding header also rate-limits the proxy's users apart from your own browser. Under the default Docker bridge network every client on the host, your browser included, reaches the container from its gateway address and shares one Overpass bucket.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -233,10 +234,48 @@ the container.
|
||||
|
||||
</details>
|
||||
|
||||
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||
|
||||
Codespaces requires a GitHub account. Overpass queries and OpenAI voice token
|
||||
requests return HTTP 403 through its forwarded HTTPS URL, even with API keys
|
||||
in `.env` and `GEV_ALLOWED_HOSTS` configured. Use a local setup for these features.
|
||||
|
||||
The codespace runs the 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 is unavailable on the forwarded URL. Never commit your workspace
|
||||
`.env`. [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**
|
||||
chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
For a local setup, keys are upgrades, not prerequisites. When you want one,
|
||||
click the **POWER UP** chip in the bottom-right corner: Provider Settings lists every supported key,
|
||||
what it switches on, and where to get it. Paste, hit **SAVE KEYS**, and the app
|
||||
restarts itself with the new capability on. Once everything is configured the
|
||||
chip reads **POWERED UP** — and if a compact layout hides it, `?setup=1`
|
||||
@@ -323,9 +362,11 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
||||
|
||||
## 🎙️ Talk to It
|
||||
|
||||
The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider chooser. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||
The standalone app offers **OpenAI** and **Gemini** under **AI Provider** in Settings, one choice shared by voice and chat. OpenAI is the initial default; your choice is saved in this browser. To use Gemini, add `GEMINI_API_KEY` in Provider Settings or your local `.env` file, then select Gemini. `GEMINI_LIVE_MODEL` defaults to `gemini-3.8-live`. The permanent key stays on the local server, which issues a constrained, single-use session credential.
|
||||
|
||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary.
|
||||
Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The AI Provider setting does not change the OpenAI-backed AI HUD summary.
|
||||
|
||||
Next to the mic, **CHAT** opens a typed chat with silent replies. It uses the selected provider and the same map actions, and never asks for the microphone. OpenAI chat needs an API key (`OPENAI_CHAT_MODEL`), so it is off while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth; Gemini chat uses a separate text model (`GEMINI_CHAT_MODEL`). Chat and voice panels share the left panels' glass surface and sit above the dock or dock to the right.
|
||||
|
||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||
|
||||
@@ -357,7 +398,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.
|
||||
|
||||

|
||||
|
||||
@@ -385,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
||||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
@@ -399,23 +440,25 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 |
|
||||
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
|
||||
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
|
||||
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
|
||||
|
||||
**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 and OSM Places sit in the separate **Utilities** panel with Draw and Import.
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
@@ -491,12 +534,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
|
||||
|
||||
+4
-2
@@ -74,17 +74,19 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
- **Typed chat spends the provider keys.** `/api/openai/chat` and `/api/gemini/chat` send the conversation to OpenAI Chat Completions or Gemini `generateContent` with the server's `OPENAI_API_KEY` or `GEMINI_API_KEY`. They require a same-site `Origin` (the Gemini request check), cap request bodies at 512 KB, refuse upstream redirects, cut the upstream call off after 60 s and return only sanitized errors. OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth it refuses with `503` (`CODEX_OAUTH_REQUIRED`) rather than spend the key. After the provider refuses the key or account (a `401`, a `403`, Gemini's `400` `API_KEY_INVALID` or OpenAI's `429` `insufficient_quota`), a route stops sending that key upstream for a minute, or until the key changes.
|
||||
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
@@ -93,7 +95,7 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
||||
|
||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Typed chat keeps its own per-IP window, set by `GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` and `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`; left unset, each takes its provider's resolved cap (`GEV_RATELIMIT_OPENAI_PER_MIN`, or `GEV_RATELIMIT_GEMINI_PER_MIN`, default `30`), so lowering a provider throttle lowers chat too, while an explicit chat value, including `0`, wins. Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||
- **Docker trusts one address, and only because the port is on loopback.**
|
||||
Docker delivers the host's own browser to the container from the container
|
||||
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 ?? '',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,8 @@
|
||||
- Add a Prohibited & Restricted Airspace layer for FAA prohibited (P) and
|
||||
restricted (R) special-use airspace. The keyless `/api/drone-airspace` proxy
|
||||
pages the public SUA FeatureServer, keeps a one-hour snapshot, and serves
|
||||
stale data when a refresh fails. Zones draw as ground polygons (red
|
||||
prohibited, orange restricted); selection shows name, altitude range, times
|
||||
of use and agency, and the row legend states the layer is informational only
|
||||
and does not include the UAS Facility Map or UAS flight restrictions. Off by
|
||||
default; share-link token `8` (mvanhorn, #1086).
|
||||
@@ -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,18 @@
|
||||
- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera
|
||||
list is one keyless JSON request to
|
||||
`traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are
|
||||
stills synthesized from each numeric camera id on `www.houstontranstar.org`,
|
||||
so a catalog field cannot steer the proxy off-host. Cameras without a
|
||||
snapshot id, and cameras outside Greater Houston, are dropped. TranStar's
|
||||
`dir` field (the side of the freeway the camera sits on) seeds a
|
||||
low-confidence heading along the nearest lanes' travel direction. TranStar's
|
||||
Content Usage terms (traffic.houstontranstar.org/disclaimer.aspx) prohibit
|
||||
copying, distribution and external framing of its images without TxDOT's
|
||||
written permission, so the pack is off by default; operators should set
|
||||
`CCTV_HOUSTON_ENABLED=1` only if they hold that permission. Frames are
|
||||
relayed on demand with `no-store` and not archived. Once enabled, it loads
|
||||
the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and
|
||||
Galveston; `CCTV_HOUSTON_MAX_SOURCES` changes that cap.
|
||||
Snapshots update about every 3 minutes. The catalog-wide ceiling is
|
||||
unchanged; extra cameras share it round-robin with the other packs
|
||||
(mvanhorn, #1125).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Add Sea Marks (OpenSeaMap) and Ski Pistes (OpenSnowMap) as two raster
|
||||
overlay layers under Infrastructure. They drape on the globe stacks and on
|
||||
Google 3D, and share links keep each one (Tom-Neverwinter, #114).
|
||||
@@ -0,0 +1,6 @@
|
||||
- Add optional keyless Radio priority-country coverage with up to 40 stations
|
||||
per configured country inside the existing 750-station cap; the UK alias uses
|
||||
the canonical GB code. Country query
|
||||
failures do not block a healthy global catalog. Bound mirror attempts to three
|
||||
and retry failed or partial refreshes after 60 seconds on all installations,
|
||||
including those without priority countries configured (m3ayush, #1206).
|
||||
@@ -0,0 +1,13 @@
|
||||
- Keep Overpass-backed layers working behind a reverse proxy: `/api/overpass`
|
||||
now uses the keyless same-site gate, admits forwarding headers and still
|
||||
refuses cross-site requests. Direct loopback requests keep their own
|
||||
admission window; proxied requests, including those a local reverse proxy
|
||||
relays over loopback, are admitted as remote traffic keyed by their socket,
|
||||
and IPv6 peers are keyed by /64, so neither LAN peers nor proxied users can
|
||||
exhaust the owner's admission. The upstream budget stays shared, so an owner
|
||||
cache miss can still be rate-limited (asturksever, #1308).
|
||||
- Give the tracking arrival check a wall-clock budget per observed frame and
|
||||
retry only an inconclusive timeout: a timeout keeps every angle it
|
||||
measured, including a late frame's, and a wrong one fails without retry, so slow software-rendered
|
||||
frames no longer fail the check and a real violation is never retried into a
|
||||
pass (asturksever, #1308).
|
||||
@@ -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).
|
||||
@@ -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,5 @@
|
||||
- Military aircraft that leave the adsb.lol feed fade and are removed again.
|
||||
The feed's routine position-less rows no longer mark every snapshot partial,
|
||||
which kept vanished aircraft at full opacity for up to five minutes; those
|
||||
rows keep only their own aircraft, and a likely-landed one still leaves on
|
||||
the landed limit (Aditya, @Rohilalala, #1435).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,3 @@
|
||||
- A terrain request the proxy can only partly answer (a 429 pause, or a point
|
||||
the upstream returns without a height) now keeps the real heights it has
|
||||
instead of putting the whole chunk on the geoid (quanticsoul4772, #1456).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ground-sample Data Centers and Dams markers only when the camera sees the
|
||||
ground under them. Unseen ground read through the Earth and retried every
|
||||
2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s
|
||||
(quanticsoul4772, #1464).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
||||
before Play is pressed; while a stream plays, it follows the new station.
|
||||
Play reads Pause while a stream connects, and a tuner release onto a station
|
||||
the directory removed or changed no longer leaves a Resume for another
|
||||
station (gekh, #1474).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||
translated feed-state warning. A partial layer no longer reads as off or
|
||||
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Street Traffic gives the two TomTom `one_side` lines of one two-way street
|
||||
a two-way direction's half share of simulated vehicles each, so the street
|
||||
is no longer drawn twice as dense as the same street from OpenStreetMap
|
||||
(quanticsoul4772, #1495).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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,9 @@
|
||||
- Add a typed AI chat beside the voice mic in the command dock. Replies are
|
||||
silent and never use the microphone; messages run the same map actions as
|
||||
voice through OpenAI Chat Completions (`OPENAI_CHAT_MODEL`) or Gemini
|
||||
`generateContent` (`GEMINI_CHAT_MODEL`). Chat is throttled per IP by
|
||||
`GEV_RATELIMIT_OPENAI_CHAT_PER_MIN` or `GEV_RATELIMIT_GEMINI_CHAT_PER_MIN`,
|
||||
which default to that provider's voice throttle, and `GEV_PREFER_CODEX_OAUTH`
|
||||
turns OpenAI chat off. The chat and voice panels minimize to a bar or dock
|
||||
beside the right rail, and step aside for the Location and Visual Presets
|
||||
trays (Manjunath, @manjunath22466, with Darren Dignam, @darrendignam, #1507).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw the Global Context 250 km range ring as a 4 px line with a dark halo,
|
||||
so it stays visible over desert, snow, ocean and forest and under the FLIR,
|
||||
NVG and CRT styles, where the old 1 px outline disappeared (manjunath22466,
|
||||
#1518).
|
||||
@@ -0,0 +1,8 @@
|
||||
- The Global Context range ring pulses like a sonar: three dim rings with a
|
||||
soft inward glow grow from the subject to 250 km, and the ring flashes as
|
||||
each one arrives. Flights, military aircraft and AIS vessels a pulse crosses
|
||||
get a brief echo around their brackets. The pulses run for about 9 s after
|
||||
a selection or camera move, dimming the ring between flashes, then rest
|
||||
with the ring steady on screen, so a parked view stops repainting. Under
|
||||
reduced motion there are no pulses and the ring stays on screen
|
||||
(manjunath22466, #1519).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Keep the globe on screen when zooming out from a tilted view. User zoom is
|
||||
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
|
||||
Earth stays framed. Correction finishes when zoom stops, respects tilt
|
||||
gestures and stricter host limits, and yields to other camera actions
|
||||
(asturksever, #1522).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,15 @@
|
||||
- Voice can reuse what it outlines. `resolve_area` turns a named place into a
|
||||
reusable area, keeping every part and hole, from the same sources as voice
|
||||
outlines; a name several countries or states share ("Georgia", "Punjab") is
|
||||
asked about unless the view is inside one of them. "Draw an area around
|
||||
<landmark>" outlines the mapped area enclosing it, or a 250 m buffer marked
|
||||
approximate. `analyst_query` counts inside an area, the area you drew or an
|
||||
outline on the map, and `find_imagery` shows the best recent acquisition for
|
||||
an area, the view or the spot under the pointer (manjunath22466, #846).
|
||||
- `osm_query` ("hospitals in Kathmandu") lists places of one kind into a
|
||||
session-only OSM Places layer. It is offered only when `OVERPASS_UPSTREAMS`
|
||||
is set, and `/api/osm/features` refuses before fetching otherwise; public
|
||||
Nominatim is never used for it (manjunath22466, #846).
|
||||
- Analyst counts that combine flights, military aircraft and local ADS-B are
|
||||
named "aircraft". Follow-ups keep the displayed scope and the loaded-data
|
||||
caveat until a new scope replaces them (manjunath22466, #846).
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
+513
-114
File diff suppressed because it is too large.
Load diff
+30
-7
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -20,7 +20,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
||||
| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) |
|
||||
| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) |
|
||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||
| `gevActions.js` runner | Area handles (page lifetime); analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||
|
||||
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+692
-14
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
@@ -169,6 +176,7 @@
|
||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||
"./layers/gnss": "./src/layers/gnss/index.js",
|
||||
"./layers/ghostmaps": "./src/layers/ghostMaps/index.js",
|
||||
"./layers/drone-airspace": "./src/layers/droneAirspace/index.js",
|
||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||
"./layers/firms": "./src/layers/firms/index.js",
|
||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||
@@ -213,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",
|
||||
@@ -248,6 +256,7 @@
|
||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||
"./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js",
|
||||
"./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js",
|
||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||
|
||||
@@ -30,3 +30,7 @@ PINOKIO_SHARE_VAR=__gev_sharing_disabled__
|
||||
# App-level guards, not billing caps. Provider-side budgets remain authoritative.
|
||||
GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||
GEV_RATELIMIT_GOOGLE_PER_MIN=120
|
||||
# Typed chat follows the OpenAI cap above, or Gemini voice's default of 30,
|
||||
# unless set here; 0 disables it.
|
||||
# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=
|
||||
# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=
|
||||
@@ -28,6 +28,8 @@ module.exports = {
|
||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
||||
},
|
||||
message: 'node scripts/pinokio-install.mjs',
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ module.exports = {
|
||||
PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}',
|
||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
||||
},
|
||||
message: 'node scripts/pinokio-start.mjs',
|
||||
on: [{
|
||||
|
||||
@@ -19,6 +19,8 @@ module.exports = {
|
||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||
GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_OPENAI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN || ""}}',
|
||||
GEV_RATELIMIT_GEMINI_CHAT_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN || ""}}',
|
||||
},
|
||||
message: 'node scripts/pinokio-update.mjs',
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+150
-6
@@ -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",
|
||||
@@ -55,6 +57,9 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"src/app/controls.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
@@ -68,6 +73,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 +186,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 +205,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 +303,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",
|
||||
@@ -397,6 +405,15 @@
|
||||
"src/layers/perimeters/cards.test.mjs",
|
||||
"src/layers/perimeters/inciweb.test.mjs",
|
||||
"src/layers/perimeters/ownership.test.mjs",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/records.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
"src/layers/droneAirspace/cards.js",
|
||||
"src/layers/droneAirspace/records.test.mjs",
|
||||
"src/layers/droneAirspace/source.test.mjs",
|
||||
"src/layers/droneAirspace/cards.test.mjs",
|
||||
"src/layers/droneAirspace/ownership.test.mjs",
|
||||
"src/layers/droneAirspace/docs.test.mjs",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/fireAnchors.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
@@ -526,6 +543,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",
|
||||
@@ -563,6 +581,7 @@
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvHouston.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
@@ -650,7 +669,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",
|
||||
@@ -676,6 +694,7 @@
|
||||
"src/app/layers/radio.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/satellites.js",
|
||||
@@ -740,6 +759,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",
|
||||
@@ -859,6 +880,7 @@
|
||||
"src/layers/weather/imageryOrder.test.mjs",
|
||||
"server/providers/cyclones.js",
|
||||
"server/providers/firePerimeters.js",
|
||||
"server/providers/droneAirspace.js",
|
||||
"src/data/cyclonesProxy.test.mjs",
|
||||
"src/data/firePerimetersProxy.test.mjs",
|
||||
"server/providers/gnssIntegrity.js",
|
||||
@@ -883,6 +905,7 @@
|
||||
"src/layers/ghostMaps/records.test.mjs",
|
||||
"src/layers/ghostMaps/lifecycle.test.mjs",
|
||||
"scripts/qa-ghostmaps.mjs",
|
||||
"src/data/droneAirspaceProxy.test.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
@@ -897,6 +920,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",
|
||||
@@ -909,6 +933,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",
|
||||
@@ -1092,9 +1118,9 @@
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
"scripts/qa-sky-model-lighting.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.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",
|
||||
@@ -1102,14 +1128,18 @@
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/hudSummaryLine.test.mjs",
|
||||
"src/testSupport/stubMgrs.mjs",
|
||||
"src/tools/queries/hud-caption-feed-state.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",
|
||||
@@ -1118,5 +1148,119 @@
|
||||
"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/awarenessEcho.js",
|
||||
"src/awarenessEcho.test.mjs",
|
||||
"src/data/detectionAwarenessEcho.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",
|
||||
"server/providers/common/rateLimitKeys.test.mjs",
|
||||
"src/voice/areaCandidateContract.js",
|
||||
"src/voice/layerActionOwnership.js",
|
||||
"src/data/areaGeometry.test.mjs",
|
||||
"src/data/areaResolver.test.mjs",
|
||||
"src/sources/osmFeatures.test.mjs",
|
||||
"src/layers/osmPlaces/index.test.mjs",
|
||||
"src/tooling/boundedCache.test.mjs",
|
||||
"src/tooling/osmFeaturesRoute.test.mjs",
|
||||
"src/voice/areaActions.test.mjs",
|
||||
"src/voice/imageryActions.test.mjs",
|
||||
"src/voice/osmActions.test.mjs",
|
||||
"src/voice/realtimeProtocol.test.mjs",
|
||||
"src/layers/awareness/rangeRing.test.mjs",
|
||||
"src/voice/voiceMiniPreview.js",
|
||||
"src/voice/voiceMiniPreview.test.mjs",
|
||||
"src/voice/chatPreferences.test.mjs",
|
||||
"src/voice/textChat.test.mjs",
|
||||
"src/voice/chatAttachments.test.mjs",
|
||||
"src/voice/chatControlView.test.mjs",
|
||||
"src/textChatServer.test.mjs",
|
||||
"src/geminiChatServer.test.mjs",
|
||||
"src/voice/geminiTextChat.test.mjs",
|
||||
"server/providers/common/textChat.js",
|
||||
"server/providers/common/textInstructions.js",
|
||||
"server/providers/common/textTools.js",
|
||||
"server/providers/gemini/chat.js",
|
||||
"server/providers/openai/chat.js",
|
||||
"src/ui/styles/ai-chat.css",
|
||||
"src/voice/chatAttachments.js",
|
||||
"src/voice/chatControl.js",
|
||||
"src/voice/chatPreferences.js",
|
||||
"src/voice/geminiTextChat.js",
|
||||
"src/voice/panelLayout.js",
|
||||
"src/voice/textChat.js",
|
||||
"src/voice/textProtocol.js"
|
||||
]
|
||||
+1
-1
@@ -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
@@ -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/') &&
|
||||
|
||||
@@ -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()
|
||||
+622
-65
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,8 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
|
||||
'LL2_API_TOKEN',
|
||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
||||
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
||||
'PINOKIO_SHARE_CLOUDFLARE',
|
||||
'PINOKIO_SHARE_LOCAL',
|
||||
'PINOKIO_SHARE_VAR',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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('; ');
|
||||
|
||||
Loaded 100 of 1249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user