Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65bc522f49 | ||
|
|
ae66e9d299 | ||
|
|
d399dfc92d | ||
|
|
53a5f01a79 | ||
|
|
39d33b3f29 | ||
|
|
d8f1742783 | ||
|
|
9db0813daf | ||
|
|
6b7bca2913 | ||
|
|
b6da93b8ac | ||
|
|
6d83bb6008 | ||
|
|
ac927de71a | ||
|
|
314a0e1c2e | ||
|
|
880a672b5e | ||
|
|
a5aaea7e16 | ||
|
|
6b8b8afba6 | ||
|
|
7afb3beefb |
No files matched your search
@@ -0,0 +1,142 @@
|
||||
# God's Eye View — environment variables
|
||||
# Copy to .env and fill in your keys. On macOS the launcher can also read keys
|
||||
# from the Keychain (see README); on Linux/Windows use this file or env vars.
|
||||
#
|
||||
# Easiest path: don't edit anything. Run the app and paste keys into the
|
||||
# in-app Provider Settings panel (the POWER UP chip, bottom-right) — it writes
|
||||
# this checkout's .env for you (owner-only permissions) and restarts the dev
|
||||
# server. Under the Pinokio launcher it writes pinokio/ENVIRONMENT instead.
|
||||
# Keys you supply yourself (shell env, Keychain) are shown as configured
|
||||
# externally and never touched. This file remains the reference for headless
|
||||
# and self-hosted setups.
|
||||
#
|
||||
# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are
|
||||
# injected into the browser bundle by design (they're used client-side) and
|
||||
# WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
||||
# them (see SECURITY.md). All other keys below stay server-side.
|
||||
|
||||
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
|
||||
# Without it, a Cesium ion token can still load ion-hosted Google 3D; with
|
||||
# neither credential, the app starts on keyless Esri World Imagery with OSM
|
||||
# available in the map tray and as the automatic provider-failure fallback.
|
||||
# CLIENT-EXPOSED — restrict it (HTTP referrer + API restriction) in Google Cloud.
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
# Optional: opt-in per-IP rate limit for the Google Places cost endpoint
|
||||
# (/api/google/nearby-places), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP Places quota spend when self-hosting.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set
|
||||
# provider-side budget alerts: Google Cloud Console -> Billing -> Budgets & alerts
|
||||
# (plus per-API quotas under APIs & Services -> Quotas).
|
||||
# GEV_RATELIMIT_GOOGLE_PER_MIN=60
|
||||
|
||||
# Optional: Google Photorealistic 3D Tiles through Cesium ion, Bing world
|
||||
# imagery, and Cesium World Terrain. The free Community plan is for eligible
|
||||
# personal/non-commercial use and has quotas; check current Cesium terms. A
|
||||
# direct Google key above is still required for GEV place search.
|
||||
# CLIENT-EXPOSED — use a public assets:read token with URL restrictions.
|
||||
CESIUM_ION_TOKEN=
|
||||
|
||||
# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
||||
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
||||
# Model ids and prices are external facts that drift — if OpenAI moves this id,
|
||||
# override it here rather than editing src/voice/voiceCost.js.
|
||||
OPENAI_REALTIME_MODEL_MINI=gpt-realtime-2.1-mini
|
||||
OPENAI_REALTIME_VOICE=marin
|
||||
OPENAI_REALTIME_REASONING_EFFORT=low
|
||||
# Keep only a short conversational window. Current map state is fetched live.
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS=3000
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION=0.5
|
||||
OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
||||
# Optional: opt-in per-IP rate limit for the OpenAI cost endpoints
|
||||
# (/api/realtime/token + /api/openai/hud-summary), in requests per minute.
|
||||
# DEFAULT IS UNLIMITED — unset (or 0) means no throttling, unchanged behavior.
|
||||
# Set a positive integer to cap per-IP spend when self-hosting on a network.
|
||||
# Recommended whenever HOST is not localhost. NOTE: this is a per-IP,
|
||||
# process-local guard (in-memory, resets on restart; over-limit requests get a
|
||||
# sanitized 429) — it is NOT a billing cap. For hard spend protection set a
|
||||
# provider-side budget: OpenAI platform -> Settings -> Limits (usage limits).
|
||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30
|
||||
|
||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||
# Default: oauth — requires client credentials below
|
||||
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
||||
OPENSKY_AUTH_MODE=oauth
|
||||
|
||||
# Optional: OpenSky OAuth credentials (from opensky-network.org account dashboard)
|
||||
OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
|
||||
# Optional: higher Launch Library 2 request allowance. Public access works
|
||||
# without a token.
|
||||
LL2_API_TOKEN=
|
||||
|
||||
# Optional: OpenSky credentials JSON file path (alternative to above)
|
||||
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json
|
||||
|
||||
# Optional: dev server config
|
||||
PORT=4173
|
||||
# Network binding. Default is localhost — the server is reachable ONLY from
|
||||
# this machine. This is the safe default: the dev server brokers your
|
||||
# configured API keys, so anyone who can reach it can spend your quota.
|
||||
# LAN opt-in: set HOST=0.0.0.0 to share on your local network. WARNING: that
|
||||
# exposes every key-brokering proxy to everyone on the network — use only on
|
||||
# networks you trust, and consider the GEV_RATELIMIT_* throttles above.
|
||||
# HOST=localhost
|
||||
# HOST=0.0.0.0
|
||||
|
||||
# Optional: NASA FIRMS live active fires. SERVER-SIDE ONLY (never reaches the
|
||||
# browser — the client polls the /api/firms proxy). Free key:
|
||||
# https://firms.modaps.eosdis.nasa.gov/api/map_key/
|
||||
# Without a key the FIRMS Active Fires layer is empty ("KEY REQUIRED").
|
||||
# FIRMS_MAP_KEY=
|
||||
|
||||
# Optional: Live AIS vessels layer
|
||||
# Browser fetch path. Keep same-origin so the private stream key stays server-side.
|
||||
VITE_AIS_LIVE_API_URL=/api/ais-live
|
||||
# Client render cap. The server can keep a larger AISStream cache, but drawing
|
||||
# every global vessel at once can overwhelm WebGL on laptops.
|
||||
VITE_AIS_LIVE_MAX_ROWS=12000
|
||||
# Active label cap after screen-space clustering. Labels exist for all rendered
|
||||
# ships, but only clustered winners are shown to keep text readable.
|
||||
VITE_AIS_LIVE_LABEL_MAX_ROWS=900
|
||||
# Private AISStream key for the server-side websocket. Do not prefix with VITE_.
|
||||
AISSTREAM_API_KEY=
|
||||
# Optional AISStream subscription filters.
|
||||
# AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]]
|
||||
# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport
|
||||
# Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket
|
||||
# is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the
|
||||
# default worldwide subscription — a narrow filter above can be legitimately
|
||||
# quiet, so set this explicitly (sized for your filter) to opt back in.
|
||||
# 0 disables the silence watch entirely.
|
||||
# AISSTREAM_SILENCE_TIMEOUT_MS=120000
|
||||
|
||||
# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile
|
||||
# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
|
||||
# the browser fetches same-origin /api/tomtom/* and never sees the key.
|
||||
# Keyless fallback: the traffic layer runs its built-in simulation (white dots,
|
||||
# hardcoded per-road-class speeds) — no key required for the layer to work.
|
||||
# TOMTOM_API_KEY=
|
||||
# Optional soft cap on upstream tile fetches per UTC day (default 40000) — a
|
||||
# configurable application safety ceiling, not a guarantee of staying within
|
||||
# TomTom's free allowance (currently 200K tile requests/month:
|
||||
# https://docs.tomtom.com/pricing/). Over the cap the proxy serves cached/stale
|
||||
# tiles instead of hitting upstream.
|
||||
# TOMTOM_DAILY_TILE_BUDGET=40000
|
||||
|
||||
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
|
||||
# unless you're customizing the camera source pack.
|
||||
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
|
||||
# CCTV_SOURCES_JSON= # inline source-pack JSON (overrides file)
|
||||
# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL
|
||||
# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog
|
||||
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force Austin-only sources
|
||||
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)
|
||||
# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Check every text file out with LF on every platform.
|
||||
#
|
||||
# 44 unit test files read repository source with readFileSync and match it
|
||||
# against multi-line regexes (src/scenes/director.test.mjs,
|
||||
# src/cockpitMarkup.test.mjs, src/firstRunExperience.test.mjs, ...). Those
|
||||
# patterns anchor on \n, so under Git's default core.autocrlf=true on Windows
|
||||
# the sources check out with CRLF, every anchored pattern misses, and `npm test`
|
||||
# fails 25 checks on an unmodified clone. Normalizing here keeps the working
|
||||
# tree byte-identical on macOS, Linux, and Windows.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Shell launchers must stay LF to run at all.
|
||||
*.sh text eol=lf
|
||||
|
||||
# Assets Git must never rewrite.
|
||||
*.gif binary
|
||||
*.png binary
|
||||
*.glb binary
|
||||
*.wav binary
|
||||
*.pbf binary
|
||||
@@ -0,0 +1,77 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Node ${{ matrix.node }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24.14.0, 26.x]
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: '1'
|
||||
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }}
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run setup policy checks
|
||||
run: npm run doctor -- --json
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
windows-onboarding:
|
||||
name: Windows onboarding
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: '1'
|
||||
GEV_REQUIRE_ALLOCATION_GATE: '0'
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.14.0
|
||||
cache: npm
|
||||
|
||||
- name: Run the Pinokio install path
|
||||
run: node scripts/pinokio-install.mjs
|
||||
|
||||
- name: Run focused onboarding tests
|
||||
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
|
||||
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
screenshots/
|
||||
qa-shots/
|
||||
output/
|
||||
.gev-logs/
|
||||
.gev-cache/
|
||||
.DS_Store
|
||||
.gstack/
|
||||
3d-models/
|
||||
pinokio/ENVIRONMENT
|
||||
pinokio/.installed
|
||||
@@ -0,0 +1,240 @@
|
||||
# Changelog
|
||||
|
||||
This changelog records public product changes. For the authoritative description
|
||||
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.1] — 2026-09-01 — Installation and live-data fixes
|
||||
|
||||
### Changed
|
||||
|
||||
- Tightened the README opening around keyless setup, source freshness, modeled
|
||||
experiences, and the accessibility of the provider stack.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pinokio now recognizes its nested successful-install marker, so a completed
|
||||
one-click install exposes Start instead of returning to Install.
|
||||
- The keyless `dev-fresh.sh` startup summary now names Esri World Imagery with
|
||||
keyless terrain and identifies OpenStreetMap as the fallback.
|
||||
- All three VIIRS sources now reach the Active Fires layer. Merging a source's
|
||||
detections used argument spread, which exceeds the engine's argument limit on
|
||||
the two largest sources and dropped them entirely — leaving roughly a third of
|
||||
global detections while reporting each dropped source twice, once as
|
||||
successful with its real count and once as failed.
|
||||
- `./scripts/dev-fresh.sh` no longer crashes on stock macOS bash 3.2 when no
|
||||
provider keys are exported: expanding the empty external-keys provenance
|
||||
array under `set -u` was fatal there. Launches with exported keys are
|
||||
unchanged.
|
||||
|
||||
### Security
|
||||
|
||||
- GBFS proxy body-size cap now measures the response in bytes
|
||||
(`Buffer.byteLength`) instead of JavaScript string length, so the
|
||||
`GBFS_MAX_BODY_BYTES` limit holds for multi-byte payloads and cannot be
|
||||
overrun by non-ASCII upstream responses.
|
||||
|
||||
## [0.1.0] — 2026-08-31 — One-click install, keyless boot, Provider Settings
|
||||
|
||||
### Added
|
||||
- **One-click install** via Pinokio. Keyless boot lands on a live Esri World
|
||||
Imagery satellite globe with keyless terrain; OSM takes over automatically if
|
||||
Esri is unreachable, and the globe continues without terrain if its source is
|
||||
unavailable.
|
||||
- **Provider Settings** (the POWER UP panel): add, replace, or remove API keys
|
||||
inside the app. Credential files are made owner-only before any secret is
|
||||
written — verified on macOS and Windows — and keys configured outside the
|
||||
panel are shown read-only, never rewritten.
|
||||
- **Keyless capability responses**: the optional HUD summary and place-search
|
||||
endpoints return a deliberate "not configured" success instead of errors, and
|
||||
never consume rate-limit quota.
|
||||
- `.gitattributes` normalizes line endings, so Windows clones pass the full
|
||||
test suite out of the box (#81 — thanks @ethanstoner).
|
||||
|
||||
### Changed
|
||||
- README rewritten keyless-first around the provider ladder: zero keys → free
|
||||
Cesium ion (eligible personal, non-commercial use) → billing-enabled Google
|
||||
Maps.
|
||||
- Browser-built data modules no longer import `node:fs`; a repo-wide boundary
|
||||
scan test keeps it that way (#83 — thanks @ethanstoner).
|
||||
- Aircraft-identity voice answers explicitly cover operator, type, and route,
|
||||
and say so plainly when enrichment is unavailable instead of guessing.
|
||||
|
||||
### Security
|
||||
- Provider Settings answers only local, unproxied requests and disables itself
|
||||
entirely whenever the server is shared. Public datacenter and dam datasets
|
||||
omit contact-oriented fields (see the dataset READMEs).
|
||||
|
||||
## Pre-release development history
|
||||
|
||||
The dated entries and internal milestone numbers below predate the first
|
||||
tagged GitHub Release. They are retained as project history and do not
|
||||
represent previously published GitHub Releases.
|
||||
|
||||
## [Unreleased] — 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added honest aircraft identity narration: callsign, operator, registration,
|
||||
type, and route come only from selected-contact context, and missing operator,
|
||||
route, or type enrichment is named explicitly.
|
||||
- Added local, publication-compatible copies of the two README PNGs, with source
|
||||
records and third-party-license boundaries in `docs/media/README.md`.
|
||||
- Added regression coverage for aircraft identity narration and optional-key
|
||||
loading feedback.
|
||||
|
||||
### Changed
|
||||
|
||||
- First-run presentation now opens with Detection `DENSE` at 75%, `ELASTIC`
|
||||
allocation, Fade 7%, Outside 1%, scope feather 11%, and aircraft 3D models in
|
||||
`PROXIMITY`. Stored state and share links still override these baselines.
|
||||
- The 17 selected README GIFs remain unchanged and are documented separately
|
||||
from the two owner-published PNGs.
|
||||
- Bundled datacenter and dam snapshots now omit contact-oriented fields and
|
||||
note values containing email or phone identifiers. Feature geometry, names,
|
||||
operator/capacity/river metadata, counts, and ODbL terms are unchanged.
|
||||
- Public documentation and the L9 release matrix no longer reference non-public
|
||||
planning material or repository history.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A missing optional FIRMS key no longer turns the complete Environmental
|
||||
mission into `LOAD FAILED`. The FIRMS row still reports `KEY REQUIRED`, while
|
||||
earthquakes continue to load. Real lifecycle and fetch failures retain
|
||||
failure priority.
|
||||
- The mapped-installations layer retries after an unavailable request when it is
|
||||
enabled or the camera settles.
|
||||
- Aircraft trails attach to the rendered aircraft transform and remain near the
|
||||
rear center across headings. Parked aircraft do not draw a moving head
|
||||
segment.
|
||||
- Grounded aircraft keep validated floor evidence through temporary terrain
|
||||
outages and wait for measured photoreal-surface evidence before a 3D model
|
||||
takes over from its billboard.
|
||||
- Cockpit altitude uses aviation MSL data rather than Cesium render height.
|
||||
|
||||
### Security
|
||||
|
||||
- Production transitive dependencies resolve to patched DOMPurify and
|
||||
protobufjs releases without changing the Cesium version or application APIs.
|
||||
- Production dependency audit reports no known advisories; remaining audit
|
||||
findings are confined to development and QA tooling.
|
||||
|
||||
## [Unreleased] — 2026-08-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added a first-run mission launcher for Contacts, Space Missions,
|
||||
Environmental, and manual exploration.
|
||||
- Added terrain-validity gating and bounded last-known placement for grounded
|
||||
aircraft models.
|
||||
|
||||
### Changed
|
||||
|
||||
- Environmental consistently presents both earthquakes and NASA FIRMS fires,
|
||||
with honest optional-key degradation.
|
||||
- The tracked aircraft trail acceptance bar is visual: roughly rear-center,
|
||||
stable across headings, with minor hull overlap allowed and no conspicuous
|
||||
top, bottom, or lateral projection.
|
||||
|
||||
## [Unreleased] — 2026-08-18 to 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added the four-source Map Source tray, share-link v2 state, cockpit/context
|
||||
voice parity, MSL altitude readouts, and close-range tracked aircraft models.
|
||||
- Added the L9 release-candidate matrix, AIS feed watchdog, voice cost controls,
|
||||
satellite classes, and the shared world-overlay host.
|
||||
- Added deterministic first-run, map-source, floor, overlay, tracking, and
|
||||
aircraft-model regression harnesses.
|
||||
|
||||
### Changed
|
||||
|
||||
- Consolidated world labels, cards, tracked readouts, CCTV thumbnails, cable
|
||||
labels, mission labels, and detection presentation under shared allocation and
|
||||
lifecycle rules.
|
||||
- Reduced idle rendering through the render governor and explicit scope mask.
|
||||
- Improved cockpit layout, context restoration, keyless feed honesty, and
|
||||
aircraft 2D/3D handoffs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed degenerate depth picks, map-source restore states, route-camera motion,
|
||||
bright-ground label readability, grounded display flooring, and cross-layer
|
||||
tracking cleanup.
|
||||
- Fixed stale overlay callbacks, parked-idle render leaks, cable-label sweep
|
||||
starvation, and several share-link state conflicts.
|
||||
|
||||
## [Unreleased] — 2026-08-02 to 2026-08-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added Global Context modes, Cockpit briefing surfaces, Radio context,
|
||||
satellite mission replay, and real per-class aircraft models with adjacent
|
||||
provenance records.
|
||||
- Added a shared screen-space overlay system with bounded allocation for labels,
|
||||
cards, callouts, detection brackets, and selected-object presentation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified right-side product controls and responsive cockpit/map layouts.
|
||||
- Migrated public-safe neighborhood geometry to DataSF and tightened safe local
|
||||
development defaults.
|
||||
- Improved proxy resilience, annotation outline bounds, CCTV enable pacing,
|
||||
contact de-emphasis, and deterministic visual stacking.
|
||||
|
||||
## [Unreleased] — July 2026
|
||||
|
||||
### Added
|
||||
|
||||
- Added live NASA FIRMS fires, optional live TomTom traffic, Caltrans and TfL
|
||||
CCTV packs, CCTV viewsheds and direct-manipulation calibration, citywide CCTV
|
||||
cards, Natural Earth regions, analyst queries, and voice routing QA.
|
||||
- Added the end-to-end vertical-datum system for aircraft, vessels, CCTV,
|
||||
annotations, trails, and terrain-aware rendering.
|
||||
- Added aircraft class silhouettes, path-derived display heading, ADSBDB
|
||||
enrichment, cached CelesTrak TLE lookup, and next-ISS-pass prediction.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed elevated-airport aircraft placement, vessel sea-surface placement,
|
||||
close-zoom FIRMS anchors, antimeridian region framing, annotation resolution,
|
||||
cross-layer tracking ownership, and CCTV projection lifecycle issues.
|
||||
|
||||
## [Unreleased] — June 2026
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenAI Realtime voice control, scene-aware entity context, viewport image
|
||||
grounding, the AI HUD summary, live AIS vessels, infrastructure layers, map
|
||||
source switching, free-text navigation, and server-side data proxies.
|
||||
- Added hybrid map annotations, 3D aircraft, panoptic detection, tracking
|
||||
harnesses, and public data attribution.
|
||||
- Added MIT source licensing, security guidance, contribution guidance, data
|
||||
source notices, and third-party asset boundaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the experimental AI video-edit style and retained seven deterministic
|
||||
visual styles.
|
||||
- Moved Realtime text-history trimming to the server-side retention policy while
|
||||
keeping only the latest viewport image in conversation context.
|
||||
|
||||
## [0.7.0] — 2026-02-18
|
||||
|
||||
- Added the Bikeshare Pulse layer and panoptic label improvements.
|
||||
- Improved tracked-item boxes, post-render alignment, and CCTV projection
|
||||
quality.
|
||||
- Removed the experimental shift-drag CCTV calibration interaction.
|
||||
|
||||
## [0.6.0] — 2026-02-10
|
||||
|
||||
- Added the initial multi-layer 3D globe experience, visual styles, live
|
||||
aircraft, satellites, earthquakes, CCTV, traffic, FIRMS, infrastructure, and
|
||||
performance controls.
|
||||
- Added entity inspection, tracking, scenes, keyboard controls, and shareable
|
||||
views.
|
||||
|
||||
## [0.1.0] — 2026-02-09
|
||||
|
||||
- Initial project version.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Contributing to God's Eye View
|
||||
|
||||
Thanks for being here. God's Eye View is an open foundation for live spatial intelligence in the browser, and it gets better when more people run it, break it, and extend it.
|
||||
|
||||
## Getting set up
|
||||
|
||||
Use Node.js 24.14.x or 26.x (also enforced by `package.json`).
|
||||
|
||||
```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
|
||||
npm install
|
||||
npm run doctor
|
||||
./scripts/dev-fresh.sh # or: npm run dev (keys are optional)
|
||||
```
|
||||
|
||||
No key is required to start: the app boots on keyless Esri World Imagery with
|
||||
keyless terrain, and OSM takes over automatically if Esri is unreachable.
|
||||
Google Maps provides direct photorealistic 3D and place search; Cesium ion
|
||||
provides ion-hosted Google 3D plus optional Bing/world-terrain stacks.
|
||||
On macOS the launcher pulls optional keys from
|
||||
the Keychain; on any platform you can pass them as env vars or use a `.env`.
|
||||
People who only want to run the app can instead install the repository directly
|
||||
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.**
|
||||
|
||||
## Good first contributions
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **🛰️ Add or improve a data layer.** Each layer is one self-contained module in `src/data/<layer>.js` implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template.
|
||||
- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `vite.config.js`) and executed client-side (`src/voice/gevActions.js`). Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
- **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md).
|
||||
|
||||
## Architecture in one minute
|
||||
|
||||
- **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/).
|
||||
- **UI lives in `src/ui.js`** (panels, HUD, styles, the control facade). **Layer logic lives in `src/data/<layer>.js`.** Keep them separate.
|
||||
- **Secrets stay server-side.** Anything needing a private key goes through a Vite proxy in `vite.config.js`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens.
|
||||
- `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first.
|
||||
|
||||
## Coding style
|
||||
|
||||
- ES modules, **2-space indent, single quotes, semicolons.**
|
||||
- JSDoc on exported/public functions.
|
||||
- Match the surrounding code — comment density, naming, and idiom.
|
||||
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Branch off `main`.
|
||||
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors.
|
||||
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR.
|
||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
||||
|
||||
## Ground rules
|
||||
|
||||
- This is a tool for **public** data. Don't add scraping of sources whose terms forbid it, private/paywalled datasets, or anything that misrepresents public-data inference as authoritative intelligence.
|
||||
- Be decent to each other. Assume good faith, keep it constructive.
|
||||
|
||||
By contributing, you agree your contributions are licensed under the project's [MIT License](LICENSE).
|
||||
@@ -0,0 +1,132 @@
|
||||
# Data Sources & Attribution
|
||||
|
||||
God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md).
|
||||
|
||||
How to read this:
|
||||
|
||||
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
|
||||
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
|
||||
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
|
||||
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
|
||||
|
||||
---
|
||||
|
||||
## Live sources (fetched at runtime — not stored in this repo)
|
||||
|
||||
| Source | Used for | License / terms | Attribution |
|
||||
|--------|----------|-----------------|-------------|
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., *"Bringing Up OpenSky"*, IPSN 2014 + opensky-network.org |
|
||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
|
||||
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (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 |
|
||||
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
|
||||
| **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" |
|
||||
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
|
||||
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
|
||||
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
|
||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **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)" |
|
||||
|
||||
### Notes on the live sources
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; 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).
|
||||
- **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.
|
||||
- **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts.
|
||||
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. 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.
|
||||
|
||||
---
|
||||
|
||||
## Bundled snapshots (committed under `src/data/local_data/`)
|
||||
|
||||
Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README.
|
||||
|
||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||
|---------|--------|---------|-----------------|-------------|
|
||||
| **Datacenters** (~4.3K) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | "© OpenStreetMap contributors" (+ Open Infrastructure Map) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
|
||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||
|
||||
The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial**-**ShareAlike**). It is bundled so the cables layer works out of the box, but it is **not covered by this project's MIT license**. CC BY-NC-SA permits redistribution with attribution and share-alike — which is exactly how it ships here — but the **NonCommercial** clause means:
|
||||
|
||||
> If you use God's Eye View commercially, delete `src/data/local_data/telegeography_submarine_cables/` (or obtain a commercial license from TeleGeography). It is one self-contained folder; the rest of the app runs without it.
|
||||
|
||||
The richer structured dataset is licensed separately/commercially by TeleGeography.
|
||||
|
||||
### ODbL share-alike (datacenters, dams)
|
||||
|
||||
The OSM-derived datasets are under the **Open Database License**. ODbL's share-alike applies to the **data / derived database, not this MIT-licensed code** — the two coexist (exactly how Open Infrastructure Map ships: MIT software + ODbL data). If you publicly distribute a *modified* version of these databases, you must offer it under ODbL. Keep the "© OpenStreetMap contributors" notice (link: https://www.openstreetmap.org/copyright).
|
||||
|
||||
### NASA FIRMS acknowledgement
|
||||
|
||||
> We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS).
|
||||
|
||||
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
|
||||
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
|
||||
Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
|
||||
transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
|
||||
fetched from the canonical `nvkelso/natural-earth-vector` GitHub repo, commit
|
||||
`ca96624a56bd078437bca8184e78163e5039ad19`, 2026-07-28): `ne_10m_geography_regions_polys`
|
||||
(mountain ranges, deserts, plateaus, peninsulas, islands, …) → `regions.json` and
|
||||
`ne_10m_geography_marine_polys` (seas, gulfs, straits, bays) → `marine.json`. They back the
|
||||
voice-annotation resolver's named-natural-region lookup (`src/data/naturalEarthRegions.js`),
|
||||
so "outline the Alps" draws the real range polygon offline.
|
||||
|
||||
Curation (provenance in each file's `meta` header): named features only, outer rings only,
|
||||
Douglas-Peucker simplified at ~0.01° with coordinates rounded to 3 decimals, sub-20 km²
|
||||
MultiPolygon crumbs and zero-area sliver artifacts dropped (7.3 MB source → 2.5 MB pack).
|
||||
|
||||
Natural Earth is **public domain** (no permission needed, no attribution legally required —
|
||||
https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made with Natural
|
||||
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
|
||||
wiring (see below).
|
||||
|
||||
### DataSF Analysis Neighborhoods (`neighborhoods/`)
|
||||
|
||||
`neighborhoods/san-francisco.json` bundles the City & County of San Francisco's official
|
||||
**"Analysis Neighborhoods"** dataset (41 neighborhood polygons; DataSF dataset `j2bu-swwd`,
|
||||
catalog map view
|
||||
[`p5b7-5n3h`](https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h)).
|
||||
It backs the voice-annotation resolver's offline neighborhood-boundary lookup
|
||||
(`src/data/neighborhoodPolygons.js`), so "outline Chinatown" draws the city's real
|
||||
boundary polygon with no network dependency.
|
||||
|
||||
The dataset is licensed **PDDL 1.0** (Open Data Commons Public Domain Dedication and
|
||||
License — public domain; the DataSF metadata declares `licenseId: "PDDL"`). No attribution
|
||||
is legally required; we note the source here and in the folder's `SOURCE.md`, which records
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
@@ -0,0 +1,54 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bilawal Sidhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
NOTE ON THIRD-PARTY DATA AND ASSETS — THE MIT LICENSE ABOVE COVERS THE SOURCE
|
||||
CODE ONLY.
|
||||
|
||||
The datasets bundled under src/data/local_data/, and all data fetched from
|
||||
third-party providers at runtime, are owned by their respective sources and
|
||||
are NOT licensed under MIT. They are included/visualized for convenience and
|
||||
remain subject to their own licenses and terms. Notably:
|
||||
|
||||
- TeleGeography Submarine Cable Map (src/data/local_data/
|
||||
telegeography_submarine_cables/): CC BY-NC-SA 3.0 — NonCommercial +
|
||||
ShareAlike. NOT for commercial use. If you use this project commercially,
|
||||
remove these files or obtain a commercial license from TeleGeography.
|
||||
- Datacenters and Dams (OpenStreetMap / Open Infrastructure Map extracts):
|
||||
Open Database License (ODbL) 1.0 — attribution + share-alike on the data.
|
||||
- NASA FIRMS active-fire snapshot: CC0 / U.S. public domain (citation
|
||||
requested).
|
||||
|
||||
Live sources (Google Maps, OpenSky, adsb.lol, AISStream, CelesTrak, USGS,
|
||||
OSM Overpass, City of Austin, GBFS) are fetched at runtime under each
|
||||
provider's terms; some (e.g. Google Maps, OpenSky) restrict commercial use
|
||||
and require you to supply your own credentials.
|
||||
|
||||
In short: the MIT grant does not extend to any third-party data. See
|
||||
DATA_SOURCES.md for the full per-source license + attribution summary, and
|
||||
remove any dataset whose terms don't fit your use.
|
||||
|
||||
The 3D models under public/models/ are also third-party works and are NOT
|
||||
licensed under MIT. They remain under their individual licenses. See
|
||||
public/models/README.md for the author, source, license, modification notice,
|
||||
and attribution covering each model file.
|
||||
@@ -0,0 +1,498 @@
|
||||
<div align="center">
|
||||
|
||||
# 🌐 God's Eye View
|
||||
|
||||
### A spy-satellite simulator in your browser — then you realize the sources are public and the data is real.
|
||||
|
||||
Photorealistic 3D globe. Live aircraft, ships, satellites, earthquakes, traffic, and public cameras. Hands-free voice control powered by a realtime AI agent.
|
||||
|
||||
*No place left behind.*
|
||||
|
||||

|
||||
|
||||
<a href="https://www.youtube.com/@bilawalsidhu">
|
||||
<img src="docs/media/youtube-popular-videos.png" alt="The God's Eye View video series on YouTube" width="100%">
|
||||
</a>
|
||||
|
||||
▶️ **From the project behind the viral God's Eye View series** *(formerly WorldView)* — [5M+ on YouTube](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · [25M+ across socials](https://www.google.com/search?q=god%27s+eye+view)
|
||||
|
||||
[](https://github.com/trending)
|
||||
|
||||
🏆 **#1 on GitHub Trending this past week — thank you.** You asked for a one-click install; it's here.
|
||||
|
||||
⚡ **No keys, no signup, no config file.** One click through [Pinokio](https://pinokio.computer/) — or `npm install && npm run dev` — and the globe comes to life. Keys are power-ups you paste into the app later. **[→ Quick Start](#-quick-start)**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[Quick Start](#-quick-start) · [First Five Minutes](#-the-first-five-minutes) · [Talk to It](#-talk-to-it) · [What's Live](#-whats-on-the-globe) · [Under the Hood](#-under-the-hood) · [Keys & Costs](#-api-keys)**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Why This Exists
|
||||
|
||||
**You asked, so it's happening.** God's Eye View is open source. Track the world live. Talk to it. Break it. Extend it.
|
||||
|
||||
Most open-source intelligence is a pile of browser tabs. The signals are abundant, but the *interface* is the bottleneck. God's Eye View turns those signals into a **place**: the world is already broadcasting — flight transponders, ship beacons, orbital elements, seismographs, public cameras — and this makes it visible on a photorealistic 3D Earth in real time. No classified clearance required; it's public signal all the way down, and the interface runs in your browser, under your control.
|
||||
|
||||
> Half the magic is that it looks like a forbidden cockpit. The other half is that every line of code is inspectable.
|
||||
|
||||
Most feeds are live or regularly refreshed. Traffic is simulated along real
|
||||
roads using aggregate location data. CCTV camera poses and rocket launch
|
||||
trajectories are coarse estimates.
|
||||
|
||||
You'll be surprised how accessible this is. Free and nearly-free APIs deliver
|
||||
a surprisingly complete experience out of the box — then it's yours to extend
|
||||
with bigger data sources whenever you're ready.
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ What This Thing Does
|
||||
|
||||
- **🛩️ Cockpit view:** Ride inside a tracked flight — the camera holds the terrain under you all the way down.
|
||||
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
|
||||
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
|
||||
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
|
||||
- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in.
|
||||
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir, Snow.
|
||||
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
|
||||
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
|
||||
- **🌐 Global Context:** Stage the full situational picture with one switch — and get your exact view back when you leave.
|
||||
- **🎥 Scene director:** Capture cinematic camera tours for clips and demos.
|
||||
- **🔗 Share Links:** Camera, style, layers, and even one tracked target serialize into a URL — a live target is a handoff, not a bookmark.
|
||||
- **🏠 Reset Globe:** One control — or one sentence — back to the full Earth.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.youtube.com/watch?v=GRJaKcXZS94)
|
||||
|
||||
▶️ **[The full walkthrough of everything below, on YouTube](https://www.youtube.com/watch?v=GRJaKcXZS94)**
|
||||
|
||||
</div>
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**Nothing to sign up for to get started.** Both paths below land you in the
|
||||
same place: a live satellite globe — keyless Esri World Imagery with keyless
|
||||
terrain, and OSM stepping in automatically if Esri is ever unreachable — with
|
||||
aircraft, military traffic, satellites, earthquakes, public cameras, radio and
|
||||
launches already moving on it. No account, no key, no file to edit.
|
||||
|
||||
**Optional signups, optimal experience.** The keyless globe gets you running;
|
||||
a couple of two-minute signups make it spectacular. Want the photorealistic-3D
|
||||
cities? A **free Cesium ion token** covers them for eligible personal,
|
||||
non-commercial use — no Google account needed; current ion terms and quotas
|
||||
apply. Prefer them straight from Google, plus in-app place search? A
|
||||
**Google Maps key** is the billing-enabled, metered route — with a surprisingly
|
||||
generous free tier ([real numbers](#-api-keys)). Either one pastes straight
|
||||
into **Then power it up** below.
|
||||
|
||||
### Path 1 — One click, no terminal
|
||||
|
||||
1. Install [Pinokio](https://pinokio.computer/).
|
||||
2. In **Discover → Download from URL**, paste
|
||||
`https://github.com/bilawalsidhu/gods-eye-view`.
|
||||
3. Click **Install**, then **Start**.
|
||||
|
||||
That is the whole thing. The launcher verifies Pinokio's runtime, installs the
|
||||
locked dependencies, finds a free local port, and opens the app.
|
||||
|
||||
### Path 2 — Terminal / coding agent
|
||||
|
||||
Requires Node.js 24.14.x or 26.x. Node 25 is usable but EOL; the setup doctor
|
||||
warns instead of blocking it.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run doctor
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **`http://localhost:4173`**. Cold start settles in under two seconds on a
|
||||
recent laptop (median 1.86 s in a point-in-time M5/Chrome capture —
|
||||
[docs/PERFORMANCE.md](docs/PERFORMANCE.md); a comparison baseline, not a hardware
|
||||
requirement). A first-run card offers to stage a mission for you — **Live
|
||||
Contacts**, **Space Missions**, **Environmental** — or leaves you to explore
|
||||
manually.
|
||||
|
||||
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
|
||||
configured keys straight from the Keychain. It starts keyless too.
|
||||
|
||||
### 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,
|
||||
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`
|
||||
reopens the same panel.
|
||||
|
||||
- **Where keys land:** Pinokio → the app's ignored `pinokio/ENVIRONMENT`; a
|
||||
terminal clone → the repo-root `.env`. Either file is made owner-only
|
||||
*before* a secret is written into it, and it never leaves your machine.
|
||||
- **Keys you already have stay yours:** values from your shell or the macOS
|
||||
Keychain show as *configured externally* and are read-only to the panel.
|
||||
- **What to get first:** the free [Cesium ion](https://cesium.com/ion) token
|
||||
(eligible personal, non-commercial use; current terms and quotas apply) for
|
||||
photorealistic 3D and world terrain; a Google Maps key only for the
|
||||
billing-enabled, metered route + place search; OpenAI when you want to talk
|
||||
to the world. Full map, costs included, in [Keys & Costs](#-api-keys).
|
||||
|
||||
> [!WARNING]
|
||||
> Do not enter credentials in Pinokio 8.0.40's native **Configure** panel: that
|
||||
> release does not save this nested app file correctly, and it logs submitted
|
||||
> values. Use Provider Settings inside the app instead. Both file stores are
|
||||
> local plaintext; on macOS the Keychain via `./scripts/dev-fresh.sh` remains
|
||||
> the stronger option.
|
||||
|
||||
The server binds to **localhost** on both paths, and Provider Settings answers
|
||||
requests only from your machine. Browser-side keys (Google Maps, Cesium ion)
|
||||
must be restricted at their providers — [SECURITY.md](SECURITY.md) shows how,
|
||||
and it carries the LAN-sharing rules alongside [Keys & Costs](#-api-keys).
|
||||
|
||||
---
|
||||
|
||||
## 🕐 The First Five Minutes
|
||||
|
||||
No account, no signup. The first-run card will offer to stage a mission for you — or run this gauntlet yourself. Somewhere in these five minutes it stops feeling like a demo:
|
||||
|
||||
1. **Light up the sky.** Take the **Live Contacts** mission (or turn on **Flights** yourself) — thousands of live aircraft, gliding on real telemetry, detection mesh already reading the scene. Click one: the camera locks on, a trail draws behind it, and its live telemetry card comes up.
|
||||
2. **Take the controls.** Hit **COCKPIT** on your tracked plane and ride it down, switching sensors mid-flight: NVG into Ironbow FLIR.
|
||||
|
||||

|
||||
|
||||
3. **Drop into a busy airport.** Search one and descend to the taxiways with **3D** aircraft on — grounded contacts, taxi trails, the whole apron working in real time.
|
||||
|
||||

|
||||
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
|
||||

|
||||
|
||||
5. **Track something in orbit.** Turn on **Satellites** and click the ISS — you ride along at orbital distance, orbit ring and all.
|
||||
|
||||

|
||||
|
||||
6. **Switch the optics.** Tap `1`–`7` — CRT, NVG, FLIR — and the whole live planet re-renders through a different sensor.
|
||||
|
||||

|
||||
|
||||
7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."*
|
||||
8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."*
|
||||
|
||||
**Keyboard:** `1`–`7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
|
||||
|
||||
---
|
||||
|
||||
## 🛩️ The Cockpit
|
||||
|
||||
> Every plane should let you do this.
|
||||
|
||||
Real-time cockpit mode, built from live flight data: the camera rides your contact with real terrain holding underneath, all the way down — sensor styles come along for the ride, and **Contacts** keeps the 250 km roster one click away: jump plane to plane and fall straight into the next cockpit.
|
||||
|
||||

|
||||
|
||||
The cockpit even carries its own briefing strip: nearby live signals, regional headlines, and real local weather — with an opt-in **WX** mode that renders volumetric clouds from actual observations around your aircraft.
|
||||
|
||||

|
||||
|
||||
*Why cockpit mode exists: you're riding a real aircraft over real terrain — and you get to pick which sensor you see the world through.*
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ Talk to It
|
||||
|
||||
> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||
|
||||
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
|
||||
|
||||
- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask *"what city is this?"* mid-flight and it knows.
|
||||
- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask *"what's this?"* It answers using the object's live telemetry.
|
||||
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
|
||||
- **🎬 Cinematic framing.** *"Show me the planes overhead"* pulls the camera back, angles it, and frames the live traffic like a director.
|
||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
|
||||
|
||||
Twenty-eight tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||
|
||||
**🎥 Direct it** — drone-operator camera verbs:
|
||||
> 🗣️ *"Take me to Tokyo."* · *"Orbit around this area slowly."* · *"Draw the walking route from the Capitol to Zilker Park."* → *"Fly the route we just drew."* · *"Zoom out to a globe view."*
|
||||
|
||||
**🖊️ Annotate it** — a whiteboard over the real world:
|
||||
> 🗣️ *"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."*
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**🔎 Interrogate it** — analyst queries against the live layers:
|
||||
> 🗣️ *"How many flights are over Texas right now?"* · *"Which ships are headed to Oakland?"* · *"What is the biggest fire near Los Angeles?"* · *"Is anything flying above forty thousand feet?"* · *"When does the ISS pass over next?"*
|
||||
|
||||
**🎛️ Operate it** — the whole console, hands-free:
|
||||
> 🗣️ *"Switch to night vision and turn on the flights layer."* · *"Turn on the camera viewsheds."* · *"Play a news radio station near Austin."* · *"Track that plane."* → *"Enter Cockpit."*
|
||||
|
||||
**And the rapid-fire tier** — one sentence each:
|
||||
> 🗣️ *"Show me global infrastructure."* (stages the layers and pulls back to the globe) · *"Play Orbital Watch."* (a full cinematic scene) · *"Set detection density to fifty percent."* · *"Next contact — helicopters only."* (mid-cockpit) · *"Show me space missions."* · *"Switch to OSM."* · *"Sharpen the image a touch."* · *"Switch to the tactical layout."* · *"What's turned on right now?"*
|
||||
|
||||

|
||||
|
||||
*Ask for radio near anywhere and the globe starts broadcasting — every station is a real place you can fly to.*
|
||||
|
||||
---
|
||||
|
||||
## 🛰️ What's on the Globe
|
||||
|
||||
Thirteen live layers. **Eleven of them need nothing at all** — no key, no account, no signup, starting with the satellite basemap you land on. (🟢 nothing · 🟡 free key · 🔴 metered.)
|
||||
|
||||
| Layer | What you get | Source | Auth |
|
||||
|-------|--------------|--------|------|
|
||||
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
|
||||
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
|
||||
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **Traffic** | Live congestion driving per-vehicle flow at street level — dive below ~8 km and the dots color to real jams. Keyless it's an approximate simulation | TomTom + OSM | 🟢 (🟡 TomTom makes it real — get one) |
|
||||
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📻 **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 | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🔥 **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 token raises the allowance) |
|
||||
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
|
||||
|
||||
**The basemap ladder — what each tier buys you:**
|
||||
|
||||
| You have | The globe you get |
|
||||
|---|---|
|
||||
| 🟢 Nothing | Esri World Imagery satellite basemap + keyless terrain, in 2D. OSM takes over automatically if Esri is unreachable; if terrain is unavailable the globe continues without it |
|
||||
| 🟡 A free Cesium ion token | **Google Photorealistic 3D cities** and world terrain — eligible personal, non-commercial use; current ion terms and quotas apply |
|
||||
| 🔴 A Google Maps key | The same 3D direct from Google, plus in-app place search — the billing-enabled, metered route |
|
||||
|
||||

|
||||
|
||||
*The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×.*
|
||||
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
|
||||
|
||||

|
||||
|
||||
**Missing a layer you want?** Open an issue — or add it and send the PR.
|
||||
|
||||
---
|
||||
|
||||
## 🎖️ Field Missions
|
||||
|
||||
Once the basics click, run these:
|
||||
|
||||
| Mission | How |
|
||||
|---|---|
|
||||
| **🚁 Ask the planet** | *"Why are all these military helicopters flying in circles?"* Select a military track — it silently backfills ~24 h of real trace history — and see what it's been doing, resolved as stacked 3D loops. |
|
||||
| **✈️ Final approach** | Click-track an airliner lining up for a runway, hop into the **cockpit**, and ride it down. |
|
||||
| **🌃 Night watch** | Fly to your own city, switch to **NVG**, and let the detection mesh and HUD read the scene. |
|
||||
| **🚢 Port call** | Vessels on over the Port of Long Beach. Click a tanker for its tactical card and wake trail — then hit **NEAREST** in the CCTV panel and look at the same water through a public camera. |
|
||||
| **📻 Tokyo FM** | Orbit Shibuya with the **Radio** layer on — then drag the analog tuner needle: every position snaps to a real station and the globe flies to whoever's broadcasting. |
|
||||
| **🔥 Fire line** | FIRMS over California. Click a detection — the camera dives to it — read the intensity, then hit **NEAREST** in the CCTV panel for a ground view. |
|
||||
| **🚶 Ask for a walking route** *🎙️* | Tell the world where you want to go and watch a real street-following route trace itself through the 3D city — then *"fly it"*: banked turns, eased ends, a camera that leads the path like a drone shot. |
|
||||
| **📏 Measure LAX to DFW** *🎙️* | *"How far is LAX from DFW?"* — an arrow spans the country, the distance lands in the caption, and the endpoints stay pinned to the real world as you orbit. |
|
||||
| **🚀 Launch replay** | Open **Space Missions**, pick a launch from the last 30 days, and ride the T-minus countdown through ascent to orbit — scrub it at 0.25×–4×. Labeled `RECONSTRUCTED ESTIMATE`, because it is one. |
|
||||
| **🪦 Walk the boneyard** | Fly from regional context down into dense, fully resolved rows of retired aircraft. |
|
||||
| **🏗️ Orbit Three Gorges** | Sweep the dam and its terrain at a glance — then flip on the **Dams** layer and find 703 more. |
|
||||
|
||||
*🎙️ = voice missions — they need an OpenAI key.*
|
||||
|
||||

|
||||
|
||||
*Ask the planet: a military contact's last ~24 hours of real trace history, resolved as stacked 3D loops.*
|
||||
|
||||

|
||||
|
||||
*"Draw the walking route… now fly it" — banked turns, eased ends, the camera leading the path like a drone shot.*
|
||||
|
||||

|
||||
|
||||
*Walk the boneyard: rows of retired airframes, fully resolved in 3D.*
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Under the Hood
|
||||
|
||||
Some of the engineering that makes it feel real rather than like a tech demo:
|
||||
|
||||
- **World-stable icons.** Aircraft and ships point along their *true real-world heading* at every camera angle — tracked or not, looking straight down or across the horizon — via per-frame screen-space course projection. No spinning, no viewport-locking.
|
||||
- **Smooth motion from choppy data.** Live feeds arrive every 15–30s; the globe renders one interval behind real time and interpolates between known fixes. Dead reckoning fills the gaps.
|
||||
- **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker.
|
||||
- **Sits on the real ground.** Entity heights run through a real vertical datum — geoid-aware, sampled against the *rendered* terrain mesh — so aircraft park on aprons and cameras stand on street corners instead of floating.
|
||||
- **Spends your quota like it's its own.** The paid feeds run behind cached, budget-governed proxies — an OpenSky credit governor, a TomTom daily tile budget, disk-cached TLEs — so an afternoon of exploring doesn't torch an API allowance.
|
||||
- **Secure by design.** 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.
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.js # Bootstrap: Google 3D tiles, layer registration
|
||||
├── ui.js # Runtime UI — panels, HUD, styles, control facade
|
||||
├── 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
|
||||
├── iconOrientation.js # Screen-projected world-space headings + horizon cull
|
||||
├── voice/ # OpenAI Realtime session + 28 voice tools
|
||||
├── data/ # One module per layer + orchestration + context store
|
||||
│ └── local_data/ # Bundled datasets (per-folder provenance)
|
||||
└── scenes/ # Cinematic scene director
|
||||
```
|
||||
|
||||
See [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md) for the authoritative runtime reference.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 API Keys
|
||||
|
||||
**The legend, one more time:** 🟢 **no signup** — works out of the box · 🟡 **free key** — register, paste, done · 🔴 **metered** — a billing-enabled account; costs are small but real.
|
||||
|
||||
Most of the globe is 🟢: flights (anonymous), military traffic, satellites, earthquakes, CCTV, radio, bikeshare, space missions, mapped installations, and every bundled dataset run with **zero keys**.
|
||||
|
||||
**And you never have to edit a file to add one.** Click **POWER UP** in the
|
||||
bottom-right corner of the running app, paste the key into Provider Settings,
|
||||
hit **SAVE KEYS** — the app writes it to its own local store with owner-only
|
||||
permissions and restarts itself. Everything below is the map of what each key
|
||||
actually buys you.
|
||||
|
||||
### What you need for the good experience
|
||||
|
||||
Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
||||
|
||||
| | Key | Why | Get it |
|
||||
|---|-----|-----|--------|
|
||||
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
||||
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
||||
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
|
||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free, seriously, it's a two-minute signup |
|
||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||
| 🟡 | **TomTom** | 🚦 Real traffic instead of an approximate simulation | [developer.tomtom.com](https://developer.tomtom.com) — free tier is plenty, completely worth it |
|
||||
|
||||

|
||||
|
||||
*What the TomTom key buys you: rush-hour density painted on the city — then dive from the jam straight into the camera watching it.*
|
||||
|
||||
### Cherry on top
|
||||
|
||||
| | Key | Why | Get it |
|
||||
|---|-----|-----|--------|
|
||||
| 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) |
|
||||
| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) |
|
||||
|
||||
All of them are worth getting. None of them are required to start.
|
||||
|
||||
`npm run doctor` reports Node/npm readiness, the primary provider routes, and
|
||||
where each configured provider was found without printing credential values.
|
||||
On macOS its Keychain-aware result previews `./scripts/dev-fresh.sh`; plain
|
||||
`npm run dev` reads only explicit environment and Vite dotenv values. The
|
||||
OpenSky summary reports only OAuth client-pair presence, not the resolved
|
||||
runtime mode or credential validity; Basic and credentials-file modes remain
|
||||
advanced `dev-fresh.sh` configuration.
|
||||
|
||||
**If you'd rather not use the panel** — headless boxes, coding agents, scripted setups:
|
||||
|
||||
```bash
|
||||
# Put keys in .env (see .env.example), or pass them as env vars:
|
||||
OPENAI_API_KEY="…" AISSTREAM_API_KEY="…" npm run dev -- --host localhost --port 4173
|
||||
|
||||
# On macOS, store any of them in the Keychain and dev-fresh.sh pulls them in:
|
||||
security add-generic-password -U -s "google-maps-api" -a "api-key" -w
|
||||
security add-generic-password -U -s "openai-api" -a "api-key" -w
|
||||
security add-generic-password -U -s "aisstream-api" -a "api-key" -w
|
||||
security add-generic-password -U -s "firms-map" -a "map-key" -w
|
||||
security add-generic-password -U -s "cesium-ion" -a "token" -w
|
||||
```
|
||||
|
||||
OpenSky can run fully anonymous (`OPENSKY_AUTH_MODE=anon`), or import OAuth credentials with `./scripts/opensky-import-client.sh /path/to/credentials.json`.
|
||||
|
||||
### 💸 What it actually costs
|
||||
|
||||
Honest numbers, roughly, as of mid-2026 — always check the provider pricing pages:
|
||||
|
||||
| | Cost reality |
|
||||
|---|---|
|
||||
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, bundled datasets. |
|
||||
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
|
||||
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for GEV place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
|
||||
| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
|
||||
|
||||
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
|
||||
3D Tiles sessions each month are currently free, and one root request supports
|
||||
roughly three hours of rendering. A solo user exploring sparingly can
|
||||
realistically stay inside the free usage cap. Billing must still be enabled, so
|
||||
restrict the key and set a quota or budget alert. Check Google's
|
||||
[current pricing](https://developers.google.com/maps/billing-and-pricing/pricing)
|
||||
before relying on these figures.
|
||||
|
||||
### 🧗 The floor is low on purpose
|
||||
|
||||
Everything above is the deliberately cheap baseline — enough to get a real taste of geospatial intelligence, GEOINT, and OSINT without ever talking to a sales team. You'll also notice the ceiling: terrestrial AIS goes quiet mid-ocean and satellite AIS costs real money; premium imagery, SAR, and the deeper commercial feeds live behind enterprise contracts. That's not a limit of the architecture — every layer here is a pattern you can point at your own data sources. This repo hands you the foundation; what you fuse into it is up to you.
|
||||
|
||||
### 🔒 Sharing an instance
|
||||
|
||||
By default nobody else can reach your server — it binds to localhost. To share on your LAN, opt in explicitly (`npm run dev -- --host 0.0.0.0 --port 4173`, or `HOST=0.0.0.0 ./scripts/dev-fresh.sh` on macOS/Linux) — but know that ⚠️ **a LAN-visible server brokers your configured API keys to anyone who can reach it.** Set the per-IP throttles (`GEV_RATELIMIT_OPENAI_PER_MIN`, `GEV_RATELIMIT_GOOGLE_PER_MIN` — see `.env.example`) and, before anything else, **set provider-side budget caps** (Google Cloud budgets, OpenAI usage limits): the throttles are app-level guards, not billing caps. Full threat model in [SECURITY.md](SECURITY.md).
|
||||
|
||||
Provider Settings switches itself off whenever the server is shared. The panel
|
||||
answers loopback requests only, and any sharing mode disables the surface
|
||||
outright rather than trusting the socket — tunnelled traffic reaches the server
|
||||
from loopback too, so socket identity can't carry that boundary. Nobody on your
|
||||
LAN gets a key-entry form.
|
||||
|
||||
Pinokio LAN and Cloudflare sharing are currently unavailable for this launcher.
|
||||
The supported Pinokio release can activate sharing again when the Open-action
|
||||
URL is registered, and writes a successful tunnel-login passcode into its own
|
||||
notification and terminal stream. Before preflight, the launcher rewrites both
|
||||
sharing modes to disabled values, clears the child passcode, and pins Pinokio's
|
||||
share trigger to a disabled sentinel. The app then starts loopback-only and
|
||||
registers the standard Open URL. Use a separate reviewed authentication proxy
|
||||
if remote access is required.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Responsible & Open
|
||||
|
||||
God's Eye View runs on **public data, clear sources, and local-first execution.** No secrets, no private datasets, no mystery scraping — anything involving a private key is brokered server-side. It has the visual grammar of a classified ops room, built entirely from open signals and inspectable code.
|
||||
|
||||
**The line.** This project models **events, assets, infrastructure, and systems** — aircraft, vessels, satellites, fires, cameras, cities. It does not build features for named-person search, face recognition, or tracking individuals, and pull requests that cross that line won't be merged. People are not a query type here.
|
||||
|
||||
**Come build it.** This is the canonical live 3D client from the project that kicked off the recent wave of spatial-intelligence tools — and it's a canvas: the layers here are the signals one person could find and fuse. Add a city pack, a data source, a style, a voice tool. It's the window through which you see the world; bring that window to others.
|
||||
|
||||
**Status:** An evolving open-source client for exploration and learning — a fast, hackable foundation, not a hardened production service. Released under the **[MIT License](LICENSE)**. Bundled and live datasets carry their own terms — see **[DATA_SOURCES.md](DATA_SOURCES.md)**. Security model: **[SECURITY.md](SECURITY.md)**. Want to contribute? **[CONTRIBUTING.md](CONTRIBUTING.md)**.
|
||||
|
||||
<sub>Media note: the capture GIFs on this page show Google Photorealistic 3D Tiles and live data layers, used promotionally with in-frame attribution; they aren't licensed for standalone reuse. See [media provenance and permissions](docs/media/README.md); full source terms in [DATA_SOURCES.md](DATA_SOURCES.md).</sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> God's Eye View is an exploratory visualization of public and third-party data.
|
||||
> Data may be delayed, incomplete, modeled, inferred, or wrong. Do not use it
|
||||
> for flight or maritime navigation, emergency response, medical or health
|
||||
> decisions, investment decisions, or other safety-critical or operational
|
||||
> purposes. Verify important information with authoritative sources.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 What's Next
|
||||
|
||||
First — thank you. To everyone who watched the God-view demos and went off to build their own, and to everyone who kept asking for the code: I'm grateful. And when I polled whether this should go open source, you weren't subtle about it:
|
||||
|
||||
<img src="docs/media/open-source-survey.png" alt="Community survey on open-sourcing God's Eye View" width="460">
|
||||
|
||||
So here it is. Step inside the spy-thriller cockpit — except the data is real — and let's turn this into our shared sandbox for making sense of the world, and have fun doing it. This repo is the baseline, it stays open, and the whole point is for you to break things and bolt on layers we haven't thought of yet.
|
||||
|
||||
One heads-up from the inside: build in this space for a week and you learn that **the present is the cheap part**. The moment you try to go back in time — tiling, serving, and scrubbing *what happened* and *what changed* at any real resolution — the data gets expensive and the compute gets brutal. For that, we're building something cool. More in the future — [halfpixel.ai](https://halfpixel.ai).
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
▶️ [Watch the God's Eye View series](https://youtube.com/playlist?list=PL6qSg2I-7_koPbDnSMo0QeeHX_RknA2uv&si=nBGYMoHWQw41v93Q) · 📬 [Map the World](https://maptheworld.ai/) — the newsletter behind the project
|
||||
|
||||
**🌐 God's Eye View. No place left behind.**
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
# Security
|
||||
|
||||
God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security issues **privately** — do not open a public issue for anything exploitable.
|
||||
|
||||
- Use GitHub's [private vulnerability reporting](https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new) (Security tab → "Report a vulnerability"), or
|
||||
- Reach the maintainer directly via the contact on the GitHub profile.
|
||||
|
||||
Include repro steps and impact. We'll acknowledge, investigate, and credit you (if you'd like) once a fix ships.
|
||||
|
||||
## How secrets are handled
|
||||
|
||||
The golden rule: **secret-bearing API keys stay on the server side.** The dev/preview server (Vite middleware in `vite.config.js`) brokers every request that needs a private credential, so the browser never receives one.
|
||||
|
||||
| Key | Where it lives | How the browser uses it |
|
||||
|-----|----------------|--------------------------|
|
||||
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
|
||||
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache |
|
||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` |
|
||||
|
||||
### Two deliberately client-side keys — restrict them
|
||||
|
||||
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
||||
|
||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
|
||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
||||
|
||||
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side.
|
||||
|
||||
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
|
||||
|
||||
The official Pinokio launcher stores optional values in its ignored local
|
||||
`pinokio/ENVIRONMENT` file and Vite explicitly denies that filename. Add,
|
||||
replace, or remove those values through the in-app **POWER UP → Provider
|
||||
Settings** panel; the server restricts the file before writing and restarts the
|
||||
local app after a save. Do not submit credentials through Pinokio 8.0.40's
|
||||
native Configure form: that release targets the wrong file for this nested
|
||||
launcher layout and logs the submitted values. The ignored file is local
|
||||
plaintext, not encrypted storage. The macOS Keychain remains the stronger local
|
||||
option when launching through `./scripts/dev-fresh.sh`.
|
||||
|
||||
## Server-side proxy hardening
|
||||
|
||||
The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **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.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
|
||||
|
||||
- **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).
|
||||
- **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 (opt-in):** `GEV_RATELIMIT_OPENAI_PER_MIN` and `GEV_RATELIMIT_GOOGLE_PER_MIN` cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). They are **per-IP, process-local, in-memory guards** — they reset on restart and are **not billing caps**.
|
||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||
logs a successful tunnel-login passcode in its own notification and terminal
|
||||
stream. Before preflight, the launcher rewrites its app-scoped sharing controls
|
||||
to disabled values, clears any Pinokio-global passcode from the child, and
|
||||
pins the platform share trigger to a disabled sentinel. A stale or requested
|
||||
sharing value is therefore discarded rather than honored, and GEV starts on
|
||||
loopback only. Use a separately reviewed authentication proxy for remote
|
||||
access and keep provider-side quotas as the spend backstop.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
- The Vite server is a **development/preview** server. If you expose it beyond localhost, put it behind your own auth/proxy and review the bindings (see the threat model above).
|
||||
- All data shown is from **public** sources. See [DATA_SOURCES.md](DATA_SOURCES.md). Respect each provider's terms and rate limits.
|
||||
- The voice agent receives feed-sourced text (place names, callsigns) as scene context. It is instructed to act only via a fixed set of app-control tools and not to execute arbitrary instructions found in data, but treat model output as untrusted and keep the tool surface limited.
|
||||
|
||||
## Responsible use
|
||||
|
||||
This is an interface for signals that are **already public**. Use it accordingly: respect privacy, follow data providers' terms, and don't represent public-data inference as authoritative intelligence.
|
||||
@@ -0,0 +1,220 @@
|
||||
# God's Eye View — Testing Guide (voice map-annotation whiteboard + live tracking)
|
||||
|
||||
> [!NOTE]
|
||||
> This is a **manual field-test scenario script** for the June-2026 whiteboard +
|
||||
> tracking work. The AUTOMATED gates live elsewhere: `npm test` (unit),
|
||||
> `npm run test:track` (tracking invariants), and the headless harnesses under
|
||||
> `scripts/qa-*.mjs` — together these are the full automated test surface.
|
||||
|
||||
This guide covers the work hardened over **4 adversarial-review batches** on
|
||||
`feat/annotate-hybrid`. Record a voice note + screenshots as you go; each scenario
|
||||
lists what **✅ pass** looks like and (where it applies) the **❌ old bug** it replaces.
|
||||
|
||||
## Focus/horizon moving evidence
|
||||
|
||||
With the Vite development server already running on port 4173, capture all four
|
||||
focus/recession scenarios with a deterministic virtual frame clock:
|
||||
|
||||
```sh
|
||||
node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
--screenshots-dir qa-shots/focus-evidence \
|
||||
--json qa-shots/focus-evidence/report.json
|
||||
```
|
||||
|
||||
For a quick operator loop, `--smoke` captures only S1 in six frames. For visual
|
||||
sign-off, add `--headful`; this removes the SwiftShader launch flags and uses
|
||||
the machine's real GPU. The harness brings its page to the foreground at the
|
||||
start of every scenario so a headful Chromium run keeps streaming tiles.
|
||||
|
||||
Google 3D remains the default basemap. If its tile stream is the bottleneck
|
||||
rather than the behavior under test, select an existing map stack explicitly:
|
||||
|
||||
```sh
|
||||
--basemap bing-aerial
|
||||
--basemap osm
|
||||
```
|
||||
|
||||
Accepted values are `photoreal`, `bing-aerial`, `bing-labels`, and `osm`; Bing
|
||||
stacks still require the app's usual Cesium ion token.
|
||||
|
||||
Tune both systems without editing source by adding, for example:
|
||||
|
||||
```sh
|
||||
--params '{"focus":{"dimFloor":0.35,"nearerBehavior":"partial"},"horizon":{"scaleFloor":0.5,"alphaFloor":0.4}}'
|
||||
```
|
||||
|
||||
The script never starts the server. During scenarios it pauses Cesium's default
|
||||
render loop, advances focus time explicitly, and renders each frame before the
|
||||
screenshot, so computed alpha/scale sequences repeat for identical parameters.
|
||||
Headless Chromium still forces SwiftShader, so its pixels are relative CI/A-B
|
||||
evidence only; headful real-GPU output is the sign-off surface. Screenshots use
|
||||
scenario/frame names and the JSON report records effective tuning plus
|
||||
per-contact alpha, scale, and screen data. Before each scenario's first capture,
|
||||
the harness gives the active Google photoreal tileset up to 45 seconds to reach
|
||||
Cesium's `tilesLoaded`/`allTilesLoaded` condition. Every frame records
|
||||
`tilesSettled: true|false` and whether that gate applied; a timeout is recorded
|
||||
as false, never promoted to a pass. Non-photoreal stacks record the gate as
|
||||
settled and not applicable because no Google 3D tileset is active.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do not use a screenshot for visual judgment unless its report frame records
|
||||
> `tilesSettled: true`.
|
||||
|
||||
## Setup
|
||||
|
||||
- **URL:** http://localhost:4173 — auto-flies to Austin on load. Give photoreal tiles ~10s.
|
||||
- **Voice (the real feature):** click **GEV MIC** (bottom of screen) → wait for **LISTENING** →
|
||||
just talk. It marks the map *as it talks*, without announcing that it's drawing. Click
|
||||
**STOP** when done. (Needs `OPENAI_API_KEY`; `dev-fresh.sh` injects it from Keychain.)
|
||||
- **Console (deterministic, no mic/live-data needed):** open DevTools (**Cmd-Opt-J**) and use:
|
||||
- `window.__gevAnnotations.tour()` — self-running narrated SF tour (camera + marks in sequence)
|
||||
- `window.__gevAnnotations.demo()` — lays the SF set down at once
|
||||
- `window.__gevAnnotations.clear()` — erase all marks
|
||||
- `window.__gevAnnotations.count()` — how many marks are live
|
||||
|
||||
---
|
||||
|
||||
## What changed (the list)
|
||||
|
||||
**Live aircraft tracking & camera (Batch D)** — civilian + military, mirrored:
|
||||
- **R14** warm-up: tracked plane glides immediately (no freeze-then-jump / backward snap),
|
||||
via a display-layer reconciliation that smooths any position discontinuity over ~0.9s.
|
||||
- **R15** framing: calibrated initial follow distance (altitude-scaled, 3–30 km).
|
||||
- **R16** trail: head stays glued to the moving icon per-frame; never trails ~1s behind or
|
||||
pokes out in front of the nose.
|
||||
- **R13** z-order: screen annotations fade where they'd cover the **tracked** plane + its label.
|
||||
|
||||
**Voice annotation — scoping & honesty (Batch B):**
|
||||
- **R1** resolver scope: a place name resolves to the *right* thing (building vs neighborhood
|
||||
vs natural feature vs city) instead of an oversized admin blob.
|
||||
- **R2** boundary stitching: complex outlines no longer close with bay-spanning chords.
|
||||
- **R6** route honesty: a routing outage draws a *labeled* straight "direct line", never a
|
||||
silently faked road.
|
||||
- **R12** partial honesty: if some targets fail, it says so — no masking partial as success.
|
||||
- **R10** cache: transient failures aren't negative-cached for the whole session.
|
||||
|
||||
**Annotation rendering & motion (Batch C):**
|
||||
- **R8** route flow: route dashes **animate** (flow along the path).
|
||||
- **R9 / R17** perf: idle annotations stop their animation loop; height cache is bounded.
|
||||
- **R11** no-freeze: large/complex boundaries simplify without locking the UI.
|
||||
|
||||
**Abuse guards (Batch A)** — mostly internal: input caps on `annotate_map`, clear/new-topic
|
||||
cancellation, hardened Overpass + route proxies. Not much to see by hand (see §4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Live aircraft tracking (Batch D) — the 3 issues you reported + z-order
|
||||
|
||||
> Needs a live plane. Track by **clicking a plane icon**; **Esc** (or click empty space) untracks.
|
||||
> If you see no planes, scroll out once — Texas airspace is busy.
|
||||
|
||||
**1a · Warm-up smoothness (your issue #1 → R14)**
|
||||
1. Click a moving plane. 📸 Snap the instant it locks, then watch ~2–3s.
|
||||
- ✅ Starts gliding smoothly *immediately*. ❌ *Was:* froze ~1s, then jumped forward.
|
||||
|
||||
**1b · Initial framing (your issue #2 → R15)**
|
||||
1. On the same lock-on, look at how the camera frames the plane. 📸 Snap the initial view.
|
||||
- ✅ Plane + label readable with context, no scrolling. ❌ *Was:* jammed in too tight.
|
||||
|
||||
**1c · Trail glued to the plane (your issue #3 → R16)**
|
||||
1. Keep tracking ~10s. 📸 Snap the cyan trail while it moves.
|
||||
- ✅ Trail tail stays attached to the plane; nothing pokes out *in front* of the nose; no ~1s lag.
|
||||
❌ *Was:* trail lagged a beat behind.
|
||||
|
||||
**1d · Military layer (same fixes, mirrored)**
|
||||
1. Enable the **Military** layer (left column). Track an **amber** plane. Repeat 1a–1c.
|
||||
- ✅ Identical smooth behavior.
|
||||
|
||||
**1e · Z-order: tracked plane stays on top (→ R13, trickiest to stage)**
|
||||
1. While tracking a plane over Austin, GEV MIC: *"annotate downtown Austin."*
|
||||
2. Orbit/zoom so the plane crosses the annotation's outline or label. 📸 Snap the overlap.
|
||||
- ✅ The annotation **dims** where it covers the plane/label — plane stays visible on top.
|
||||
❌ *Was:* the annotation drew over and hid the plane.
|
||||
|
||||
---
|
||||
|
||||
## 2. Voice annotation — scoping & honesty (Batch B)
|
||||
|
||||
> All from the Austin default view (no camera move needed). Click GEV MIC, then speak.
|
||||
|
||||
**2a · Scope: building vs region (→ R1)** — say each, watch what gets outlined:
|
||||
- *"annotate the Texas State Capitol"* → ✅ the **Capitol building**, not the whole state.
|
||||
- *"annotate Barton Creek"* → ✅ the **creek / greenbelt**, not Barton Creek *Mall* or the city.
|
||||
- *"annotate downtown Austin"* → ✅ the **downtown district**, a sensible neighborhood-size area.
|
||||
- *"annotate the University of Texas at Austin"* → ✅ the **campus**, not the city.
|
||||
- 📸 Snap each outline. ❌ *Was:* incidental names ballooned into giant county/state blobs.
|
||||
|
||||
**2b · Classic SF set (optional)** — first say *"take me to San Francisco,"* then:
|
||||
- *"annotate the Mission District"* → neighborhood. *"...the Presidio"* → the full former-base
|
||||
outline (not a single point, not a bay-spanning blob → R2). *"...Chinatown"* → the district.
|
||||
|
||||
**2c · Route honesty (→ R6)**
|
||||
1. *"draw a route from Austin-Bergstrom airport to the Texas Capitol."*
|
||||
- ✅ A real **road** route with a flowing dashed line + a labeled callout.
|
||||
- If a route ever comes back as a **straight** line, it should be **labeled** as a direct/approx
|
||||
line (not pretending to be a road). ❌ *Was:* silent fake straight-line "routes."
|
||||
|
||||
**2d · Partial-failure honesty (→ R12)**
|
||||
1. *"annotate the Texas Capitol and the Flibbergibbet Building."*
|
||||
- ✅ It annotates the Capitol **and tells you** it couldn't find the other one.
|
||||
❌ *Was:* reported success and silently dropped the missing one.
|
||||
|
||||
**2e · Clear / new-topic race (→ R4)**
|
||||
1. *"annotate downtown Austin"* and, right as it starts, *"actually, clear everything."*
|
||||
- ✅ Marks clear and **stay** cleared (the in-flight one doesn't pop back a second later).
|
||||
- Sanity in console: `window.__gevAnnotations.count()` → `0` after a clear.
|
||||
|
||||
---
|
||||
|
||||
## 3. Annotation rendering & motion (Batch C) — deterministic, no mic
|
||||
|
||||
**3a · Full experience (smoke test)**
|
||||
1. Console: `window.__gevAnnotations.tour()`. 📸 A couple of frames as it runs.
|
||||
- ✅ Camera flies to SF; Palace highlight → arrow → Presidio **draped** outline → ILM pin →
|
||||
Crissy Field **route** appear in sequence; marks stay glued as the camera moves.
|
||||
|
||||
**3b · Route dashes animate (→ R8)**
|
||||
1. During/after `tour()` (or `demo()`), watch the **route** line (Crissy Field shoreline).
|
||||
- ✅ The dashes **flow** along the path (animated), not static. ❌ *Was:* dashes never moved.
|
||||
|
||||
**3c · Draping & persistence**
|
||||
1. After `demo()`, orbit the camera around the Presidio outline.
|
||||
- ✅ The footprint **drapes onto** the 3D ground/buildings and conforms; callouts/rings/arrows
|
||||
are the hand-drawn SVG style and stay anchored to their real spot.
|
||||
|
||||
**3d · Large boundary, no freeze (→ R11)**
|
||||
1. Console:
|
||||
`window.__gevAnnotations.annotate([{type:'area',target:'Travis County, Texas',label:'Travis County',color:'green',footprint:true}],{flyTo:true,persist:true})`
|
||||
- ✅ The complex county boundary simplifies and draws **without** freezing/janking the UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. Robustness / guards (Batch A) — mostly internal
|
||||
|
||||
These are server-side / defensive and don't have a clean visual tell. Light checks only:
|
||||
- Rapid-fire several annotate commands, then a clear — UI should stay responsive and end clean
|
||||
(`count()` → `0`).
|
||||
- Nothing here should change normal behavior; flag it only if something feels *broken* (stuck
|
||||
spinner, marks that won't clear, errors in the console).
|
||||
|
||||
---
|
||||
|
||||
## 5. General feel — open feedback
|
||||
|
||||
While recording, call out anything in these areas — this is the feedback I most want:
|
||||
- **Tracking feel:** does follow motion feel smooth/natural, or floaty/laggy/overshooting?
|
||||
- **Framing:** is the initial tracked view a good "hero" shot, or too close/far?
|
||||
- **Annotations:** do outlines land on the *right* thing? Are labels readable / well-placed /
|
||||
not overlapping? Does the hand-drawn style read well over photoreal tiles?
|
||||
- **Voice:** does it mark things *as it talks* (not after), and confirm only what actually
|
||||
happened? Any command it misunderstood?
|
||||
- **Anything that looks wrong, janky, or surprising** — screenshot it; that's the gold.
|
||||
|
||||
## If something looks off
|
||||
|
||||
- **Grey globe / slow tiles:** wait a few seconds after a camera flight; photoreal streams in.
|
||||
- **A voice mark didn't land:** the place may not geocode — try a more specific name
|
||||
(e.g. "Palace of Fine Arts, San Francisco").
|
||||
- **No planes:** OpenSky data may be momentarily sparse; scroll out or wait a poll cycle.
|
||||
- **No GEV MIC button / voice errors:** `OPENAI_API_KEY` didn't load — use the console API for
|
||||
the annotation tests and skip the voice-only ones (§2).
|
||||
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 176 KiB |
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,59 @@
|
||||
[
|
||||
{
|
||||
"id": "tokyo-shinjuku-east-1",
|
||||
"name": "Shinjuku Crossing East Cam",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"provider": "Pilot Feed Pack",
|
||||
"sourceKind": "pilot",
|
||||
"feedType": "mp4",
|
||||
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
||||
"lat": 35.689614,
|
||||
"lon": 139.700523,
|
||||
"headingDeg": 242,
|
||||
"pitchDeg": -19,
|
||||
"fovDeg": 66,
|
||||
"rangeM": 560,
|
||||
"mountHeightM": 29,
|
||||
"groundElevationM": 40,
|
||||
"license": "Demo sample stream for projection pipeline testing"
|
||||
},
|
||||
{
|
||||
"id": "tokyo-shinjuku-west-2",
|
||||
"name": "Shinjuku Station West Cam",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"provider": "Pilot Feed Pack",
|
||||
"sourceKind": "pilot",
|
||||
"feedType": "mp4",
|
||||
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
||||
"lat": 35.690976,
|
||||
"lon": 139.699237,
|
||||
"headingDeg": 128,
|
||||
"pitchDeg": -17,
|
||||
"fovDeg": 72,
|
||||
"rangeM": 600,
|
||||
"mountHeightM": 32,
|
||||
"groundElevationM": 40,
|
||||
"license": "Demo sample stream for projection pipeline testing"
|
||||
},
|
||||
{
|
||||
"id": "tokyo-shibuya-scramble",
|
||||
"name": "Shibuya Scramble North Cam",
|
||||
"city": "Tokyo",
|
||||
"cityId": "tokyo",
|
||||
"provider": "Pilot Feed Pack",
|
||||
"sourceKind": "pilot",
|
||||
"feedType": "mp4",
|
||||
"url": "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
||||
"lat": 35.659695,
|
||||
"lon": 139.700539,
|
||||
"headingDeg": 26,
|
||||
"pitchDeg": -20,
|
||||
"fovDeg": 74,
|
||||
"rangeM": 610,
|
||||
"mountHeightM": 30,
|
||||
"groundElevationM": 35,
|
||||
"license": "Demo sample stream for projection pipeline testing"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
# KNOWN ISSUES
|
||||
|
||||
Updated: July 8, 2026
|
||||
|
||||
This file tracks active runtime issues only.
|
||||
|
||||
For the roadmap and open backlog, see the repository issue tracker.
|
||||
|
||||
---
|
||||
|
||||
## Open
|
||||
|
||||
### Street traffic can be slow/uneven when panning across dense city blocks
|
||||
Status: Open (partially mitigated)
|
||||
|
||||
Context:
|
||||
- Current traffic loader fetches one clamped viewport tile at a time (major pass, then full pass).
|
||||
- In dense cores, some visible roads can appear late after city jumps or fast pans.
|
||||
- Zooming into adjacent streets does not always immediately trigger higher-detail coverage for all visible roads.
|
||||
|
||||
Current mitigation in runtime:
|
||||
- Fair per-road dot budget allocation (reduces hard starvation under global `MAX_DOTS` cap).
|
||||
- Center-shift threshold (reduces stale overlap lock while panning).
|
||||
|
||||
Next iteration candidates:
|
||||
- Prioritize currently visible road segments inside the active viewport before off-center segments.
|
||||
- Add neighbor prefetch ring for nearby tiles after jump-to-city actions.
|
||||
- Add adaptive dot cap by frame time (coverage first, density second).
|
||||
- Promote sync chip from loading indicator to true multi-phase progress.
|
||||
|
||||
---
|
||||
|
||||
### CCTV panel can appear "missing" after layout refactors
|
||||
Status: Open (workaround available)
|
||||
|
||||
Context:
|
||||
- Panel positions are persisted in local storage and can restore off-screen after UI changes.
|
||||
|
||||
Workaround:
|
||||
- In browser console:
|
||||
- `localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel');`
|
||||
- `localStorage.removeItem('godsEyeView.v6.panelCollapsed.cctv-panel');`
|
||||
- `location.reload();`
|
||||
|
||||
Related keys (current versions):
|
||||
- Panel positions: `godsEyeView.v7.panelPos.<panel-id>` (re-versioned 2026-06-10)
|
||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>`
|
||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||
|
||||
---
|
||||
|
||||
### Height-datum residuals (branch `feat/height-datum`, pending merge)
|
||||
Status: Open (owner-accepted 2026-07-08, documented)
|
||||
|
||||
- **Cold-start floor latency:** at a freshly-visited airport, grounded/low aircraft
|
||||
float low for ~1–2 poll cycles (30–60 s) and rise as terrain floors resolve;
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Closed / Intentional (for clarity)
|
||||
|
||||
### Proxy SSRF and error-surface hardening gaps
|
||||
Status: Closed as fixed on `main`
|
||||
|
||||
Context:
|
||||
- Proxy middleware previously allowed broader error/internal surface area and looser upstream handling.
|
||||
- Current `main` includes hardened proxy behavior in `vite.config.js`:
|
||||
- CCTV upstream URL no longer accepted from client query params.
|
||||
- Error payloads are sanitized.
|
||||
- OpenSky cache stores successful responses only.
|
||||
- OpenSky token refresh is coalesced.
|
||||
- GBFS/CCTV memory growth is bounded.
|
||||
|
||||
Validation target:
|
||||
- `vite.config.js`
|
||||
|
||||
---
|
||||
|
||||
### NVG vignette edge color bleed
|
||||
Status: Closed as fixed in current shader composite
|
||||
|
||||
Context:
|
||||
- Earlier builds leaked original scene colors near the NVG tube edge.
|
||||
- Current composite now masks NVG output with tube falloff before final blend, removing the color edge bleed.
|
||||
|
||||
Validation target:
|
||||
- `src/styles/surveillance.js`
|
||||
|
||||
---
|
||||
|
||||
### Wildfires layer unavailable / static bundled snapshot
|
||||
Status: Closed — live FIRMS integration shipped (2026-07-16)
|
||||
|
||||
Context:
|
||||
- Wildfires (NASA FIRMS) were removed from runtime in v0.5.3, returned June 2026 as a
|
||||
bundled-snapshot layer (`local-firms`, 2026-05-25 data, ~58 MB in-repo), and were
|
||||
converted to **live NASA FIRMS data** on 2026-07-16: the `/api/firms` proxy merges
|
||||
three VIIRS NRT sources (trailing 24 h, 30 min cache, serve-stale-on-failure) and the
|
||||
bundled snapshot was deleted. Requires a free server-side `FIRMS_MAP_KEY`; without it
|
||||
the layer shows a KEY REQUIRED state.
|
||||
- Weather radar is still held out of OSS v1 after QA found the previous overlay did not provide reliable visible value.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Performance baseline
|
||||
|
||||
This page records one hardware-rendered Apple M5 comparison captured on 22
|
||||
August 2026 in Chrome 150 at 1440 x 900. It is not a minimum hardware
|
||||
specification and should not be used to predict performance on untested systems.
|
||||
The original capture artifacts are not included here, so this page records
|
||||
results rather than defining a runnable benchmark.
|
||||
|
||||
## Test context
|
||||
|
||||
The baseline was captured on 22 August 2026 with these conditions:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Renderer | Apple M5 Metal through the hardware ANGLE path |
|
||||
| Browser | Chrome 150 in a fresh isolated profile |
|
||||
| Viewport | 1440 x 900 at device pixel ratio 1 |
|
||||
| Focus | Page foregrounded for controlled scenes |
|
||||
| Scene sample | 5 seconds of scripted motion, then 5 seconds at rest |
|
||||
| Startup | Browser cache disabled; three samples |
|
||||
|
||||
The capture covered three startup samples, 16 cold layer scenarios with 14
|
||||
measurements, 23 controlled option and stress scenes, and five
|
||||
hardware-rendered overlay scenes.
|
||||
|
||||
## Startup
|
||||
|
||||
| Sample | App ready | Initial settle | Load event | Motion / rest | Used JS heap |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 784.980 ms | 2,035.082 ms | 439.5 ms | 60 / 60 FPS | 102.9 MiB |
|
||||
| 2 | 604.849 ms | 1,855.836 ms | 442.4 ms | 60 / 60 FPS | 111.6 MiB |
|
||||
| 3 | 558.527 ms | 1,809.592 ms | 438.8 ms | 60 / 60 FPS | 105.1 MiB |
|
||||
| Median | 604.849 ms | 1,855.836 ms | 439.5 ms | 60 / 60 FPS | 105.1 MiB |
|
||||
|
||||
The initial-settle measurement is the more useful launch reference because it
|
||||
includes the first visual and data settling window. All three samples reached
|
||||
the display ceiling during both motion and rest.
|
||||
|
||||
## Cold layer activation
|
||||
|
||||
Cold activation was measured separately from warm option switching. Live object
|
||||
counts are included so that future runs can compare source populations before
|
||||
attributing a difference to the client.
|
||||
|
||||
| Layer | Activation | Source count | Motion / rest | Used JS heap |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| CCTV city | 19,608.240 ms | 48 | 60 / 60 FPS | 192.7 MiB |
|
||||
| Space Missions (report label: Rocket missions) | 3,581.066 ms | 26 | 60 / 60 FPS | 131.3 MiB |
|
||||
| Radio | 3,458.709 ms | 750 | 60 / 60 FPS | 124.8 MiB |
|
||||
| Bikeshare | 2,069.498 ms | 633 | 60 / 60 FPS | 157.4 MiB |
|
||||
| Datacenters | 817.693 ms | 4,362 | 59.6 / 60 FPS | 328.2 MiB |
|
||||
| Flights | 667.671 ms | 247 | 60 / 60 FPS | 118.4 MiB |
|
||||
| Submarine cables | 614.727 ms | 2,629 | 60 / 60 FPS | 412.0 MiB |
|
||||
| Military Flights | 557.113 ms | 68 | 60 / 60 FPS | 118.4 MiB |
|
||||
|
||||
CCTV had the largest cold activation cost in this capture. Submarine cables
|
||||
used the most heap, followed by datacenters. Completed single-layer samples
|
||||
generally reached 60 FPS, so activation time and heap separate these cases more
|
||||
clearly than steady-state frame rate.
|
||||
|
||||
## Aircraft, detection, and Cockpit
|
||||
|
||||
| Scene | Motion / rest |
|
||||
| --- | ---: |
|
||||
| Idle globe | 60 / 60 FPS |
|
||||
| Flights, 2D | 60 / 60 FPS |
|
||||
| Flights, 3D proximity | 60 / 60 FPS |
|
||||
| Flights, all 3D models | 60 / 60 FPS |
|
||||
| Military Flights, all 3D models | 60 / 60 FPS |
|
||||
| Detection at 25% | 39.3 / 41.1 FPS |
|
||||
| Detection at 50% | 37.4 / 39.8 FPS |
|
||||
| Detection at 100% | 34.4 / 35.5 FPS |
|
||||
| Cockpit | 49.6 / 49.2 FPS |
|
||||
|
||||
The clean detection scenes processed 8,169 to 8,170 observations. Selected
|
||||
labels rose from 14 at 25% density to 28 at 50% and 56 at 100%. The aircraft
|
||||
rows came from an earlier loaded, foreground-controlled pass because the clean
|
||||
rerun received no live aircraft rows.
|
||||
|
||||
## Visual styles and combined stress
|
||||
|
||||
| Scene | Motion / rest |
|
||||
| --- | ---: |
|
||||
| Normal | 60 / 60 FPS |
|
||||
| CRT (report label: Retro) | 60 / 60 FPS |
|
||||
| NVG (report label: Surveillance) | 60 / 60 FPS |
|
||||
| FLIR (report label: Thermal) | 49 / 60 FPS |
|
||||
| Anime | 60 / 59.8 FPS |
|
||||
| Noir | 47 / 56.6 FPS |
|
||||
| Snow | 42.3 / 45.8 FPS |
|
||||
| Combined static | 57.6 / 60 FPS |
|
||||
| Combined operational | 39.9 / 43.1 FPS |
|
||||
|
||||
The combined static scene rendered 11,575 objects, used 872.2 MiB of JavaScript
|
||||
heap, and issued 48,665 text draws during motion and 54,106 at rest. The combined
|
||||
operational sample contained 3,909 observations and two selected labels, but its
|
||||
live aircraft and traffic rows were empty, so it remains a limited stress case.
|
||||
|
||||
Snow, Noir, dense detection, and text-heavy combined layers are the clearest
|
||||
controlled comparison points for later optimization work.
|
||||
|
||||
## Keyed live sources
|
||||
|
||||
NASA FIRMS, AISStream, and TomTom were captured in a separate hardware-rendered
|
||||
pass. The page was visible but was not the focused window, so these frame rates
|
||||
must not be compared directly with the foreground-controlled scenes above.
|
||||
|
||||
| Source | Point-in-time population | Activation or coverage | Motion / rest |
|
||||
| --- | ---: | --- | ---: |
|
||||
| NASA FIRMS | 100,430 detections in 3,557 cells | 30.0 s activation | 32.1 / 55.2 FPS |
|
||||
| AISStream | 12,000 vessels | 6.4 s activation | 22.1 / 29.8 FPS |
|
||||
| TomTom Traffic | 4,222 road dots | 70% coverage, 2 decoded tiles | 45.0 / 51.7 FPS |
|
||||
|
||||
These populations change continuously. A future comparison must record the
|
||||
live counts again and match the focus conditions.
|
||||
|
||||
## Controls for a future capture
|
||||
|
||||
Use the same controls before attributing a difference to the application:
|
||||
|
||||
1. Record the exact GPU renderer and reject software-rendered or unavailable GPU
|
||||
strings.
|
||||
2. Use a 1440 x 900 viewport at device pixel ratio 1 and keep the page focused.
|
||||
3. Measure cache-disabled startup separately from cold layer activation and warm
|
||||
option switching.
|
||||
4. Repeat startup three times and compare medians.
|
||||
5. Sample each option for 5 seconds in scripted motion and 5 seconds at rest.
|
||||
6. Record live object counts before attributing a difference to the client.
|
||||
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
||||
rendering cost.
|
||||
|
||||
## What is not established yet
|
||||
|
||||
- This report does not establish Windows performance.
|
||||
- The report does not record machine memory capacity, so it cannot support a
|
||||
minimum-memory recommendation.
|
||||
- The report does not cover other GPU renderers or viewport configurations.
|
||||
- Military Installations is outside this comparison because it requires close
|
||||
camera context.
|
||||
- The keyed pass has no controlled rerun suitable for comparison with the
|
||||
option scenes.
|
||||
|
||||
Use this page as a regression baseline for one known hardware and browser
|
||||
configuration, not as a compatibility guarantee.
|
||||
|
After Width: | Height: | Size: 981 KiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,40 @@
|
||||
# README media provenance
|
||||
|
||||
## Capture GIFs
|
||||
|
||||
Bilawal Sidhu created and owns the following 17 GIFs and has authorized their inclusion and redistribution as part of this public repository and its project documentation. Any appearance by Bilawal Sidhu in these captures is included with his permission.
|
||||
|
||||
- `hero-open-source-reveal.gif`
|
||||
- `06-cockpit-ar.gif`
|
||||
- `start-here/airport-ground-traffic-google-3d.gif`
|
||||
- `03-austin-cctv.gif`
|
||||
- `14-iss-over-ukraine.gif`
|
||||
- `01-style-sweep.gif`
|
||||
- `12-switch-aircraft-cockpit.gif`
|
||||
- `start-here/military-cockpit-dense-google-3d.gif`
|
||||
- `01-voice-annotate-zilker.gif`
|
||||
- `04-airport-distance.gif`
|
||||
- `15-global-radio-layer.gif`
|
||||
- `08-falcon9-replay.gif`
|
||||
- `09-undersea-cables.gif`
|
||||
- `07-helicopter-loops.gif`
|
||||
- `10-walking-route-flythrough.gif`
|
||||
- `08-boneyard.gif`
|
||||
- `05-traffic-to-cctv.gif`
|
||||
|
||||
Copyright © Bilawal Sidhu. These files are not covered by the project's MIT License. Permission is limited to their inclusion and redistribution with this repository and its project documentation. No permission is granted for standalone reuse or modification. Commercial reuse outside this repository requires separate permission. No share-alike license applies.
|
||||
|
||||
The GIFs are promotional captures of God's Eye View. They include Google Photorealistic 3D Tiles and public or third-party data layers with in-frame attribution. Bilawal Sidhu's ownership and permission cover the captures and his likeness; they do not replace the terms of Google Maps Platform or any displayed data provider. Keep the visible attribution intact.
|
||||
|
||||
## Public README PNGs
|
||||
|
||||
Bilawal Sidhu published the following PNGs in the existing public `bilawalsidhu/gods-eye-view` repository and authorized their continued inclusion and redistribution with this repository and its project documentation:
|
||||
|
||||
- `youtube-popular-videos.png`
|
||||
- `open-source-survey.png`
|
||||
|
||||
Source: [`assets/youtube-popular-videos.png`](https://github.com/bilawalsidhu/gods-eye-view/blob/7afb3beefb2dd39110ed117c8f06825e9ca2ada4/assets/youtube-popular-videos.png) and [`assets/open-source-survey.png`](https://github.com/bilawalsidhu/gods-eye-view/blob/7afb3beefb2dd39110ed117c8f06825e9ca2ada4/assets/open-source-survey.png) in public repository commit [`7afb3beefb2dd39110ed117c8f06825e9ca2ada4`](https://github.com/bilawalsidhu/gods-eye-view/commit/7afb3beefb2dd39110ed117c8f06825e9ca2ada4). The localized files preserve the source bytes unchanged.
|
||||
|
||||
These files are not covered by the project's MIT License. No permission is granted for standalone reuse or modification. Commercial reuse outside this repository requires separate permission. No share-alike license applies.
|
||||
|
||||
The authorization covers the PNGs as project promotional material. It does not claim ownership of platform interfaces, trademarks, avatars, data, or third-party imagery visible within the screenshots. Those elements remain subject to their respective owners' terms.
|
||||
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 8.2 MiB |
|
After Width: | Height: | Size: 958 KiB |
@@ -0,0 +1,66 @@
|
||||
# OpenSky Auth Setup
|
||||
|
||||
God's Eye View uses explicit auth modes for `/api/opensky`:
|
||||
|
||||
- `OPENSKY_AUTH_MODE=oauth` (default, recommended)
|
||||
- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback)
|
||||
- `OPENSKY_AUTH_MODE=basic`
|
||||
- `OPENSKY_AUTH_MODE=anon`
|
||||
|
||||
Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow.
|
||||
|
||||
## Quick Start (OAuth Client JSON)
|
||||
|
||||
Import credentials from JSON (`clientId`/`clientSecret` or `client_id`/`client_secret`) into Keychain:
|
||||
|
||||
```bash
|
||||
./scripts/opensky-import-client.sh ~/Downloads/credentials.json
|
||||
# or: npm run opensky:import -- ~/Downloads/credentials.json
|
||||
```
|
||||
|
||||
Then launch:
|
||||
|
||||
```bash
|
||||
./scripts/dev-fresh.sh
|
||||
```
|
||||
|
||||
Expected startup lines:
|
||||
|
||||
- `OpenSky auth mode: oauth`
|
||||
- `OpenSky OAuth: configured`
|
||||
|
||||
## Optional: Launch With File (No Keychain Import)
|
||||
|
||||
```bash
|
||||
OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json ./scripts/dev-fresh.sh
|
||||
```
|
||||
|
||||
Launchers resolve OAuth creds in this order:
|
||||
|
||||
1. `OPENSKY_CLIENT_ID` + `OPENSKY_CLIENT_SECRET`
|
||||
2. `OPENSKY_CREDENTIALS_FILE`
|
||||
3. Keychain (`opensky-network` / `client_id`, `client_secret`)
|
||||
|
||||
## Basic Auth (Legacy)
|
||||
|
||||
Basic auth is still supported for now, but OpenSky is moving away from it.
|
||||
|
||||
```bash
|
||||
OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Inspect proxy headers:
|
||||
|
||||
```bash
|
||||
curl -si http://localhost:4173/api/opensky | grep -iE 'HTTP/|X-OpenSky-Auth'
|
||||
```
|
||||
|
||||
Useful reasons:
|
||||
|
||||
- `oauth_invalid_or_missing`: OAuth client not found/usable
|
||||
- `oauth_invalid_credentials`: OAuth client rejected by OpenSky
|
||||
- `basic_invalid_credentials`: username/password rejected
|
||||
- `missing_basic_creds`: basic mode missing user/pass
|
||||
- `rate_limited`: OpenSky throttling
|
||||
@@ -0,0 +1,925 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>God's Eye View</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<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=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="cesiumContainer"></div>
|
||||
<div id="world-overlay-root" aria-hidden="true">
|
||||
<canvas id="world-overlay-canvas"></canvas>
|
||||
</div>
|
||||
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets">
|
||||
<div id="world-overlay-action-list"></div>
|
||||
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle">NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label">ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name">NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
<nav id="top-center-actions" aria-label="Globe actions">
|
||||
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
|
||||
</button>
|
||||
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link">
|
||||
<span class="share-icon" aria-hidden="true">🔗</span>
|
||||
</button>
|
||||
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
|
||||
<span id="global-loading-label">LOADING LIVE DATA</span>
|
||||
<span id="global-loading-detail"></span>
|
||||
</div>
|
||||
<div id="traffic-sync-chip" aria-live="polite">
|
||||
<span id="traffic-sync-label">syncing road network</span>
|
||||
<span id="traffic-sync-progress">0%</span>
|
||||
</div>
|
||||
<div id="cctv-sync-chip" aria-live="polite">
|
||||
<span id="cctv-sync-label">loading frames</span>
|
||||
<span id="cctv-sync-progress">0/0</span>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<!-- Safe frame guides for recording composition -->
|
||||
<div id="safe-frame-overlay">
|
||||
<div id="safe-frame-box"></div>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
|
||||
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
|
||||
<div class="cockpit-visor-shell" aria-hidden="true">
|
||||
<div class="cockpit-roll-arc">
|
||||
<span style="--slot:-3;--depth:3">−30</span>
|
||||
<span style="--slot:-2;--depth:2">−20</span>
|
||||
<span style="--slot:-1;--depth:1">−10</span>
|
||||
<span class="active" style="--slot:0;--depth:0">0</span>
|
||||
<span style="--slot:1;--depth:1">+10</span>
|
||||
<span style="--slot:2;--depth:2">+20</span>
|
||||
<span style="--slot:3;--depth:3">+30</span>
|
||||
</div>
|
||||
<div class="cockpit-pitch-rail cockpit-pitch-rail-left">
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-pitch-rail cockpit-pitch-rail-right">
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-horizon-guide">
|
||||
<span></span><small>LEVEL</small><span></span>
|
||||
</div>
|
||||
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction">
|
||||
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
|
||||
</svg>
|
||||
<small id="cockpit-route-direction-label">DEST ---°</small>
|
||||
</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left">OPTICAL PLANE · 01</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right">VISOR LOCK · ACTIVE</div>
|
||||
</div>
|
||||
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
|
||||
<span class="cockpit-altitude-rim-label">GROUND SPEED · KTS</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
<span data-speed-rim-tick><b></b><i></i></span>
|
||||
</div>
|
||||
<div class="cockpit-altitude-rim-pointer">
|
||||
<span class="material-symbols-outlined">arrow_right</span>
|
||||
<strong id="cockpit-speed-rim-value">---</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
|
||||
<span class="cockpit-altitude-rim-label">ALTITUDE · FT</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
<span data-altitude-rim-tick><b></b><i></i></span>
|
||||
</div>
|
||||
<div class="cockpit-altitude-rim-pointer">
|
||||
<strong id="cockpit-altitude-rim-value">-----</strong>
|
||||
<span class="material-symbols-outlined">arrow_left</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-topline">
|
||||
<div>
|
||||
<span class="cockpit-kicker">FIRST PERSON</span>
|
||||
<strong id="cockpit-callsign">AIRCRAFT</strong>
|
||||
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
|
||||
</div>
|
||||
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
|
||||
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
<small>PREV</small>
|
||||
</button>
|
||||
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next">
|
||||
<small>CURRENT</small>
|
||||
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
|
||||
</button>
|
||||
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
|
||||
<small>NEXT</small>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
|
||||
<div class="cockpit-compass" aria-label="Current aircraft heading">
|
||||
<div class="cockpit-instrument-row">
|
||||
<div class="cockpit-readout cockpit-speed">
|
||||
<span class="cockpit-readout-label">GROUND SPEED</span>
|
||||
<strong id="cockpit-speed-value">---</strong>
|
||||
<span class="cockpit-readout-unit">KTS</span>
|
||||
</div>
|
||||
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
|
||||
<div class="cockpit-readout cockpit-altitude">
|
||||
<span class="cockpit-readout-label">ALTITUDE</span>
|
||||
<strong id="cockpit-altitude-value">-----</strong>
|
||||
<span class="cockpit-readout-unit">FT</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
|
||||
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="cockpit-position-readout">
|
||||
<span id="cockpit-clock">--:--:--Z</span>
|
||||
<span id="cockpit-position">--° · --°</span>
|
||||
</div>
|
||||
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden>
|
||||
<header class="cockpit-context-header">
|
||||
<div>
|
||||
<span class="cockpit-context-kicker">CONTACT</span>
|
||||
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
|
||||
</div>
|
||||
<nav class="cockpit-context-actions" aria-label="Contact navigation">
|
||||
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
|
||||
</button>
|
||||
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
|
||||
</button>
|
||||
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
</nav>
|
||||
<span class="cockpit-context-qualifier">CONTEXT ONLY</span>
|
||||
</header>
|
||||
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
|
||||
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
|
||||
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
|
||||
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
|
||||
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
|
||||
</div>
|
||||
<div class="cockpit-context-nearest">
|
||||
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
|
||||
<div>
|
||||
<small>NEAREST OBSERVED / MAPPED</small>
|
||||
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
|
||||
</div>
|
||||
<span id="cockpit-context-bearing">—</span>
|
||||
<span id="cockpit-context-distance">—</span>
|
||||
</div>
|
||||
<footer class="cockpit-context-footer">
|
||||
<span id="cockpit-context-uncertainty">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
|
||||
<button
|
||||
id="cockpit-weather-toggle"
|
||||
class="cockpit-context-weather"
|
||||
type="button"
|
||||
aria-label="Enable cockpit weather effects"
|
||||
aria-pressed="false"
|
||||
title="Enable cockpit weather effects"
|
||||
>
|
||||
<span>WX</span>
|
||||
<strong id="cockpit-weather-state">OFF</strong>
|
||||
</button>
|
||||
<time id="cockpit-context-updated">--:--:--Z</time>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden>
|
||||
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden>
|
||||
<div class="cockpit-route-heading">
|
||||
<span class="cockpit-route-kicker">ESTIMATED FLIGHT PLAN</span>
|
||||
<small id="cockpit-route-status">ROUTE DATA UNAVAILABLE</small>
|
||||
</div>
|
||||
<div class="cockpit-route-endpoints">
|
||||
<div class="cockpit-route-airport">
|
||||
<span>FROM</span>
|
||||
<strong id="cockpit-route-from">UNKNOWN</strong>
|
||||
</div>
|
||||
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
|
||||
<div class="cockpit-route-airport">
|
||||
<span>TO</span>
|
||||
<strong id="cockpit-route-to">UNKNOWN</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<header class="cockpit-signal-header">
|
||||
<div class="cockpit-brief-heading">
|
||||
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
|
||||
<strong id="cockpit-brief-subtitle">OBSERVED / MAPPED PINGS</strong>
|
||||
</div>
|
||||
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
|
||||
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
|
||||
</button>
|
||||
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
|
||||
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
|
||||
</button>
|
||||
<button
|
||||
id="cockpit-brief-auto"
|
||||
type="button"
|
||||
aria-label="CYCLE OFF"
|
||||
aria-pressed="false"
|
||||
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
|
||||
>CYCLE OFF</button>
|
||||
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="cockpit-brief-stage">
|
||||
<section data-cockpit-brief-page="signals" aria-label="Live signals">
|
||||
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden>
|
||||
<div id="cockpit-news-status" class="cockpit-brief-status">ACQUIRING REGIONAL NEWS</div>
|
||||
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
|
||||
</section>
|
||||
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden>
|
||||
<div class="cockpit-local-place">
|
||||
<strong id="cockpit-local-place">RESOLVING REGION</strong>
|
||||
<small id="cockpit-local-coordinates">--</small>
|
||||
</div>
|
||||
<div class="cockpit-local-grid">
|
||||
<div><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
|
||||
<div><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
|
||||
<div><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
|
||||
<div><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
|
||||
</div>
|
||||
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="cockpit-signal-footer">
|
||||
<span id="cockpit-brief-source">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
|
||||
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
|
||||
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true">SIG</button>
|
||||
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News">NEWS</button>
|
||||
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info">LOCAL</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</aside>
|
||||
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls">
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
|
||||
<span>DISPLAY</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options">◀</button>
|
||||
</div>
|
||||
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
|
||||
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-utility-control">
|
||||
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
|
||||
<span>RADIO</span>
|
||||
<span class="cockpit-utility-divider" aria-hidden="true"></span>
|
||||
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls">◀</button>
|
||||
</div>
|
||||
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden>
|
||||
<strong id="cockpit-radio-station" class="cockpit-radio-station">READY</strong>
|
||||
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<div class="cockpit-radio-transport">
|
||||
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled>◀◀</button>
|
||||
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled>▶</button>
|
||||
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled>▶▶</button>
|
||||
</div>
|
||||
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled />
|
||||
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
|
||||
</section>
|
||||
|
||||
<nav id="view-switcher" aria-label="View switcher">
|
||||
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span>RESET</span>
|
||||
</button>
|
||||
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
|
||||
<span>EXIT COCKPIT</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Post-processing toggles (top-right) -->
|
||||
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
|
||||
<div class="compact pp-header-row">
|
||||
<span class="pp-header-label">DISPLAY</span>
|
||||
<span class="panel-divider" aria-hidden="true"></span>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">◀</button>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
|
||||
<span class="pp-icon">◎</span>
|
||||
<span class="pp-label">HUD</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="hud-layout-row">
|
||||
<span class="pp-slider-mini-label">Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout">
|
||||
<option value="tactical">Tactical</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
|
||||
<span class="pp-icon">▣</span>
|
||||
<span class="pp-label">DETECT</span>
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider">Density</label>
|
||||
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" />
|
||||
<span class="pp-slider-value" id="detection-density-value">50%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
|
||||
<span class="pp-slider-mini-label">Allocation</span>
|
||||
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation">
|
||||
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true">Elastic</button>
|
||||
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false">Weighted</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider">Fade</label>
|
||||
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" />
|
||||
<span class="pp-slider-value" id="detection-fade-value">7%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider">Outside</label>
|
||||
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
|
||||
<div id="param-slider-panel">
|
||||
<div class="param-panel-header">
|
||||
<span class="param-panel-title">PARAMETERS</span>
|
||||
<span class="param-panel-divider"></span>
|
||||
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel">−</button>
|
||||
</div>
|
||||
<div id="param-sliders"></div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the
|
||||
button ships lit and the mode row ships open — the same markup-carries-the-default
|
||||
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
|
||||
is what flips them back for a share link or a stored session carrying 3D off. -->
|
||||
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close">
|
||||
<span class="pp-icon">✈</span>
|
||||
<span class="pp-label">3D</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="models3d-mode-row">
|
||||
<span class="pp-slider-mini-label">Models</span>
|
||||
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage">
|
||||
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true">Proximity</button>
|
||||
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false">All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label">Scope</span>
|
||||
</button>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label">Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" title="Scope edge feather as a percentage of the keyhole radius" />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
<span class="pp-label">Celestial</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome">
|
||||
<span class="pp-icon">□</span>
|
||||
<span class="pp-label">Clean UI</span>
|
||||
</button>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
|
||||
<span class="pp-icon">✨</span>
|
||||
<span class="pp-label">Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="bloom-slider-row">
|
||||
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening">
|
||||
<span class="pp-icon">🔍</span>
|
||||
<span class="pp-label">Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="sharpen-slider-row">
|
||||
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
|
||||
|
||||
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
|
||||
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls">
|
||||
<!-- Control panel -->
|
||||
<div id="control-panel" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="panel-inner">
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
data-dock-toggle-target="control-panel" aria-expanded="false"
|
||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets">
|
||||
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||
<span class="panel-divider"></span>
|
||||
</button>
|
||||
<div id="control-panel-popover" class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="style-buttons" class="button-grid">
|
||||
<button class="style-btn active" data-style="normal">
|
||||
<span class="btn-icon">◯</span>
|
||||
<span class="btn-label">Normal</span>
|
||||
<span class="btn-key">1</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="retro">
|
||||
<span class="btn-icon">▦</span>
|
||||
<span class="btn-label">CRT</span>
|
||||
<span class="btn-key">2</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="surveillance">
|
||||
<span class="btn-icon">🌙</span>
|
||||
<span class="btn-label">NVG</span>
|
||||
<span class="btn-key">3</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="thermal">
|
||||
<span class="btn-icon">🌡️</span>
|
||||
<span class="btn-label">FLIR</span>
|
||||
<span class="btn-key">4</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="anime">
|
||||
<span class="btn-icon">✦</span>
|
||||
<span class="btn-label">Anime</span>
|
||||
<span class="btn-key">5</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="noir">
|
||||
<span class="btn-icon">◐</span>
|
||||
<span class="btn-label">Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="snow">
|
||||
<span class="btn-icon">❄</span>
|
||||
<span class="btn-label">Snow</span>
|
||||
<span class="btn-key">7</span>
|
||||
</button>
|
||||
</div>
|
||||
<section class="map-source-section" aria-labelledby="map-source-label">
|
||||
<div class="map-source-heading">
|
||||
<span id="map-source-label">MAP SOURCE</span>
|
||||
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
|
||||
</div>
|
||||
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
|
||||
<span class="panel-mini-label">Style</span>
|
||||
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location bar -->
|
||||
<div id="location-bar" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
|
||||
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
|
||||
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
|
||||
</div>
|
||||
<div class="dock-popover-content">
|
||||
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
|
||||
<div id="poi-row" class="poi-row-container"></div>
|
||||
<div class="location-bar-divider" id="location-bar-divider"></div>
|
||||
<div class="location-city-row">
|
||||
<div id="location-pills" class="location-pills"></div>
|
||||
<div class="location-search-wrap">
|
||||
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
|
||||
<span>🔎</span>
|
||||
</button>
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="left-panel-stack">
|
||||
<!-- Data Layer Toggle Panel -->
|
||||
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="data-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">DATA LAYERS</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="data-toggles" class="data-toggle-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CCTV Coverage Panel -->
|
||||
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="cctv-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">CCTV</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
</div>
|
||||
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
|
||||
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-enable-btn" class="scene-btn">CCTV OFF</button>
|
||||
<button id="cctv-nearest-btn" class="scene-btn">NEAREST</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-prev-btn" class="scene-btn">PREV</button>
|
||||
<select id="cctv-camera-select" aria-label="CCTV camera"></select>
|
||||
<button id="cctv-next-btn" class="scene-btn">NEXT</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-focus-btn" class="scene-btn">FOCUS</button>
|
||||
<button id="cctv-coverage-btn" class="scene-btn">COVERAGE OFF</button>
|
||||
<button id="cctv-auto-hop-btn" class="scene-btn">AUTO HOP OFF</button>
|
||||
</div>
|
||||
<div class="cctv-controls">
|
||||
<button id="cctv-projection-btn" class="scene-btn">PROJECTION ON</button>
|
||||
<span id="cctv-quality-chip">CAL · --</span>
|
||||
</div>
|
||||
<div class="cctv-calibration-block">
|
||||
<div class="cctv-calibration-header">
|
||||
<div class="cctv-summary-label">CALIBRATION</div>
|
||||
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV">ADJUST</button>
|
||||
</div>
|
||||
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type">
|
||||
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type">HDG --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type">PITCH --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type">FOV --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type">RANGE --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type">HGT --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type">ΔN --</button>
|
||||
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type">ΔE --</button>
|
||||
</div>
|
||||
<div class="cctv-controls cctv-calibration-actions">
|
||||
<button id="cctv-calib-save-btn" class="scene-btn">SAVE CAL</button>
|
||||
<button id="cctv-calib-reset-btn" class="scene-btn">RESET CAL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-summary-label">SCENE SUMMARY</div>
|
||||
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene Director Panel -->
|
||||
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="scene-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">SCENES</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel">+</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<select id="scene-select" aria-label="Scene recipe"></select>
|
||||
<button id="scene-new-btn" class="scene-btn">NEW</button>
|
||||
<button id="scene-delete-btn" class="scene-btn scene-btn-danger">DEL</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-capture-btn" class="scene-btn">CAPTURE SHOT</button>
|
||||
<button id="scene-update-shot-btn" class="scene-btn">UPDATE SHOT</button>
|
||||
</div>
|
||||
<div id="scene-shot-list" class="scene-shot-list"></div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-start-btn" class="scene-btn">START</button>
|
||||
<button id="scene-stop-btn" class="scene-btn scene-btn-danger">STOP</button>
|
||||
<button id="scene-next-btn" class="scene-btn">NEXT</button>
|
||||
</div>
|
||||
<div class="scene-controls">
|
||||
<button id="scene-export-btn" class="scene-btn">EXPORT PRESETS</button>
|
||||
<button id="scene-import-btn" class="scene-btn">IMPORT</button>
|
||||
<button id="scene-download-btn" class="scene-btn">RUN LOG</button>
|
||||
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
<div class="scene-progress">
|
||||
<div id="scene-progress-fill">0%</div>
|
||||
</div>
|
||||
<div id="scene-status">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Global Context is deliberately independent from the left layer accordion. -->
|
||||
<aside id="right-context-rail">
|
||||
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="global-context-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">CONTEXT</span>
|
||||
<span class="panel-divider"></span>
|
||||
<div id="context-radio-dock" class="context-radio-dock">
|
||||
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
|
||||
</button>
|
||||
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden>
|
||||
<div class="context-radio-mini-heading">
|
||||
<span>RADIO</span>
|
||||
<strong id="context-radio-mini-station">RADIO READY</strong>
|
||||
<span class="context-radio-mini-actions">
|
||||
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
|
||||
</button>
|
||||
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<div class="context-radio-mini-transport">
|
||||
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled>◀◀</button>
|
||||
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled>▶</button>
|
||||
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled>▶▶</button>
|
||||
</div>
|
||||
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled />
|
||||
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
|
||||
</div>
|
||||
<div class="global-context-modes" role="tablist" aria-label="Context mode">
|
||||
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span>CONTACTS</span>
|
||||
</button>
|
||||
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="-1" aria-selected="false" aria-controls="context-missions-view">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span>SPACE MISSIONS</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="context-mode-standby" class="context-mode-standby">
|
||||
<strong>SELECT CONTEXT</strong>
|
||||
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches & orbital assets</span>
|
||||
</div>
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
|
||||
<div class="global-context-actions" aria-label="Contact Context actions">
|
||||
<button id="cockpit-entry" type="button" hidden>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
||||
<span>COCKPIT</span>
|
||||
</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button">SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B">🛸</button>
|
||||
</div>
|
||||
<aside id="military-awareness-panel">
|
||||
<div class="military-awareness-standby">
|
||||
<strong>CONTACTS CONTEXT OFF</strong>
|
||||
<span>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
|
||||
<div id="space-mission-panel-host">
|
||||
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions">
|
||||
<div class="space-mission-roster-header">
|
||||
<div>
|
||||
<strong>AVAILABLE MISSIONS</strong>
|
||||
<span>SELECT A MISSION TO INSPECT</span>
|
||||
</div>
|
||||
<output data-mission-roster-count>—</output>
|
||||
</div>
|
||||
<div class="space-mission-roster-list" data-mission-roster-list>
|
||||
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section">+</button>
|
||||
</div>
|
||||
<div class="radio-directory-row">
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter">STATION TAG</label>
|
||||
<select id="radio-filter" aria-label="Filter stations by station tag" disabled>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
<strong id="radio-station-name">NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta">Enable Radio, then choose a globe marker or use next.</span>
|
||||
<span id="radio-station-tags"></span>
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider">DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
|
||||
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station">ALL · DRAG THE NEEDLE</span>
|
||||
<span>SNAPS TO AVAILABLE STATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback">
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled>PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled>PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled>NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled>STOP</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite">Radio off</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden>STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="scene-runtime"></div>
|
||||
|
||||
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
|
||||
until the visitor asks; shared links skip this surface entirely. It returns
|
||||
every session until "Don't show this again" is ticked — see
|
||||
src/firstRunExperience.js for the full show policy. -->
|
||||
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
|
||||
<div class="first-run-scanline" aria-hidden="true"></div>
|
||||
<header class="first-run-header">
|
||||
<span class="first-run-kicker">MISSION CONTROL · FIRST LAUNCH</span>
|
||||
</header>
|
||||
<h2 id="first-run-title">Choose your first view</h2>
|
||||
<!-- The card's one persuasive line, owner-authored and VERBATIM (2026-08-23)
|
||||
— including the unspaced em dash. The arc it has to carry: this looks
|
||||
like something you should not have access to → the sources are all
|
||||
public → the data is real. Tile subcopy stays purely functional. -->
|
||||
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
|
||||
<div class="first-run-choices">
|
||||
<button type="button" data-first-run-choice="contacts">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
|
||||
<span><strong>LIVE CONTACTS</strong><small>Aircraft, vessels and nearby intelligence</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" data-first-run-choice="space-missions">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
<span><strong>SPACE MISSIONS</strong><small>Launches, spacecraft and orbital context</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" data-first-run-choice="environmental">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">local_fire_department</span>
|
||||
<!-- Title text is painted from ENVIRONMENTAL_LABEL_CHOICE at init. -->
|
||||
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
|
||||
optimizes for the fully configured experience (owner ruling,
|
||||
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
|
||||
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
<button type="button" class="first-run-explore" data-first-run-choice="explore">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span><strong>EXPLORE MANUALLY</strong><small>Begin with a clean globe</small></span>
|
||||
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="first-run-footer">
|
||||
<label class="first-run-suppress">
|
||||
<input type="checkbox" data-first-run-suppress />
|
||||
<span>Don't show this again</span>
|
||||
</label>
|
||||
<span>ESC to dismiss</span>
|
||||
</div>
|
||||
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
|
||||
a live region that also holds layout makes the row jump as it speaks. -->
|
||||
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
|
||||
</aside>
|
||||
|
||||
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
|
||||
both the chip and this dialog outright when /api/setup/status is not
|
||||
there to answer (prod builds, LAN visitors). Rows are rendered from
|
||||
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
|
||||
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
||||
<span data-key-setup-chip-label>POWER UP</span>
|
||||
</button>
|
||||
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
|
||||
<div class="key-setup-scanline" aria-hidden="true"></div>
|
||||
<header class="key-setup-header">
|
||||
<span class="key-setup-kicker">GROUND STATION · PROVIDER SETTINGS</span>
|
||||
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<h2 id="key-setup-title">Power up the globe</h2>
|
||||
<p id="key-setup-description">The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
|
||||
<div class="key-setup-rows" data-key-setup-rows></div>
|
||||
<div class="key-setup-footer">
|
||||
<button type="button" class="key-setup-apply" data-key-setup-apply>SAVE KEYS</button>
|
||||
<span class="key-setup-hint">ESC to close</span>
|
||||
</div>
|
||||
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite">The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
|
||||
</aside>
|
||||
|
||||
<!-- Intelligence HUD Overlay -->
|
||||
<div id="intel-hud"></div>
|
||||
|
||||
<!-- 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>GOD'S EYE <span class="title-accent">VIEW</span></h2>
|
||||
<p class="loader-status">Initializing photorealistic world...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bilawal Sidhu",
|
||||
"homepage": "https://github.com/bilawalsidhu/gods-eye-view#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bilawalsidhu/gods-eye-view.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/bilawalsidhu/gods-eye-view/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.14.0 <25 || >=26 <27"
|
||||
},
|
||||
"keywords": [
|
||||
"cesium",
|
||||
"3d-globe",
|
||||
"geospatial",
|
||||
"osint",
|
||||
"spatial-intelligence",
|
||||
"photorealistic-3d-tiles",
|
||||
"adsb",
|
||||
"ais",
|
||||
"satellites",
|
||||
"voice-control",
|
||||
"openai-realtime",
|
||||
"visualization"
|
||||
],
|
||||
"scripts": {
|
||||
"doctor": "node scripts/setup-doctor.mjs",
|
||||
"dev": "vite",
|
||||
"dev:secure": "./scripts/dev-secure.sh",
|
||||
"opensky:import": "./scripts/opensky-import-client.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"puppeteer": "^24.37.5",
|
||||
"sharp": "^0.34.5",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# God's Eye View works without any API keys.
|
||||
# Easiest way to add one: open PROVIDER SETTINGS inside the running app (the
|
||||
# POWER UP chip, bottom-right). It saves into this file for you with owner-only
|
||||
# permissions and restarts the app — you never need to edit this file by hand.
|
||||
# Manual fallback: use Pinokio's File Explorer to reveal this ignored
|
||||
# pinokio/ENVIRONMENT file, edit it with a trusted local text editor, uncomment
|
||||
# only the providers you need, then Stop and Start the app. This file is
|
||||
# plaintext, not encrypted.
|
||||
# Do not enter credentials in Pinokio 8.0.40's native Configure panel: that
|
||||
# release saves this nested layout to the wrong path and logs submitted values.
|
||||
|
||||
# GOOGLE_MAPS_API_KEY=
|
||||
# CESIUM_ION_TOKEN=
|
||||
# OPENAI_API_KEY=
|
||||
# AISSTREAM_API_KEY=
|
||||
# FIRMS_MAP_KEY=
|
||||
# TOMTOM_API_KEY=
|
||||
# OPENSKY_CLIENT_ID=
|
||||
# OPENSKY_CLIENT_SECRET=
|
||||
# LL2_API_TOKEN=
|
||||
|
||||
# Keep sharing off. The current supported Pinokio release logs successful
|
||||
# tunnel-login passcodes, so the launcher refuses to create a tunnel.
|
||||
PINOKIO_SHARE_CLOUDFLARE=false
|
||||
PINOKIO_SHARE_LOCAL=false
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
run: [
|
||||
{
|
||||
when: "{{!kernel.exists(cwd, 'ENVIRONMENT')}}",
|
||||
method: 'fs.copy',
|
||||
params: {
|
||||
src: '_ENVIRONMENT',
|
||||
dest: 'ENVIRONMENT',
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'shell.run',
|
||||
params: {
|
||||
path: '..',
|
||||
// Forward nonblank app configuration values for Pinokio compatibility. The
|
||||
// child also reads the raw app ENVIRONMENT file because Pinokio removes
|
||||
// blank fields before constructing this merged template environment.
|
||||
env: {
|
||||
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
|
||||
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
|
||||
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
|
||||
AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}',
|
||||
FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}',
|
||||
TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}',
|
||||
OPENSKY_CLIENT_ID: '{{env.OPENSKY_CLIENT_ID || ""}}',
|
||||
OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}',
|
||||
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 || ""}}',
|
||||
},
|
||||
message: 'node scripts/pinokio-install.mjs',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
module.exports = {
|
||||
version: '3.6',
|
||||
title: "God's Eye View",
|
||||
description: 'A live 3D intelligence console for planet Earth.',
|
||||
menu: async (kernel, info) => {
|
||||
const installed = await kernel.exists(__dirname, '.installed');
|
||||
const installing = info.running('install.js');
|
||||
const starting = info.running('start.js');
|
||||
const updating = info.running('update.js');
|
||||
const resetting = info.running('reset.js');
|
||||
|
||||
if (installing || updating || resetting) {
|
||||
const href = installing ? 'install.js' : updating ? 'update.js' : 'reset.js';
|
||||
const text = installing ? 'Installing' : updating ? 'Updating' : 'Resetting';
|
||||
return [{ default: true, icon: 'fa-solid fa-terminal', text, href }];
|
||||
}
|
||||
|
||||
if (!installed) {
|
||||
return [{ default: true, icon: 'fa-solid fa-download', text: 'Install', href: 'install.js' }];
|
||||
}
|
||||
|
||||
if (starting) {
|
||||
const local = info.local('start.js');
|
||||
if (local?.url) {
|
||||
return [
|
||||
{ default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url },
|
||||
{ icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' },
|
||||
];
|
||||
}
|
||||
return [{ default: true, icon: 'fa-solid fa-terminal', text: 'Starting', href: 'start.js' }];
|
||||
}
|
||||
|
||||
return [
|
||||
{ default: true, icon: 'fa-solid fa-power-off', text: 'Start', href: 'start.js' },
|
||||
{ icon: 'fa-solid fa-arrows-rotate', text: 'Update', href: 'update.js' },
|
||||
{ icon: 'fa-solid fa-broom', text: 'Repair installation', href: 'reset.js' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
run: [
|
||||
{
|
||||
method: 'shell.run',
|
||||
params: {
|
||||
path: '..',
|
||||
message: 'node scripts/pinokio-reset.mjs',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
module.exports = {
|
||||
daemon: true,
|
||||
run: [
|
||||
{
|
||||
method: 'shell.run',
|
||||
params: {
|
||||
path: '..',
|
||||
env: {
|
||||
HOST: '127.0.0.1',
|
||||
PORT: '{{port}}',
|
||||
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
|
||||
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
|
||||
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
|
||||
AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}',
|
||||
FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}',
|
||||
TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}',
|
||||
OPENSKY_CLIENT_ID: '{{env.OPENSKY_CLIENT_ID || ""}}',
|
||||
OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}',
|
||||
LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}',
|
||||
PINOKIO_SHARE_CLOUDFLARE: '{{env.PINOKIO_SHARE_CLOUDFLARE || "false"}}',
|
||||
PINOKIO_SHARE_LOCAL: '{{env.PINOKIO_SHARE_LOCAL || "false"}}',
|
||||
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 || ""}}',
|
||||
},
|
||||
message: 'node scripts/pinokio-start.mjs',
|
||||
on: [{
|
||||
event: '/\\[Pinokio\\] Ready at (http:\\/\\/127\\.0\\.0\\.1:[0-9]+\\/)/',
|
||||
done: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Pinokio requires local.url for ready/Open state. PINOKIO_SHARE_VAR is
|
||||
// pinned to a different sentinel so local.set cannot trigger sharing.
|
||||
method: 'local.set',
|
||||
params: {
|
||||
url: '{{input.event[1]}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
run: [
|
||||
{
|
||||
method: 'shell.run',
|
||||
params: {
|
||||
path: '..',
|
||||
// Update reuses the install doctor. The child re-reads raw app
|
||||
// ENVIRONMENT so blank fields override Pinokio-global values too.
|
||||
env: {
|
||||
GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}',
|
||||
CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}',
|
||||
OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}',
|
||||
AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}',
|
||||
FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}',
|
||||
TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}',
|
||||
OPENSKY_CLIENT_ID: '{{env.OPENSKY_CLIENT_ID || ""}}',
|
||||
OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}',
|
||||
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 || ""}}',
|
||||
},
|
||||
message: 'node scripts/pinokio-update.mjs',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" aria-hidden="true">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="180 120 775 520" role="img" aria-labelledby="logo-title">
|
||||
<title id="logo-title">God's Eye View</title>
|
||||
<defs>
|
||||
<style>
|
||||
.cls-4 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2, .cls-4 {
|
||||
stroke: #000;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.cls-1 {
|
||||
fill: #4454727a;
|
||||
stroke: #48b;
|
||||
stroke-width: 10px;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #00f6ff;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
stroke: #00f6ff;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 6.99px;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
opacity: 0.63;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path id="outer_eye" class="cls-1" d="M567.5,623c-163.183,0-303.026-99.084-361.823-239.922C257.508,238.76,401.037,135,570,135c166.451,0,308.222,100.7,361.954,241.667C874.771,520.905,733.152,623,567.5,623ZM567,156c124.816,0,226,101.184,226,226S691.816,608,567,608,341,506.816,341,382,442.184,156,567,156ZM816,256.213A272.836,272.836,0,0,1,846,381M793.574,488.677a249.545,249.545,0,0,0,3.239-208.3A303.384,303.384,0,0,0,816,256.213a274.468,274.468,0,0,1-1.516,252.475q-0.927,1.758-1.88,3.5-1.632-2.238-3.306-4.446Q801.826,497.884,793.574,488.677ZM345.5,490.85q-8.055,9.164-15.37,18.966a274.353,274.353,0,0,1-3.748-250.343q7.347,10.012,15.469,19.393A249.512,249.512,0,0,0,345.5,490.85Z"/>
|
||||
<path id="inner_eye" class="cls-2" d="M568,179.985c107.678,0,194.968,88.655,194.968,198.015S675.678,576.015,568,576.015,373.032,487.36,373.032,378,460.322,179.985,568,179.985Z"/>
|
||||
<path id="globe" class="cls-3" d="M569.5,499.831A120.331,120.331,0,1,1,689.831,379.5,120.33,120.33,0,0,1,569.5,499.831Zm65.1-50.585c1.225-.4,3.268-2.858,4.492-3.677s3.268-4.083,3.675-5.717,1.227-4.495,1.634-6.126a36.015,36.015,0,0,0,.818-6.135,14.639,14.639,0,0,0-.407-2.855l-3.266,6.127s-4.9,2.04-5.315,3.674-0.817,4.088-.817,4.088a61.248,61.248,0,0,0-2.45,6.133C632.554,446.388,633.372,449.66,634.6,449.246ZM461.011,377.072c0.72-1.105,1.928-6.276,1.6-7.894-0.411-2.037,1.225-6.94,1.225-9.392s4.083-7.358,4.9-9.4,4.491-7.349,6.535-10.623-0.4-2.038-2.449-.809,1.225-3.274,2.449-4.086,8.172-5.72,8.172-5.72,2.449-2.454-.409-7.762c-2.211-4.1,0-4.5,0-6.128a2.43,2.43,0,0,0-1.28-.223c2.129-2.952,4.408-5.788,6.811-8.512A108.871,108.871,0,0,0,494.612,458.6a0.454,0.454,0,0,1,.683-0.363c2.043,0.819,2.449-.4,4.085-0.4s4.086-2.043,4.086-2.043l-1.641-4.9,1.641-6.126,2.041-5.717s0-4.095-2.041-4.5-5.722-4.081-6.946-4.081a10.018,10.018,0,0,1-4.491-1.636c-1.227-.813-3.677-1.634-4.5-2.857a31.144,31.144,0,0,1-2.858-5.314c-0.816-2.039-.409-2.039-3.681-5.715s-2.452-2.863-4.9-4.093-3.265-2.443-5.308-3.263a13.378,13.378,0,0,1-4.5-2.857,4.884,4.884,0,0,0-2.711-1.519,110.041,110.041,0,0,1-2.595-23.808C460.98,378.618,461,377.84,461.011,377.072Zm35.743-78.7a13.8,13.8,0,0,0-1.051,2.589c-0.408,1.638,2.864.416,2.864,0.416L502.239,303l4.5-1.631a13.741,13.741,0,0,1,4.083-4.5c2.857-2.043,2.454-2.452,3.677-4.084s0-.006,5.314-5.732,8.167-5.718,8.167-5.718,1.915-2.1,3.244-3.895A108.986,108.986,0,0,0,496.754,298.368Zm41.024,56.1c-1.224.82-5.715,3.272-6.944,5.315a22.681,22.681,0,0,0-2.859,6.535,31.042,31.042,0,0,0-.408,6.538c0,2.854-.41,4.5,0,6.949s4.5,8.568,6.128,10.623c1.63,2.038,3.674,4.483,5.719,5.713a47.235,47.235,0,0,1,4.9,3.674,4.03,4.03,0,0,0,4.9,1.223c3.272-1.223,6.538-1.223,9.4-2.853s7.353-3.685,7.353-1.231,1.632,4.084,4.082,3.272,6.539-2.858,6.13,0S577,406.764,577,408.4s-0.4,3.265.818,4.495,4.5,3.265,4.5,4.49,0.409,5.311,1.229,6.946,1.639,4.08.823,5.724-2.434,6.123-2.434,6.123-0.779,3.676-.37,4.9a12.683,12.683,0,0,0,3.332,4.906c2.043,2.042,1.771,4.492,2.994,6.124s2.993,6.539,2.993,6.539l1.236,4.088v3.677s7.966-1.231,9.591-1.64a30.673,30.673,0,0,0,8.618-4.9c1.636-1.632,1.311-2.856,3.349-4.488s5.762-4.5,5.762-4.5l-0.387-6.126,4.914-6.544,3.684-3.677s3.267-4.484,2.865-6.128c-0.413-1.628-1.231-9.8-1.231-9.8a37.972,37.972,0,0,1,2.863-5.313c1.224-1.634,1.227-3.678,3.679-5.719a33.109,33.109,0,0,0,5.306-6.126c1.229-1.634,3.681-4.9,4.5-6.127a21.871,21.871,0,0,0,1.635-4.894l-7.355,1.216c-2.45-4.082-9.392-13.073-10.621-15.112a37.04,37.04,0,0,1-2.861-7.76c-0.4-1.634-2.445-6.131-2.856-7.356s-3.27-4.908-4.088-6.128-3.675-2.043-5.309-3.268-11.843-3.27-11.843-3.27-3.129-1.991-4.088-1.226c-4.083,3.269-13.48,1.226-15.523.415s-3.269-2.041-3.269-3.272-1.835-7.143-1.835-7.143-3.985.705-5.213,0.705a20.333,20.333,0,0,1-4.442-1.276s-4.055.379-5.693,1.2a61.205,61.205,0,0,1-6.115,2.438,36.488,36.488,0,0,1-4.488.814c-3.671-1.636-6.529,2.45-7.352,4.079s-2.445,2.454-2.445,4.084S539,353.659,537.778,354.471Zm18.191-54.739-3.128-4.5-3.727,2.045V304.5l-3.839-1.5-1.715,3.674,1.685,2.452L549.114,305v4.129l8.478-1.636,3.164-2.449Zm71.15-12.676c-1.669.869-4.808,3.094-6,1.894-1.227-1.218-4.086-3.423-4.086-3.423,2.448-1.636-4.9-5.212-4.9-5.212l-8.576-1.022h-6.125c-1.227,0-3.681.825-5.723,0.825s-6.943,3.583-8.577,3.994-5.314,2.5-6.944,2.5-4.088,3.086-4.088,3.086-2.856,5.218-.4,4.4,5.717-.858,6.122.367,1.227,3.242,1.636,4.471,6.131,1.618,6.131,1.618a18.551,18.551,0,0,1,2.038-3.683c1.227-1.634,2.452-3.675,2.452-3.675s2.044,4.082,2.452,5.3-0.818,4.908-.818,4.908-4.491,2.852-5.716,2.852-8.175-6.126-8.175-6.126a25.4,25.4,0,0,0-4.489,2.043c-2.856,2.861-5.717,5.31-5.717,5.31a17.057,17.057,0,0,0-4.081,2.046c-1.629,1.222-5.138,1.487-6.117,2.854-2.039,2.863.84,4.9,0.84,4.9l-2.408,3.271-1.548,1.225-5.553,1.221-2.751.616c0.4,1.227,1.258,5.105,1.258,5.105s-1.87,6.943,0,6.943c0,0,4.276,1.64,5.5,1.64s2.625,0.4,4.265-1.227a59.861,59.861,0,0,0,5.156-6.538,8.047,8.047,0,0,1,4.49-3.267c2.454-.82,3-2.047,4.231-2.452s6.47-1.229,6.47-1.229,3.709,2.043,4.932,2.861c0.573,0.386-1.393,1.658,6.086,2.907V331.6l3.7-.411-4.207-3.264-0.44-.1-4.778-4.8-0.419-3.672s4.861,2.862,6.493,3.672a43.671,43.671,0,0,1,4.81,3.272l1.783,5.719,2.4,4.5s-1.87-5.722,0-5.722c0,0,5.158.405,6.381,0a36.253,36.253,0,0,0,5.237-1.632c1.636-.815-0.648-3.68.986-5.721a10.385,10.385,0,0,1,4.981-3.27c1.23-.405,5.35-2.447,5.35-2.447l4.522,0.82c-2.041,1.627-4.486,4.077-2.85,3.676s5.313,1.63,6.949,2.855,0.413,4.087.413,4.087-4.5-.411-7.355-0.815a29.263,29.263,0,0,0-7.351.4l-3.677,2.043s-2.454.816-2.045,2.861,2.045,4.5,3.679,6.535,2.858,4.5,4.085,3.675,5.309-3.265,5.309-3.265a12.784,12.784,0,0,0-.409,4.9,30.6,30.6,0,0,1,.409,4.9s-4.082,0-2.861.822,2.967,3.905,3.681,4.9c4.083,5.717,12.664,18.383,13.482,20.015s0.44,4.126,1.631,5.31c2.454,2.45,7.352,0,8.578-.817s5.311-2.863,6.536-4.082,0.818-.822,3.272-2.454,4.079-2.856,2.859-4.085-3.68-5.725-3.68-5.725c-3.676.825-7.351-1.222-7.351-2.445s-3.269-6.124-3.269-6.124,0.407-.82,3.269.809,5.306,4.908Line truncated
|
||||
<path id="globe_cage" class="cls-4" d="M569.5,576.491c-106.968,0-193.988-89.044-193.988-198.491S462.532,179.509,569.5,179.509,763.488,268.553,763.488,378,676.468,576.491,569.5,576.491ZM578.4,368.9h76.237a314.2,314.2,0,0,0-11.945-78.026H578.4V368.9ZM654.634,387.1H578.4V464.94h64.345A314.251,314.251,0,0,0,654.634,387.1Zm90.813,0H671.861a333.4,333.4,0,0,1-11.206,77.836h63.112A182.117,182.117,0,0,0,745.447,387.1Zm-32.528,95.444H655.363a288.421,288.421,0,0,1-32.3,67.191A177.214,177.214,0,0,0,712.919,482.548Zm-115.67,73.48a263.416,263.416,0,0,0,39.828-73.48H578.4v75.476A173.846,173.846,0,0,0,597.249,556.028ZM560.6,558.036V482.548H497.674a263.837,263.837,0,0,0,39.135,72.575A174.279,174.279,0,0,0,560.6,558.036ZM510.434,547.83a289.038,289.038,0,0,1-31.078-65.282H426.1A177.5,177.5,0,0,0,510.434,547.83Zm-95.208-82.89H474.1A333.353,333.353,0,0,1,462.878,387.1H393.555A182.127,182.127,0,0,0,415.226,464.94ZM393.555,368.9h69.323a333.4,333.4,0,0,1,11.268-78.026H415.332A182.13,182.13,0,0,0,393.555,368.9Zm32.676-95.633h53.185a289.064,289.064,0,0,1,31-65.087A177.487,177.487,0,0,0,426.231,273.263Zm134.372-75.3a174.068,174.068,0,0,0-23.8,2.914,263.8,263.8,0,0,0-39.06,72.384H560.6v-75.3Zm0,92.905H492.051A314.088,314.088,0,0,0,480.106,368.9h80.5V290.87Zm-80.5,96.234A314.1,314.1,0,0,0,492,464.94h68.6V387.1h-80.5ZM578.4,197.977v75.286h58.612a263.412,263.412,0,0,0-39.751-73.289A174.011,174.011,0,0,0,578.4,197.977Zm44.672,8.286a288.6,288.6,0,0,1,32.231,67h57.491A177.213,177.213,0,0,0,623.069,206.263Zm100.6,84.607H660.6A333.3,333.3,0,0,1,671.861,368.9h73.586A182.129,182.129,0,0,0,723.667,290.87Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">Microphone</title>
|
||||
<g fill="none" stroke="#36dcff" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="23" y="10" width="18" height="30" rx="9" stroke-width="3"/>
|
||||
<path d="M16 31v2c0 9 7 16 16 16s16-7 16-16v-2" stroke-width="3"/>
|
||||
<path d="M32 49v8M24 57h16" stroke-width="3"/>
|
||||
<path d="M28 17h8M28 23h8M28 29h8" stroke-width="2" opacity=".65"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,21 @@
|
||||
# Bundled 3D Model Attribution
|
||||
|
||||
The model files in this directory are third-party visual assets. They are not
|
||||
covered by the repository's MIT source-code license; each remains available
|
||||
under the license listed below.
|
||||
|
||||
| File | Original work and creator | Source | License | Project modifications |
|
||||
|---|---|---|---|---|
|
||||
| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. |
|
||||
| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. |
|
||||
| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. |
|
||||
| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. |
|
||||
|
||||
CC BY 4.0 permits sharing and adaptation, including commercial use, provided
|
||||
appropriate credit is retained, the license is linked, and modifications are
|
||||
identified. These credits do not imply endorsement by the original creators.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" role="img" aria-labelledby="title">
|
||||
<title id="title">Pin panel</title>
|
||||
<line x1="12" y1="17" x2="12" y2="22"/>
|
||||
<path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.12-2.65A2 2 0 0 1 16 10.11V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v5.11a2 2 0 0 1-.44 1.24L5.44 14a2 2 0 0 0-.44 1.24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#36dcff" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 16.5L12 10L21 16.5"/>
|
||||
<path d="M3 11L12 4.5L21 11"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/data/local_data/neighborhoods/san-francisco.json from the official
|
||||
* public-domain DataSF "Analysis Neighborhoods" dataset.
|
||||
*
|
||||
* Source (map view): https://data.sfgov.org/Geographic-Locations-and-Boundaries/Analysis-Neighborhoods-Map/p5b7-5n3h
|
||||
* Underlying dataset: https://data.sfgov.org/d/j2bu-swwd (columns: the_geom, nhood)
|
||||
* Fetched from: https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100
|
||||
* License: Open Data Commons Public Domain Dedication and License (PDDL 1.0)
|
||||
* (dataset metadata `licenseId: "PDDL"` at
|
||||
* https://data.sfgov.org/api/views/j2bu-swwd.json)
|
||||
*
|
||||
* Transform (deterministic):
|
||||
* 1. `properties.nhood` → `properties.name` (no renames — DataSF names kept verbatim).
|
||||
* 2. Douglas-Peucker simplification per ring, tolerance 2e-5° (~2 m) — shaves
|
||||
* hyper-detailed shoreline vertices without visibly moving boundaries.
|
||||
* 3. Coordinates rounded to 6 decimals (~0.1 m); consecutive duplicates dropped;
|
||||
* rings re-closed; rings that collapse below 4 points dropped.
|
||||
* 4. Single-part MultiPolygons collapsed to Polygon.
|
||||
* 5. Features sorted by name for a stable diff.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-sf-neighborhoods.mjs [raw.geojson]
|
||||
* With no argument it downloads the live dataset; with an argument it reads the
|
||||
* given raw GeoJSON file (the exact bytes retrieved on 2026-07-30 in our case).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://data.sfgov.org/resource/j2bu-swwd.geojson?$limit=100';
|
||||
const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..', 'src', 'data', 'local_data', 'neighborhoods', 'san-francisco.json');
|
||||
const TOLERANCE = 2e-5; // degrees, ~2 m
|
||||
const DECIMALS = 6;
|
||||
|
||||
/** Perpendicular distance from point p to segment a-b (in degrees, planar — fine at this scale). */
|
||||
function segDist(p, a, b) {
|
||||
let [x, y] = p; const [x1, y1] = a; const [x2, y2] = b;
|
||||
let dx = x2 - x1; let dy = y2 - y1;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
|
||||
if (t > 1) { x -= x2; y -= y2; return Math.hypot(x, y); }
|
||||
if (t > 0) { x -= x1 + dx * t; y -= y1 + dy * t; return Math.hypot(x, y); }
|
||||
}
|
||||
return Math.hypot(x - x1, y - y1);
|
||||
}
|
||||
|
||||
/** Iterative Douglas-Peucker on an open point list. */
|
||||
function douglasPeucker(points, tolerance) {
|
||||
if (points.length <= 2) return points.slice();
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = keep[points.length - 1] = 1;
|
||||
const stack = [[0, points.length - 1]];
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop();
|
||||
let maxDist = 0; let index = -1;
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDist(points[i], points[first], points[last]);
|
||||
if (d > maxDist) { maxDist = d; index = i; }
|
||||
}
|
||||
if (maxDist > tolerance && index !== -1) {
|
||||
keep[index] = 1;
|
||||
stack.push([first, index], [index, last]);
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
const round = (v) => Number(v.toFixed(DECIMALS));
|
||||
|
||||
/** Simplify + round one ring; returns null if it degenerates. */
|
||||
function processRing(ring) {
|
||||
// GeoJSON rings are closed (first == last); simplify the open part.
|
||||
const open = ring.length > 1
|
||||
&& ring[0][0] === ring[ring.length - 1][0] && ring[0][1] === ring[ring.length - 1][1]
|
||||
? ring.slice(0, -1) : ring.slice();
|
||||
const simplified = douglasPeucker(open, TOLERANCE).map(([lon, lat]) => [round(lon), round(lat)]);
|
||||
const out = [];
|
||||
for (const pt of simplified) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev && prev[0] === pt[0] && prev[1] === pt[1]) continue;
|
||||
out.push(pt);
|
||||
}
|
||||
if (out.length < 3) return null;
|
||||
out.push([out[0][0], out[0][1]]); // re-close
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let rawText;
|
||||
if (process.argv[2]) {
|
||||
rawText = fs.readFileSync(process.argv[2], 'utf8');
|
||||
console.log(`read ${process.argv[2]}`);
|
||||
} else {
|
||||
console.log(`fetching ${SOURCE_URL}`);
|
||||
const res = await fetch(SOURCE_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} from DataSF`);
|
||||
rawText = await res.text();
|
||||
}
|
||||
const raw = JSON.parse(rawText);
|
||||
if (raw.type !== 'FeatureCollection' || !Array.isArray(raw.features)) {
|
||||
throw new Error('unexpected payload: not a FeatureCollection');
|
||||
}
|
||||
|
||||
let inVerts = 0; let outVerts = 0;
|
||||
const features = [];
|
||||
for (const f of raw.features) {
|
||||
const name = f.properties && f.properties.nhood;
|
||||
if (!name || !f.geometry) throw new Error(`feature missing nhood/geometry: ${JSON.stringify(f.properties)}`);
|
||||
const polysIn = f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates
|
||||
: f.geometry.type === 'Polygon' ? [f.geometry.coordinates]
|
||||
: null;
|
||||
if (!polysIn) throw new Error(`unexpected geometry type ${f.geometry.type} for ${name}`);
|
||||
const polys = [];
|
||||
for (const poly of polysIn) {
|
||||
const rings = [];
|
||||
for (const ring of poly) {
|
||||
inVerts += ring.length;
|
||||
const r = processRing(ring);
|
||||
if (r) { rings.push(r); outVerts += r.length; }
|
||||
}
|
||||
if (rings.length && poly[0] && rings[0]) polys.push(rings);
|
||||
}
|
||||
if (!polys.length) throw new Error(`geometry collapsed for ${name}`);
|
||||
const geometry = polys.length === 1
|
||||
? { type: 'Polygon', coordinates: polys[0] }
|
||||
: { type: 'MultiPolygon', coordinates: polys };
|
||||
features.push({ type: 'Feature', properties: { name }, geometry });
|
||||
}
|
||||
features.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
const out = {
|
||||
type: 'FeatureCollection',
|
||||
city: 'San Francisco',
|
||||
source: 'DataSF "Analysis Neighborhoods" (dataset j2bu-swwd, map view p5b7-5n3h) — PDDL 1.0 (public domain); see SOURCE.md',
|
||||
features,
|
||||
};
|
||||
fs.writeFileSync(OUT, `${JSON.stringify(out)}\n`);
|
||||
const kb = (fs.statSync(OUT).size / 1024).toFixed(0);
|
||||
console.log(`wrote ${OUT}: ${features.length} features, ${inVerts} → ${outVerts} vertices, ${kb} KB`);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
|
||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "error: npm not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="env"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
echo "Starting God's Eye View with CCTV source pack..."
|
||||
echo "URL: http://localhost:${PORT}/"
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
if [[ -f "${CCTV_SOURCES_FILE}" ]]; then
|
||||
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE}"
|
||||
else
|
||||
echo "CCTV_SOURCES_FILE: ${CCTV_SOURCES_FILE} (missing, using Austin open-data feed discovery)"
|
||||
fi
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
|
||||
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE}" \
|
||||
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN}" \
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
|
||||
npm run dev -- --host "${HOST}" --port "${PORT}" --force
|
||||
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
# Local-only by default: the dev server brokers configured API keys, so it
|
||||
# should not be reachable from the network unless explicitly requested.
|
||||
# Set HOST=0.0.0.0 to opt in to LAN exposure (a warning is printed).
|
||||
HOST="${HOST:-localhost}"
|
||||
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
|
||||
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
|
||||
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
|
||||
# pack; override per-run for lighter/heavier loads. Kill switches:
|
||||
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
|
||||
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
|
||||
# is preserved rather than replaced by the default. Still set-u-safe when unset.
|
||||
CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
|
||||
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
|
||||
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
|
||||
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
# passed to Vite; values never enter the provenance marker. This lets Provider
|
||||
# Settings keep an exported credential read-only even when .env happens to hold
|
||||
# the same value, without misclassifying values that dev-fresh loaded from .env.
|
||||
KEY_SETUP_EXTERNAL_KEYS=()
|
||||
[[ -n "${GOOGLE_MAPS_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_API_KEY)
|
||||
[[ -n "${CESIUM_ION_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(CESIUM_ION_TOKEN)
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY)
|
||||
[[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY)
|
||||
[[ -n "${FIRMS_MAP_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(FIRMS_MAP_KEY)
|
||||
[[ -n "${TOMTOM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(TOMTOM_API_KEY)
|
||||
[[ -n "${OPENSKY_CLIENT_ID:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENSKY_CLIENT_ID)
|
||||
[[ -n "${OPENSKY_CLIENT_SECRET:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENSKY_CLIENT_SECRET)
|
||||
[[ -n "${LL2_API_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(LL2_API_TOKEN)
|
||||
KEY_SETUP_EXTERNAL_KEYS_CSV="$(IFS=,; printf '%s' "${KEY_SETUP_EXTERNAL_KEYS[*]:-}")"
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
DEV_COMMAND=(npm run dev --)
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
DEV_COMMAND=(pnpm run dev)
|
||||
else
|
||||
echo "error: neither npm nor pnpm found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_dotenv_value() {
|
||||
local variable_name="$1"
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse dotenv files" >&2
|
||||
return
|
||||
fi
|
||||
node scripts/read-dotenv-value.mjs "${variable_name}"
|
||||
}
|
||||
|
||||
# Vite loads .env for browser build-time configuration, but this launcher needs
|
||||
# the Maps key before Vite starts. Preserve a shell-provided value; otherwise
|
||||
# read Vite's project-local dotenv ladder without executing it as shell code.
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_ENV_SOURCE="env"
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_ENV="$(read_dotenv_value "GOOGLE_MAPS_API_KEY")"
|
||||
GOOGLE_MAPS_API_KEY_ENV_SOURCE="dotenv"
|
||||
fi
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="${GOOGLE_MAPS_API_KEY_ENV_SOURCE}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="not configured"
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" -- "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE:-$(read_dotenv_value "OPENSKY_AUTH_MODE")}"
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-$(read_dotenv_value "OPENSKY_CREDENTIALS_FILE")}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Explicit env wins, then .env, then the credentials file / Keychain below.
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-$(read_dotenv_value "OPENSKY_CLIENT_ID")}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-$(read_dotenv_value "OPENSKY_CLIENT_SECRET")}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-$(read_dotenv_value "OPENSKY_USERNAME")}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-$(read_dotenv_value "OPENSKY_PASSWORD")}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
# Optional keys: explicit env wins, followed by .env and Keychain fallback.
|
||||
# Add to Keychain with e.g.:
|
||||
# security add-generic-password -U -s "openai-api" -a "api-key" -w
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}"
|
||||
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}"
|
||||
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}"
|
||||
LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}"
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_dotenv_value "TOMTOM_API_KEY")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_dotenv_value "FIRMS_MAP_KEY")}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_keychain_secret "openai-api" "api-key")}"
|
||||
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_keychain_secret "aisstream-api" "api-key")}"
|
||||
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token")}"
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}"
|
||||
|
||||
if [[ ! -f "src/data/cctv.js" ]]; then
|
||||
echo "error: expected CCTV layer file missing: src/data/cctv.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then
|
||||
echo "error: CCTV layer not wired in src/main.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping all existing God's Eye View dev servers..."
|
||||
pkill -f "${ROOT_DIR}/node_modules/.bin/vite" >/dev/null 2>&1 || true
|
||||
pkill -f "${ROOT_DIR}/node_modules/vite/bin/vite.js" >/dev/null 2>&1 || true
|
||||
|
||||
# Also clear the requested port in case it is held by a stale wrapper or a
|
||||
# server started through a different package-manager command.
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
PIDS="$(lsof -tiTCP:${PORT} -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${PIDS}" ]]; then
|
||||
echo "${PIDS}" | xargs kill -9 >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Clearing Vite cache..."
|
||||
rm -rf node_modules/.vite
|
||||
|
||||
echo "Starting fresh God's Eye View dev server..."
|
||||
case "${HOST}" in
|
||||
localhost|127.0.0.1|::1)
|
||||
echo "Local-only mode: reachable at http://localhost:${PORT}/ (set HOST=0.0.0.0 for LAN)"
|
||||
;;
|
||||
*)
|
||||
LAN_IP=""
|
||||
if command -v ipconfig >/dev/null 2>&1; then
|
||||
# macOS: first active interface wins
|
||||
for iface in en0 en1; do
|
||||
LAN_IP="$(ipconfig getifaddr "${iface}" 2>/dev/null || true)"
|
||||
[[ -n "${LAN_IP}" ]] && break
|
||||
done
|
||||
elif command -v hostname >/dev/null 2>&1; then
|
||||
# Linux: hostname -I lists addresses; take the first
|
||||
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
fi
|
||||
echo ""
|
||||
echo "!! =============================================================="
|
||||
echo "!! WARNING: HOST=${HOST} — network-exposed mode."
|
||||
echo "!! This dev server brokers your configured API keys (OpenAI,"
|
||||
echo "!! OpenSky, AISStream, TomTom, FIRMS, LL2, Google) to ANYONE who can"
|
||||
echo "!! reach it on the network. Use only on networks you trust."
|
||||
echo "!! Consider the opt-in per-IP throttles GEV_RATELIMIT_OPENAI_PER_MIN"
|
||||
echo "!! and GEV_RATELIMIT_GOOGLE_PER_MIN (see .env.example) — and note"
|
||||
echo "!! they are NOT billing caps; set provider-side budget alerts too."
|
||||
if [[ -n "${LAN_IP}" ]]; then
|
||||
echo "!! LAN URL: http://${LAN_IP}:${PORT}/"
|
||||
else
|
||||
echo "!! LAN URL: http://<this-machine-ip>:${PORT}/"
|
||||
fi
|
||||
echo "!! =============================================================="
|
||||
echo ""
|
||||
echo "URL (this machine): http://localhost:${PORT}/"
|
||||
;;
|
||||
esac
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)."
|
||||
echo "If panels are still missing, run this once in browser console:"
|
||||
echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled"
|
||||
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "Startup map: Google Photorealistic 3D Tiles (direct)"
|
||||
elif [[ -n "${CESIUM_ION_TOKEN}" ]]; then
|
||||
echo "Startup map: Google Photorealistic 3D Tiles (Cesium ion)"
|
||||
else
|
||||
echo "Startup map: Esri World Imagery with keyless terrain (OpenStreetMap fallback)"
|
||||
fi
|
||||
[[ -n "${CESIUM_ION_TOKEN}" ]] && echo "Cesium ion token: configured — Google 3D, Bing, and world-terrain stacks available" || echo "Cesium ion token: not set"
|
||||
[[ -n "${TOMTOM_API_KEY}" ]] && echo "TomTom key (live traffic flow): configured" || echo "TomTom key (live traffic flow): not set — simulated traffic"
|
||||
[[ -n "${FIRMS_MAP_KEY}" ]] && echo "NASA FIRMS key (live fires): configured" || echo "NASA FIRMS key (live fires): not set — fires layer requires a key"
|
||||
[[ -n "${LL2_API_TOKEN}" ]] && echo "Launch Library 2 token: configured" || echo "Launch Library 2 token: not set — using public access"
|
||||
|
||||
# Build the dev server environment explicitly. A value that resolved to
|
||||
# nothing is left UNSET instead of being exported empty: Vite backfills its
|
||||
# own .env values only for variables it finds undefined, so an empty export
|
||||
# would shadow a key the user did configure in .env.
|
||||
DEV_ENV=()
|
||||
DEV_UNSET=()
|
||||
put_env() {
|
||||
DEV_ENV+=("$1=$2")
|
||||
}
|
||||
put_env_if_set() {
|
||||
if [[ -n "$2" ]]; then
|
||||
DEV_ENV+=("$1=$2")
|
||||
else
|
||||
# Leaving it out is not enough: the child inherits this shell's
|
||||
# environment, so an empty export made in the PARENT would pass straight
|
||||
# through and shadow .env just the same. Remove it from the child outright.
|
||||
DEV_UNSET+=(-u "$1")
|
||||
fi
|
||||
}
|
||||
|
||||
put_env_if_set GOOGLE_MAPS_API_KEY "${GOOGLE_MAPS_API_KEY}"
|
||||
put_env CCTV_AUSTIN_MAX_SOURCES "${CCTV_AUSTIN_MAX_SOURCES}"
|
||||
# Empty is the documented Caltrans kill switch, so this one is passed as-is.
|
||||
put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
|
||||
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
|
||||
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
|
||||
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
|
||||
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
|
||||
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
|
||||
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
|
||||
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
|
||||
put_env_if_set OPENSKY_CLIENT_ID "${OPENSKY_CLIENT_ID}"
|
||||
put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}"
|
||||
put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}"
|
||||
put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}"
|
||||
put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}"
|
||||
put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}"
|
||||
put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}"
|
||||
put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}"
|
||||
put_env_if_set FIRMS_MAP_KEY "${FIRMS_MAP_KEY}"
|
||||
put_env_if_set LL2_API_TOKEN "${LL2_API_TOKEN}"
|
||||
put_env GEV_LAUNCHER "dev-fresh"
|
||||
put_env GEV_KEY_SETUP_EXTERNAL_KEYS "${KEY_SETUP_EXTERNAL_KEYS_CSV}"
|
||||
|
||||
env ${DEV_UNSET[@]+"${DEV_UNSET[@]}"} "${DEV_ENV[@]}" "${DEV_COMMAND[@]}" --host "${HOST}" --port "${PORT}" --force
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN.
|
||||
HOST="${HOST:-localhost}"
|
||||
|
||||
GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN=""
|
||||
GOOGLE_MAPS_API_KEY_SOURCE=""
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
for acct in "api-key" "default" "key"; do
|
||||
GOOGLE_MAPS_API_KEY_KEYCHAIN="$(security find-generic-password -s "google-maps-api" -a "${acct}" -w 2>/dev/null || true)"
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="keychain:${acct}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${GOOGLE_MAPS_API_KEY_KEYCHAIN}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_KEYCHAIN}"
|
||||
elif [[ -n "${GOOGLE_MAPS_API_KEY_ENV}" ]]; then
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY_ENV}"
|
||||
GOOGLE_MAPS_API_KEY_SOURCE="env"
|
||||
else
|
||||
GOOGLE_MAPS_API_KEY=""
|
||||
fi
|
||||
if [[ -z "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||
echo "error: Google Maps API key missing."
|
||||
echo "set GOOGLE_MAPS_API_KEY in env, or add Keychain item: service=google-maps-api account=api-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_keychain_secret() {
|
||||
local service="$1"
|
||||
local account="$2"
|
||||
security find-generic-password -s "$service" -a "$account" -w 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_opensky_oauth_from_file() {
|
||||
local file_path="$1"
|
||||
if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "warning: node not found; cannot parse OPENSKY_CREDENTIALS_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
local parsed
|
||||
parsed="$(node -e "const fs=require('fs');const p=process.argv[1];try{const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();process.stdout.write(id+'\\t'+secret);}catch{process.exit(1)}" "${file_path}" 2>/dev/null || true)"
|
||||
if [[ "${parsed}" != *$'\t'* ]]; then
|
||||
return
|
||||
fi
|
||||
local parsed_client_id="${parsed%%$'\t'*}"
|
||||
local parsed_client_secret="${parsed#*$'\t'}"
|
||||
|
||||
if [[ -z "${OPENSKY_CLIENT_ID}" && -n "${parsed_client_id}" ]]; then
|
||||
OPENSKY_CLIENT_ID="${parsed_client_id}"
|
||||
fi
|
||||
if [[ -z "${OPENSKY_CLIENT_SECRET}" && -n "${parsed_client_secret}" ]]; then
|
||||
OPENSKY_CLIENT_SECRET="${parsed_client_secret}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_opensky_credentials() {
|
||||
OPENSKY_AUTH_MODE="$(printf '%s' "${OPENSKY_AUTH_MODE:-oauth}" | tr '[:upper:]' '[:lower:]')"
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE:-}"
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic|oauth|auto|anon) ;;
|
||||
*)
|
||||
echo "warning: invalid OPENSKY_AUTH_MODE='${OPENSKY_AUTH_MODE}', defaulting to 'oauth'"
|
||||
OPENSKY_AUTH_MODE="oauth"
|
||||
;;
|
||||
esac
|
||||
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID:-}"
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET:-}"
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME:-}"
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD:-}"
|
||||
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_USERNAME}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "username" "user" "login" "email" "default"; do
|
||||
OPENSKY_USERNAME="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_USERNAME}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "basic" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_PASSWORD}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "password" "pass" "token" "secret" "default"; do
|
||||
OPENSKY_PASSWORD="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
load_opensky_oauth_from_file "${OPENSKY_CREDENTIALS_FILE}"
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_id" "client-id" "client" "api-key"; do
|
||||
OPENSKY_CLIENT_ID="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [[ "${OPENSKY_AUTH_MODE}" == "oauth" || "${OPENSKY_AUTH_MODE}" == "auto" ]] && [[ -z "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
for svc in "opensky-network" "opensky"; do
|
||||
for acct in "client_secret" "client-secret" "secret"; do
|
||||
OPENSKY_CLIENT_SECRET="$(read_keychain_secret "${svc}" "${acct}")"
|
||||
if [[ -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
;;
|
||||
oauth)
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
anon)
|
||||
OPENSKY_CLIENT_ID=""
|
||||
OPENSKY_CLIENT_SECRET=""
|
||||
OPENSKY_USERNAME=""
|
||||
OPENSKY_PASSWORD=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_opensky_credentials
|
||||
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-48}"
|
||||
|
||||
echo "Starting God's Eye View dev server..."
|
||||
echo "URL: http://localhost:${PORT}/"
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE}"
|
||||
else
|
||||
echo "OpenSky credentials file: ${OPENSKY_CREDENTIALS_FILE} (missing)"
|
||||
fi
|
||||
fi
|
||||
case "${OPENSKY_AUTH_MODE}" in
|
||||
basic)
|
||||
if [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky basic auth: configured"
|
||||
else
|
||||
echo "OpenSky basic auth: missing credentials"
|
||||
fi
|
||||
;;
|
||||
oauth)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky OAuth: configured"
|
||||
else
|
||||
echo "OpenSky OAuth: missing client credentials"
|
||||
fi
|
||||
;;
|
||||
auto)
|
||||
if [[ -n "${OPENSKY_CLIENT_ID}" && -n "${OPENSKY_CLIENT_SECRET}" ]]; then
|
||||
echo "OpenSky auto auth: OAuth configured"
|
||||
elif [[ -n "${OPENSKY_USERNAME}" && -n "${OPENSKY_PASSWORD}" ]]; then
|
||||
echo "OpenSky auto auth: basic configured"
|
||||
else
|
||||
echo "OpenSky auto auth: no credentials found"
|
||||
fi
|
||||
;;
|
||||
anon)
|
||||
echo "OpenSky auth: disabled (anonymous mode)"
|
||||
;;
|
||||
esac
|
||||
|
||||
GOOGLE_MAPS_API_KEY="${GOOGLE_MAPS_API_KEY}" \
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES}" \
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES}" \
|
||||
OPENSKY_AUTH_MODE="${OPENSKY_AUTH_MODE}" \
|
||||
OPENSKY_CREDENTIALS_FILE="${OPENSKY_CREDENTIALS_FILE}" \
|
||||
OPENSKY_CLIENT_ID="${OPENSKY_CLIENT_ID}" \
|
||||
OPENSKY_CLIENT_SECRET="${OPENSKY_CLIENT_SECRET}" \
|
||||
OPENSKY_USERNAME="${OPENSKY_USERNAME}" \
|
||||
OPENSKY_PASSWORD="${OPENSKY_PASSWORD}" \
|
||||
npm run dev -- --host "${HOST}" --port "${PORT}"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Voice QA fixture
|
||||
|
||||
`full-globe-turn-on-radio.wav` is an owner-generated fake-microphone fixture for the credentialed AI-to-Radio acceptance test. Its embedded WAV metadata is the provenance record for the generator, model, voice, spoken text, usage terms, and audio settings; inspect it with `ffprobe -show_entries format_tags -of json scripts/fixtures/voice/full-globe-turn-on-radio.wav`.
|
||||
|
||||
- Provided and approved for repository QA use by the user on 2026-08-05.
|
||||
- SHA-256: `b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb`
|
||||
- Run: `node scripts/qa-voice-wav.mjs http://localhost:4189`
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceSpriteFocus,
|
||||
} from '../src/data/focusDeemphasis.js';
|
||||
import {
|
||||
applyAircraftBillboardTreatment,
|
||||
} from '../src/data/aircraftRecession.js';
|
||||
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('focus-allocation-check.mjs requires node --expose-gc');
|
||||
}
|
||||
|
||||
const CALLS = 100_000;
|
||||
const TRIALS = 9;
|
||||
const WARMUP_CALLS = 200_000;
|
||||
const PURE_CALL_BUDGET = 16;
|
||||
const PRODUCTION_SPRITE_TICK_BUDGET = 212;
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function bytesPerCall(fn) {
|
||||
for (let index = 0; index < WARMUP_CALLS; index += 1) fn();
|
||||
const samples = [];
|
||||
for (let trial = 0; trial < TRIALS; trial += 1) {
|
||||
global.gc();
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < CALLS; index += 1) checksum += fn();
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
// Keep the returned scalar observable so V8 cannot delete the calls.
|
||||
assert.ok(Number.isFinite(checksum));
|
||||
samples.push((after - before) / CALLS);
|
||||
}
|
||||
const rawMedian = median(samples);
|
||||
return { rawMedian, samples };
|
||||
}
|
||||
|
||||
function netOfLoopBaseline(observed, baseline) {
|
||||
// V8's callback/checksum loop itself retains roughly one boxed-number word
|
||||
// per call until the next collection. Subtract the identically measured
|
||||
// no-op loop so this gate reports allocation attributable to the treatment.
|
||||
const rawMedian = Math.max(0, observed.rawMedian - baseline.rawMedian);
|
||||
return {
|
||||
rawMedian,
|
||||
roundedMedian: Math.round(rawMedian),
|
||||
observedRawMedian: observed.rawMedian,
|
||||
baselineRawMedian: baseline.rawMedian,
|
||||
samples: observed.samples,
|
||||
};
|
||||
}
|
||||
|
||||
const target = {
|
||||
screenRect: { left: 0, top: 0, right: 10, bottom: 10 },
|
||||
paddingPx: 18,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
const sprite = {};
|
||||
const focusInput = {
|
||||
screenPosition: { x: 5, y: 5 },
|
||||
cameraDistance: 1200,
|
||||
nowMs: 1000,
|
||||
target,
|
||||
};
|
||||
|
||||
const baseColor = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const aircraftInput = {
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
baseAlpha: 1,
|
||||
baseColor,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 100,
|
||||
cameraHeightM: 1000,
|
||||
};
|
||||
|
||||
const baseline = bytesPerCall(() => focusInput.nowMs);
|
||||
const focus = netOfLoopBaseline(
|
||||
bytesPerCall(() => advanceSpriteFocus(sprite, focusInput).factor),
|
||||
baseline,
|
||||
);
|
||||
const aircraft = netOfLoopBaseline(
|
||||
bytesPerCall(() => applyAircraftBillboardTreatment(aircraftInput).alpha),
|
||||
baseline,
|
||||
);
|
||||
const productionSpriteTick = netOfLoopBaseline(
|
||||
bytesPerCall(() => {
|
||||
// Match the hot civilian/military sprite path: the caller constructs fresh
|
||||
// option literals, consumes the shared focus result immediately, and then
|
||||
// constructs the composed aircraft-treatment options for the same sprite.
|
||||
const focusResult = advanceSpriteFocus(sprite, {
|
||||
screenPosition: focusInput.screenPosition,
|
||||
cameraDistance: focusInput.cameraDistance,
|
||||
nowMs: focusInput.nowMs,
|
||||
target,
|
||||
});
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: aircraftInput.baseScale,
|
||||
baseAlpha: aircraftInput.baseAlpha,
|
||||
baseColor,
|
||||
focusFactor: focusResult.factor,
|
||||
cameraDistanceM: aircraftInput.cameraDistanceM,
|
||||
cameraHeightM: aircraftInput.cameraHeightM,
|
||||
});
|
||||
return focusResult.factor + treatment.alpha;
|
||||
}),
|
||||
baseline,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
focus.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`advanceSpriteFocus allocated ${focus.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
aircraft.roundedMedian <= PURE_CALL_BUDGET,
|
||||
`applyAircraftBillboardTreatment allocated ${aircraft.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
assert.ok(
|
||||
productionSpriteTick.roundedMedian <= PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
`production sprite tick allocated ${productionSpriteTick.rawMedian.toFixed(3)} B/call median`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
callsPerTrial: CALLS,
|
||||
trials: TRIALS,
|
||||
budgets: {
|
||||
pureCall: PURE_CALL_BUDGET,
|
||||
productionSpriteTick: PRODUCTION_SPRITE_TICK_BUDGET,
|
||||
},
|
||||
loopBaseline: baseline,
|
||||
advanceSpriteFocus: focus,
|
||||
applyAircraftBillboardTreatment: aircraft,
|
||||
productionSpriteTick,
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if ! command -v security >/dev/null 2>&1; then
|
||||
echo "error: macOS 'security' command not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "error: node is required to parse JSON credentials"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CREDENTIALS_FILE="${1:-${OPENSKY_CREDENTIALS_FILE:-}}"
|
||||
if [[ -z "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "usage: ./scripts/opensky-import-client.sh /path/to/credentials.json"
|
||||
echo "or set OPENSKY_CREDENTIALS_FILE and run ./scripts/opensky-import-client.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
|
||||
echo "error: credentials file not found: ${CREDENTIALS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PARSED="$(node -e "const fs=require('fs');const p=process.argv[1];const raw=JSON.parse(fs.readFileSync(p,'utf8'));const id=String(raw.clientId??raw.client_id??'').trim();const secret=String(raw.clientSecret??raw.client_secret??'').trim();if(!id||!secret){process.exit(2)};process.stdout.write(id+'\t'+secret);" "${CREDENTIALS_FILE}" 2>/dev/null || true)"
|
||||
if [[ "${PARSED}" != *$'\t'* ]]; then
|
||||
echo "error: could not parse clientId/clientSecret from ${CREDENTIALS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_ID="${PARSED%%$'\t'*}"
|
||||
CLIENT_SECRET="${PARSED#*$'\t'}"
|
||||
if [[ -z "${CLIENT_ID}" || -z "${CLIENT_SECRET}" ]]; then
|
||||
echo "error: credentials file is missing client id or client secret"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
security add-generic-password -U -s "opensky-network" -a "client_id" -w "${CLIENT_ID}" >/dev/null
|
||||
security add-generic-password -U -s "opensky-network" -a "client_secret" -w "${CLIENT_SECRET}" >/dev/null
|
||||
|
||||
echo "OpenSky OAuth client credentials stored in Keychain:"
|
||||
echo " service=opensky-network account=client_id"
|
||||
echo " service=opensky-network account=client_secret"
|
||||
@@ -0,0 +1,140 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseEnv } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_ENVIRONMENT_FILE = path.join(ROOT, 'pinokio', 'ENVIRONMENT');
|
||||
|
||||
export const PINOKIO_CONFIG_FIELDS = Object.freeze([
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
'CESIUM_ION_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'AISSTREAM_API_KEY',
|
||||
'FIRMS_MAP_KEY',
|
||||
'TOMTOM_API_KEY',
|
||||
'OPENSKY_CLIENT_ID',
|
||||
'OPENSKY_CLIENT_SECRET',
|
||||
'LL2_API_TOKEN',
|
||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
||||
'PINOKIO_SHARE_CLOUDFLARE',
|
||||
'PINOKIO_SHARE_LOCAL',
|
||||
'PINOKIO_SHARE_VAR',
|
||||
]);
|
||||
|
||||
const PINOKIO_DEFAULTS = Object.freeze({
|
||||
GEV_RATELIMIT_OPENAI_PER_MIN: '30',
|
||||
GEV_RATELIMIT_GOOGLE_PER_MIN: '120',
|
||||
PINOKIO_SHARE_CLOUDFLARE: 'false',
|
||||
PINOKIO_SHARE_LOCAL: 'false',
|
||||
PINOKIO_SHARE_VAR: '__gev_sharing_disabled__',
|
||||
});
|
||||
|
||||
const PINOKIO_SHARE_SENTINEL = '__gev_sharing_disabled__';
|
||||
const PINOKIO_SHARING_FIELDS = Object.freeze([
|
||||
'PINOKIO_SHARE_CLOUDFLARE',
|
||||
'PINOKIO_SHARE_LOCAL',
|
||||
'PINOKIO_SHARE_VAR',
|
||||
]);
|
||||
|
||||
function appendEnvironmentLine(source, line) {
|
||||
const prefix = source.length > 0 && !source.endsWith('\n') ? '\n' : '';
|
||||
return `${source}${prefix}${line}\n`;
|
||||
}
|
||||
|
||||
function detectEnvironmentEncoding(buffer) {
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf-16le';
|
||||
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) return 'utf-16be';
|
||||
|
||||
let evenNulls = 0;
|
||||
let oddNulls = 0;
|
||||
const sampleLength = Math.min(buffer.length, 512);
|
||||
for (let index = 0; index < sampleLength; index += 1) {
|
||||
if (buffer[index] !== 0) continue;
|
||||
if (index % 2 === 0) evenNulls += 1;
|
||||
else oddNulls += 1;
|
||||
}
|
||||
const minimumNulls = Math.max(2, Math.floor(sampleLength / 16));
|
||||
if (oddNulls >= minimumNulls && oddNulls > evenNulls * 2) return 'utf-16le';
|
||||
if (evenNulls >= minimumNulls && evenNulls > oddNulls * 2) return 'utf-16be';
|
||||
return 'utf-8';
|
||||
}
|
||||
|
||||
export function readEnvironmentSource(filepath) {
|
||||
if (!existsSync(filepath)) return '';
|
||||
const buffer = readFileSync(filepath);
|
||||
try {
|
||||
return new TextDecoder(detectEnvironmentEncoding(buffer), { fatal: true }).decode(buffer);
|
||||
} catch {
|
||||
throw new Error('Pinokio ENVIRONMENT could not be decoded as UTF-8 or UTF-16.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist only the non-secret controls Pinokio itself re-reads at local.set. */
|
||||
export function ensurePinokioSharingBoundary(filepath = DEFAULT_ENVIRONMENT_FILE) {
|
||||
const original = existsSync(filepath) ? readFileSync(filepath) : null;
|
||||
let source = readEnvironmentSource(filepath);
|
||||
try {
|
||||
if (source) parseEnv(source);
|
||||
} catch {
|
||||
throw new Error('Pinokio ENVIRONMENT could not be parsed.');
|
||||
}
|
||||
|
||||
// Pinokio re-reads this file after the child preflight. Remove every legacy,
|
||||
// blank, or duplicate control before appending one canonical safe block so
|
||||
// that its later global/app merge cannot diverge from the checked state.
|
||||
const sharingLine = new RegExp(
|
||||
`^[\\t ]*(?:${PINOKIO_SHARING_FIELDS.join('|')})[\\t ]*=.*(?:\\r?\\n|$)`,
|
||||
'gm',
|
||||
);
|
||||
source = source.replace(sharingLine, '');
|
||||
source = appendEnvironmentLine(source, [
|
||||
'PINOKIO_SHARE_CLOUDFLARE=false',
|
||||
'PINOKIO_SHARE_LOCAL=false',
|
||||
`PINOKIO_SHARE_VAR=${PINOKIO_SHARE_SENTINEL}`,
|
||||
].join('\n'));
|
||||
|
||||
let configured;
|
||||
try {
|
||||
configured = parseEnv(source);
|
||||
} catch {
|
||||
throw new Error('Pinokio ENVIRONMENT could not be parsed.');
|
||||
}
|
||||
|
||||
const encoded = Buffer.from(source, 'utf8');
|
||||
if (!original || !original.equals(encoded)) {
|
||||
writeFileSync(filepath, source, { mode: 0o600 });
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/** Read the app-scoped Pinokio configuration without exposing its values. */
|
||||
export function readPinokioEnvironment(filepath = DEFAULT_ENVIRONMENT_FILE) {
|
||||
if (!existsSync(filepath)) return {};
|
||||
try {
|
||||
return parseEnv(readEnvironmentSource(filepath));
|
||||
} catch {
|
||||
throw new Error('Pinokio ENVIRONMENT could not be parsed.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app-scoped Pinokio file authoritative over Pinokio-global values.
|
||||
* Pinokio removes blank entries before merging environments, so each child
|
||||
* must restore the raw app value before diagnosis or Vite configuration.
|
||||
*/
|
||||
export function applyPinokioEnvironment({
|
||||
environment = process.env,
|
||||
filepath = DEFAULT_ENVIRONMENT_FILE,
|
||||
} = {}) {
|
||||
const configured = ensurePinokioSharingBoundary(filepath);
|
||||
for (const field of PINOKIO_CONFIG_FIELDS) {
|
||||
environment[field] = String(configured[field] ?? PINOKIO_DEFAULTS[field] ?? '');
|
||||
}
|
||||
|
||||
// Sharing is unsupported on Pinokio 8.0.40. Never let a global passcode
|
||||
// enter the child even if the host's global Pinokio environment defines it.
|
||||
environment.PINOKIO_SHARE_PASSCODE = '';
|
||||
return configured;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
import { realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applyPinokioEnvironment } from './pinokio-environment.mjs';
|
||||
import { formatSetupReport, inspectSetup, npmProcessSpec } from './setup-doctor.mjs';
|
||||
|
||||
const MODULE_PATH = fileURLToPath(import.meta.url);
|
||||
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
|
||||
const READY_FILE = path.join(ROOT, 'pinokio', '.installed');
|
||||
|
||||
export function runChecked(command, args, { shell = false } = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: '1' },
|
||||
shell,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
|
||||
export function installPinokioDependencies() {
|
||||
applyPinokioEnvironment();
|
||||
rmSync(READY_FILE, { force: true });
|
||||
const npm = npmProcessSpec();
|
||||
runChecked(npm.command, ['ci'], { shell: npm.shell });
|
||||
|
||||
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
|
||||
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
|
||||
// Keychain items, so its install report must describe that exact runtime.
|
||||
const report = inspectSetup({
|
||||
includeKeychain: false,
|
||||
// The raw app ENVIRONMENT file was applied above. Even an empty field now
|
||||
// shadows Vite's dotenv ladder, so diagnosis must stop there instead of
|
||||
// claiming a dotenv-only value will reach the launched app.
|
||||
authoritativeEnvironment: true,
|
||||
});
|
||||
console.log(`\n${formatSetupReport(report, {
|
||||
readyMessage: 'Ready. Return to Pinokio and choose Start.',
|
||||
})}\n`);
|
||||
if (!report.ready) process.exit(1);
|
||||
|
||||
writeFileSync(READY_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
console.log('[Pinokio] Installation ready.');
|
||||
}
|
||||
|
||||
export function isDirectInvocation(
|
||||
invokedPath = process.argv[1],
|
||||
modulePath = MODULE_PATH,
|
||||
) {
|
||||
if (typeof invokedPath !== 'string' || invokedPath.length === 0) return false;
|
||||
if (typeof modulePath !== 'string' || modulePath.length === 0) return false;
|
||||
try {
|
||||
return realpathSync(path.resolve(invokedPath)) === realpathSync(path.resolve(modulePath));
|
||||
} catch {
|
||||
return path.resolve(invokedPath) === path.resolve(modulePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectInvocation()) {
|
||||
installPinokioDependencies();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export function isPinokioShareEnabled(value) {
|
||||
return /^(1|true)$/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function validatePinokioSharing(env = process.env) {
|
||||
const cloudflare = isPinokioShareEnabled(env.PINOKIO_SHARE_CLOUDFLARE);
|
||||
const local = isPinokioShareEnabled(env.PINOKIO_SHARE_LOCAL);
|
||||
const shareVariable = String(env.PINOKIO_SHARE_VAR || '').trim();
|
||||
if (cloudflare || local || shareVariable !== '__gev_sharing_disabled__') {
|
||||
throw new Error(
|
||||
'Pinokio sharing is unavailable because the current supported release can expose the app after child preflight '
|
||||
+ 'and logs successful tunnel-login passcodes. Keep PINOKIO_SHARE_CLOUDFLARE=false, '
|
||||
+ 'PINOKIO_SHARE_LOCAL=false, and PINOKIO_SHARE_VAR=__gev_sharing_disabled__.',
|
||||
);
|
||||
}
|
||||
return { cloudflare: false, local: false, protected: false };
|
||||
}
|
||||
|
||||
function run() {
|
||||
validatePinokioSharing();
|
||||
console.log('[Pinokio] Local-only launch.');
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
console.error(`[Pinokio] ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { rmSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
for (const target of ['node_modules', 'dist', 'pinokio/.installed']) {
|
||||
rmSync(path.join(ROOT, target), { recursive: true, force: true });
|
||||
}
|
||||
console.log('[Pinokio] Installation reset. Local credentials were preserved.');
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { applyPinokioEnvironment } from './pinokio-environment.mjs';
|
||||
import { isDirectInvocation } from './pinokio-install.mjs';
|
||||
import { validatePinokioSharing } from './pinokio-preflight.mjs';
|
||||
|
||||
const MODULE_PATH = fileURLToPath(import.meta.url);
|
||||
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
|
||||
|
||||
function launchPort(value) {
|
||||
const port = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Pinokio did not supply a valid local port.');
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function loadViteFromCanonicalRoot(
|
||||
root = ROOT,
|
||||
loadVite = () => import('vite'),
|
||||
) {
|
||||
process.chdir(realpathSync(path.resolve(root)));
|
||||
return loadVite();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
applyPinokioEnvironment();
|
||||
validatePinokioSharing();
|
||||
const port = launchPort(process.env.PORT);
|
||||
// Provider Settings routes credential writes to pinokio/ENVIRONMENT (never
|
||||
// .env) when the app runs under this launcher. The marker is set here — after
|
||||
// applyPinokioEnvironment, before Vite snapshots process.env — so the
|
||||
// dev-server endpoint knows which store this launch owns.
|
||||
process.env.GEV_LAUNCHER = 'pinokio';
|
||||
console.log('[Pinokio] Local-only launch.');
|
||||
|
||||
// Import Vite only after app-scoped blank fields have replaced any merged
|
||||
// Pinokio-global values. Vite snapshots process.env during configuration.
|
||||
const { createServer } = await loadViteFromCanonicalRoot();
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
await server.listen();
|
||||
server.printUrls();
|
||||
console.log(`[Pinokio] Ready at http://127.0.0.1:${port}/`);
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
|
||||
start().catch((error) => {
|
||||
console.error(`[Pinokio] Start refused: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { installPinokioDependencies, runChecked } from './pinokio-install.mjs';
|
||||
|
||||
runChecked('git', ['pull', '--ff-only']);
|
||||
installPinokioDependencies();
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
|
||||
*
|
||||
* Findings H10 + H11 (docs/pre-ship-audit-2026-07-01.md):
|
||||
* H10 — the Google/Cesium credit MUST stay visible in clean-view AND
|
||||
* recording modes (those are the modes used to record demos).
|
||||
* H11 — every data layer's required attribution must surface in the
|
||||
* expandable bottom-left "Data attribution" lightbox.
|
||||
*
|
||||
* This script drives the REAL app headless and proves:
|
||||
* (i) per-layer credits are registered in viewer.creditDisplay (H11),
|
||||
* and appear in the "Data attribution" lightbox when opened;
|
||||
* (ii) enabling datacenters + submarine cables keeps their credits present;
|
||||
* (iii) toggling clean-view keeps #cesium-credits visible (screenshot);
|
||||
* (iv) toggling recording-mode keeps #cesium-credits visible (screenshot).
|
||||
*
|
||||
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
function getOpt(flag, def) {
|
||||
const i = argv.indexOf(flag);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
||||
}
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
|
||||
try { return existsSync(candidate); } catch { return false; }
|
||||
}) || null;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function check(name, ok, detail) {
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (ok) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// Substrings that MUST be present across the registered per-layer credits.
|
||||
const REQUIRED_CREDIT_SUBSTRINGS = [
|
||||
'OpenStreetMap contributors', // ODbL — datacenters/dams/roads
|
||||
'adsb.lol', // ODbL — military traces
|
||||
'TeleGeography', // CC BY-NC-SA — cables
|
||||
'NASA FIRMS', // fires
|
||||
'CelesTrak', // satellites
|
||||
'U.S. Geological Survey', // earthquakes
|
||||
'OpenSky Network', // flights
|
||||
'AISStream', // vessels
|
||||
'City of Austin', // CCTV
|
||||
'Radio Browser', // internet-radio directory
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ summary: 'QA globe ready' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/google/nearby-places') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ places: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
request.continue();
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const failedResponses = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const sourceUrl = msg.location()?.url || '';
|
||||
consoleErrors.push(sourceUrl ? `${msg.text()} [${sourceUrl}]` : msg.text());
|
||||
}
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 500) {
|
||||
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n qa-attribution-b12 → ${APP_URL}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
// Wait for the app + viewer.creditDisplay to be live.
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// Give Cesium a few frames to render the on-screen credit line (logo + link).
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// ── (i) H11: static credits registered ────────────────────────────
|
||||
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
|
||||
const registeredHtml = await page.evaluate(() => {
|
||||
const cd = window.__godsEyeView.viewer.creditDisplay;
|
||||
const statics = cd._staticCredits || [];
|
||||
return statics.map((c) => c.html);
|
||||
});
|
||||
check(
|
||||
'creditDisplay has static per-layer credits',
|
||||
registeredHtml.length >= 10,
|
||||
`${registeredHtml.length} static credits registered`,
|
||||
);
|
||||
for (const sub of REQUIRED_CREDIT_SUBSTRINGS) {
|
||||
const found = registeredHtml.some((h) => h.includes(sub));
|
||||
check(`credit present: "${sub}"`, found, found ? '' : 'MISSING');
|
||||
}
|
||||
|
||||
// ── (i) H11: credits render in the "Data attribution" lightbox ─────
|
||||
console.log('\nH11 — "Data attribution" lightbox surfaces the credits');
|
||||
// Force a render frame so the credit display flushes, then open the lightbox.
|
||||
const lightboxState = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const cd = viewer.creditDisplay;
|
||||
cd.showLightbox();
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
const title = box?.querySelector('.cesium-credit-lightbox-title');
|
||||
const close = box?.querySelector('.cesium-credit-lightbox-close');
|
||||
const boxRect = box?.getBoundingClientRect();
|
||||
const listRect = list?.getBoundingClientRect();
|
||||
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
|
||||
const worldOverlay = document.getElementById('world-overlay-root');
|
||||
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
|
||||
const stackProbe = document.createElement('div');
|
||||
stackProbe.dataset.qaAttributionStackProbe = 'true';
|
||||
stackProbe.textContent = 'QA WORLD LABEL — STACK PROBE';
|
||||
Object.assign(stackProbe.style, {
|
||||
position: 'absolute',
|
||||
left: `${Math.max(8, (boxRect?.left || 0) - 90)}px`,
|
||||
top: `${Math.max(8, (boxRect?.top || 0) + 150)}px`,
|
||||
width: '180px',
|
||||
padding: '5px 8px',
|
||||
color: '#8ffcff',
|
||||
background: 'rgba(4, 18, 28, 0.94)',
|
||||
borderLeft: '3px solid #19d9e8',
|
||||
font: '11px monospace',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
worldOverlay?.appendChild(stackProbe);
|
||||
const stackProbeRect = stackProbe.getBoundingClientRect();
|
||||
const lastItem = list?.lastElementChild;
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
const lastRect = lastItem?.getBoundingClientRect();
|
||||
return {
|
||||
html: box?.innerHTML || '',
|
||||
itemCount: list?.children.length || 0,
|
||||
linkCount: list?.querySelectorAll('a[href]').length || 0,
|
||||
everyLinkHasDestination: [...(list?.querySelectorAll('a') || [])]
|
||||
.every((link) => Boolean(link.getAttribute('href'))),
|
||||
boxHeight: boxRect?.height || 0,
|
||||
viewportHeight: window.innerHeight,
|
||||
listClientHeight: list?.clientHeight || 0,
|
||||
listScrollHeight: list?.scrollHeight || 0,
|
||||
listClientWidth: list?.clientWidth || 0,
|
||||
listScrollWidth: list?.scrollWidth || 0,
|
||||
listOverflowY: list ? getComputedStyle(list).overflowY : '',
|
||||
titleVisible: Boolean(title?.getBoundingClientRect().height),
|
||||
closeVisible: Boolean(close?.getBoundingClientRect().height),
|
||||
overlayZIndex: Number(getComputedStyle(overlay).zIndex),
|
||||
worldOverlayZIndex: Number(getComputedStyle(worldOverlay).zIndex),
|
||||
stackProbeIntersectsModal: Boolean(boxRect
|
||||
&& stackProbeRect.left < boxRect.left
|
||||
&& stackProbeRect.right > boxRect.left
|
||||
&& stackProbeRect.top < boxRect.bottom
|
||||
&& stackProbeRect.bottom > boxRect.top),
|
||||
stackProbeExtendsOutsideModal: Boolean(boxRect && stackProbeRect.left < boxRect.left),
|
||||
lastItemReachable: Boolean(lastRect && listRect
|
||||
&& lastRect.bottom <= listRect.bottom + 1
|
||||
&& lastRect.top >= listRect.top - 1),
|
||||
};
|
||||
});
|
||||
const lightboxHtml = lightboxState.html;
|
||||
const lightboxHits = REQUIRED_CREDIT_SUBSTRINGS.filter((s) => lightboxHtml.includes(s));
|
||||
check(
|
||||
'lightbox renders the per-layer credits',
|
||||
lightboxHits.length >= REQUIRED_CREDIT_SUBSTRINGS.length - 1,
|
||||
`${lightboxHits.length}/${REQUIRED_CREDIT_SUBSTRINGS.length} required strings visible in lightbox`,
|
||||
);
|
||||
check(
|
||||
'desktop lightbox stays compact with a fixed title and close control',
|
||||
lightboxState.boxHeight > 0
|
||||
&& lightboxState.boxHeight <= lightboxState.viewportHeight * 0.71
|
||||
&& lightboxState.titleVisible
|
||||
&& lightboxState.closeVisible,
|
||||
`box=${Math.round(lightboxState.boxHeight)}px viewport=${lightboxState.viewportHeight}px`,
|
||||
);
|
||||
check(
|
||||
'complete credit list scrolls vertically without horizontal overflow',
|
||||
lightboxState.itemCount >= registeredHtml.length
|
||||
&& lightboxState.linkCount > 0
|
||||
&& lightboxState.everyLinkHasDestination
|
||||
&& lightboxState.listScrollHeight > lightboxState.listClientHeight
|
||||
&& /auto|scroll/.test(lightboxState.listOverflowY)
|
||||
&& lightboxState.listScrollWidth <= lightboxState.listClientWidth + 1
|
||||
&& lightboxState.lastItemReachable,
|
||||
`${lightboxState.itemCount} items, ${lightboxState.linkCount} links, list=${lightboxState.listClientHeight}/${lightboxState.listScrollHeight}px`,
|
||||
);
|
||||
check(
|
||||
'attribution modal stacks above shared world labels',
|
||||
Number.isFinite(lightboxState.overlayZIndex)
|
||||
&& Number.isFinite(lightboxState.worldOverlayZIndex)
|
||||
&& lightboxState.overlayZIndex > lightboxState.worldOverlayZIndex
|
||||
&& lightboxState.stackProbeIntersectsModal
|
||||
&& lightboxState.stackProbeExtendsOutsideModal,
|
||||
`modal z=${lightboxState.overlayZIndex}, world labels z=${lightboxState.worldOverlayZIndex}, overlap=${lightboxState.stackProbeIntersectsModal}`,
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-stacking-before-desktop.png') });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.creditDisplay.showLightbox();
|
||||
const list = document.querySelector('.cesium-credit-lightbox > ul');
|
||||
if (list) list.scrollTop = 0;
|
||||
});
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-desktop.png') });
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.creditDisplay.hideLightbox();
|
||||
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
|
||||
});
|
||||
|
||||
await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 });
|
||||
await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.resize();
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const mobileLightbox = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.creditDisplay.showLightbox();
|
||||
const box = document.querySelector('.cesium-credit-lightbox');
|
||||
const list = box?.querySelector(':scope > ul');
|
||||
const rect = box?.getBoundingClientRect();
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
const listRect = list?.getBoundingClientRect();
|
||||
const lastRect = list?.lastElementChild?.getBoundingClientRect();
|
||||
return {
|
||||
left: rect?.left || 0,
|
||||
right: rect?.right || 0,
|
||||
top: rect?.top || 0,
|
||||
bottom: rect?.bottom || 0,
|
||||
viewportWidth: innerWidth,
|
||||
viewportHeight: innerHeight,
|
||||
lastItemReachable: Boolean(lastRect && listRect
|
||||
&& lastRect.bottom <= listRect.bottom + 1),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'mobile lightbox remains full-screen edge-to-edge and reaches the final credit',
|
||||
Math.abs(mobileLightbox.left) <= 1
|
||||
&& Math.abs(mobileLightbox.right - mobileLightbox.viewportWidth) <= 1
|
||||
&& Math.abs(mobileLightbox.top) <= 1
|
||||
&& Math.abs(mobileLightbox.bottom - mobileLightbox.viewportHeight) <= 1
|
||||
&& mobileLightbox.lastItemReachable,
|
||||
`box=${Math.round(mobileLightbox.left)},${Math.round(mobileLightbox.top)}–${Math.round(mobileLightbox.right)},${Math.round(mobileLightbox.bottom)}`,
|
||||
);
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-mobile.png') });
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
|
||||
// ── (ii) enable datacenters + cables; credits still present ────────
|
||||
console.log('\nH11 — enabling datacenters + submarine cables');
|
||||
const layerIds = await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
return [...dm.layers.keys()];
|
||||
});
|
||||
// Find the datacenter + cable layer ids by fuzzy match on the id string.
|
||||
const dcId = layerIds.find((id) => /datacenter/i.test(id));
|
||||
const cableId = layerIds.find((id) => /cable|submarine|telegeo/i.test(id));
|
||||
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
|
||||
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
|
||||
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const afterEnableHtml = await page.evaluate(() =>
|
||||
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
|
||||
);
|
||||
check(
|
||||
'datacenter credit (OSM/ODbL) still present after enable',
|
||||
afterEnableHtml.some((h) => h.includes('OpenStreetMap contributors')),
|
||||
'',
|
||||
);
|
||||
check(
|
||||
'cable credit (TeleGeography) still present after enable',
|
||||
afterEnableHtml.some((h) => h.includes('TeleGeography')),
|
||||
'',
|
||||
);
|
||||
|
||||
// helper: is #cesium-credits visible (line rendered, not display:none)?
|
||||
const creditVisibility = async () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.getElementById('cesium-credits');
|
||||
if (!el) return { present: false };
|
||||
const cs = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
present: true,
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
text: (el.textContent || '').trim().slice(0, 120),
|
||||
hasLogo: !!el.querySelector('.cesium-credit-logoContainer, img'),
|
||||
};
|
||||
});
|
||||
|
||||
// ── (iii) clean-view: credit line STILL visible ───────────────────
|
||||
console.log('\nH10 — clean-view keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cleanVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
|
||||
check(
|
||||
'clean-view: #cesium-credits not display:none',
|
||||
cleanVis.present && cleanVis.display !== 'none' && cleanVis.visibility !== 'hidden',
|
||||
`display=${cleanVis.display} visibility=${cleanVis.visibility} w=${Math.round(cleanVis.width)} h=${Math.round(cleanVis.height)}`,
|
||||
);
|
||||
check(
|
||||
'clean-view: "Data attribution" link to per-layer popover still present',
|
||||
!!cleanVis.text && /Data attribution/i.test(cleanVis.text),
|
||||
`credit text = "${cleanVis.text}"`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('ui-clean-view'));
|
||||
|
||||
// ── (iv) recording-mode: credit line STILL visible ────────────────
|
||||
console.log('\nH10 — recording-mode keeps the credit line visible');
|
||||
await page.evaluate(() => document.body.classList.add('recording-mode'));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const recVis = await creditVisibility();
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
|
||||
check(
|
||||
'recording-mode: #cesium-credits not display:none',
|
||||
recVis.present && recVis.display !== 'none' && recVis.visibility !== 'hidden',
|
||||
`display=${recVis.display} visibility=${recVis.visibility} w=${Math.round(recVis.width)} h=${Math.round(recVis.height)}`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('recording-mode'));
|
||||
|
||||
// baseline (normal) screenshot for comparison
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
|
||||
|
||||
check(
|
||||
'no console errors during QA',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
check(
|
||||
'no HTTP 5xx responses during QA',
|
||||
failedResponses.length === 0,
|
||||
failedResponses.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n────────────────────────────────────────────────────────────');
|
||||
console.log(` RESULT: ${passed} passed, ${failed} failed`);
|
||||
console.log(` Screenshots: ${SHOT_DIR}`);
|
||||
console.log('────────────────────────────────────────────────────────────\n');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('qa-attribution-b12 crashed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-overlay — submarine-cable label path measurement + contract gate.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. MEASURE (both builds, `--legacy` on the pre-migration build): with the
|
||||
* cables layer ON at a fixed mid-Atlantic camera, capture
|
||||
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
|
||||
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
||||
* b. toggle-ON → first-labels-visible latency,
|
||||
* c. entity / native-label / host-entry counts,
|
||||
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||
* 2. GATE (unified build only): assert the migration contract —
|
||||
* zero native `LabelGraphics` on cable reference entities, host entries
|
||||
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||
* (no orphan host entries after disable).
|
||||
*
|
||||
* Usage: node scripts/qa-cables-overlay.mjs [--url http://localhost:4214] [--legacy]
|
||||
* Requires a running dev server. Headless; rAF throttling disabled so the
|
||||
* frame clock is honest (hidden-pane gotcha).
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
const legacy = argv.includes('--legacy');
|
||||
// --control: never enable the layer; measure the empty-scene orbit cost so the
|
||||
// cables layer's share of frame time is attributable.
|
||||
const control = argv.includes('--control');
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const results = [];
|
||||
function check(name, pass, detail) {
|
||||
results.push({ name, pass });
|
||||
const tag = pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${tag}] ${name}${detail !== undefined ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||
}
|
||||
function report(name, detail) {
|
||||
console.log(` [MEAS] ${name} — ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000)); // boot flyTo + deferred init
|
||||
|
||||
// Park mid-Atlantic (many cables + both coasts' landings in range) and
|
||||
// disable every layer so the cables layer is measured in isolation.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* measured via counts */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 6_000)); // tiles + fades settle
|
||||
|
||||
// ── b. toggle-ON → labels visible ─────────────────────────────────────
|
||||
if (!control) {
|
||||
const labelLatency = await page.evaluate(async (layerId, isLegacy) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const t0 = performance.now();
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
const deadline = t0 + 60_000;
|
||||
const labelsUp = () => {
|
||||
if (isLegacy) {
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
return (stats.referenceLabelCount || 0) > 0;
|
||||
}
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
return (diag.paintedBySource?.[layerId] || 0) > 0;
|
||||
};
|
||||
while (performance.now() < deadline) {
|
||||
if (labelsUp()) return { ms: Math.round(performance.now() - t0), timedOut: false };
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { ms: 60_000, timedOut: true };
|
||||
}, LAYER_ID, legacy);
|
||||
report('toggle-ON -> labels visible (ms)', labelLatency);
|
||||
check('labels became visible after enable', labelLatency.timedOut === false, labelLatency);
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
|
||||
}
|
||||
|
||||
// ── c. entity / label / host counts ───────────────────────────────────
|
||||
const counts = await page.evaluate((layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const out = {
|
||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
||||
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
|
||||
};
|
||||
for (let i = 0; i < v.dataSources.length; i++) {
|
||||
const ds = v.dataSources.get(i);
|
||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||
const list = ds.entities.values;
|
||||
out.dataSources[ds.name] = list.length;
|
||||
for (const e of list) {
|
||||
if (e.label !== undefined) out.nativeLabels++;
|
||||
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
|
||||
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
|
||||
out.dynamicPositionEntities++;
|
||||
}
|
||||
}
|
||||
if (/References/.test(ds.name)) out.referenceEntities = list.length;
|
||||
}
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
||||
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
||||
return out;
|
||||
}, LAYER_ID);
|
||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||
|
||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
||||
const frameCost = await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const scene = v.scene;
|
||||
const durations = [];
|
||||
const originalRender = scene.render;
|
||||
scene.render = function patchedRender(...args) {
|
||||
const started = performance.now();
|
||||
const result = originalRender.apply(this, args);
|
||||
durations.push(performance.now() - started);
|
||||
return result;
|
||||
};
|
||||
const t0 = performance.now();
|
||||
const tick = () => {
|
||||
// Slow orbit: every frame is a camera change, so Cesium renders every
|
||||
// rAF — the honest "user is interacting" cost of the layer.
|
||||
v.camera.rotateRight(0.0004);
|
||||
if (performance.now() - t0 < 10_000) requestAnimationFrame(tick);
|
||||
else {
|
||||
scene.render = originalRender;
|
||||
durations.sort((a, b) => a - b);
|
||||
const n = durations.length;
|
||||
const sum = durations.reduce((s, d) => s + d, 0);
|
||||
resolve({
|
||||
frames: n,
|
||||
meanMs: +(sum / Math.max(1, n)).toFixed(2),
|
||||
p50Ms: +(durations[Math.floor(n * 0.5)] || 0).toFixed(2),
|
||||
p95Ms: +(durations[Math.floor(n * 0.95)] || 0).toFixed(2),
|
||||
maxMs: +(durations[n - 1] || 0).toFixed(2),
|
||||
effectiveFps: +(n / 10).toFixed(1),
|
||||
});
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
report(control
|
||||
? 'scene.render cost, control (cables OFF), 10s orbit'
|
||||
: 'scene.render cost, cables ON, 10s orbit', frameCost);
|
||||
|
||||
// ── d. parked idle honesty (labels must not force continuous render) ──
|
||||
await new Promise((r) => setTimeout(r, 4_000)); // orbit stop + fades settle
|
||||
const idle = await page.evaluate(() => new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let renders = 0;
|
||||
const remove = scene.postRender.addEventListener(() => { renders += 1; });
|
||||
setTimeout(() => { remove(); resolve({ renders }); }, 5_000);
|
||||
}));
|
||||
report(control
|
||||
? 'parked idle, control (postRender fires / 5s)'
|
||||
: 'parked idle with cables ON (postRender fires / 5s)', idle);
|
||||
|
||||
if (!legacy && !control) {
|
||||
// ── unified-build contract gates ────────────────────────────────────
|
||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
||||
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
|
||||
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
||||
// The cables source never republishes an unchanged cohort (unit pin), and
|
||||
// since the 2026-08-18 host fix the occluder observer is scoped (body
|
||||
// childList filtered to inventory chrome; attribute observation
|
||||
// element-only) and the right-rail allocator writes-if-changed — so a
|
||||
// parked camera measures 0 postRender fires / 5 s with cables ON, equal
|
||||
// to the empty-scene control (pre-fix: ~56-61). The headroom below covers
|
||||
// an occasional genuine chrome transition (loading-chip flip + its 100 ms
|
||||
// occluder-refresh echo) landing inside the window; ticking churn (~10)
|
||||
// or the old observer leak (~56+) must fail.
|
||||
check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle);
|
||||
|
||||
// OFF must clear the host source (no orphan labels), ON must restore.
|
||||
const cycle = await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(layerId, false, { origin: 'user' });
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 1_200));
|
||||
const offDiag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
const offEntries = offDiag.entriesBySource?.[layerId] || 0;
|
||||
const offPainted = offDiag.paintedBySource?.[layerId] || 0;
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
const t0 = performance.now();
|
||||
let onPainted = 0;
|
||||
while (performance.now() - t0 < 20_000) {
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
onPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
if (onPainted > 0) break;
|
||||
gev.viewer.scene.requestRender?.();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return { offEntries, offPainted, onPainted };
|
||||
}, LAYER_ID);
|
||||
check('disable clears host entries (no orphans)', cycle.offEntries === 0 && cycle.offPainted === 0, cycle);
|
||||
check('re-enable restores painted labels', cycle.onPainted > 0, cycle);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
console.log(`\nqa-cables-overlay: ${passed}/${results.length} passed${legacy ? ' (legacy measurement mode)' : ''}${control ? ' (control mode)' : ''}`);
|
||||
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
|
||||
process.exit(passed === results.length ? 0 : 1);
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-render-probe — replicates the owner-side perf probe methodology
|
||||
* for the cables layer (relative numbers; SwiftShader when run headless):
|
||||
*
|
||||
* 1. PARKED: timed full `viewer.render()` loop (40 calls) with cables ON,
|
||||
* then OFF, at the fixed mid-Atlantic camera. Reports mean/max.
|
||||
* 2. PAN-STOP: 3 cycles of { setView to an offset, raise camera.moveEnd,
|
||||
* wait 650 ms, 20 timed `viewer.render()` calls }. Reports per-cycle
|
||||
* mean/max and the worst frame across cycles, cables ON vs OFF.
|
||||
*
|
||||
* Usage: node scripts/qa-cables-render-probe.mjs [--url http://localhost:4216]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4216';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: -40 * Math.PI / 180, latitude: 35 * Math.PI / 180, height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* probe */ } }
|
||||
}
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
|
||||
const stats = (values) => ({
|
||||
mean: +(values.reduce((s, v) => s + v, 0) / Math.max(1, values.length)).toFixed(2),
|
||||
max: +Math.max(...values).toFixed(2),
|
||||
});
|
||||
|
||||
const measure = async (label, enabled) => {
|
||||
await page.evaluate(async (id, on) => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.dataManager.setEnabled(id, on, { origin: 'user' });
|
||||
}, LAYER_ID, enabled);
|
||||
await new Promise((r) => setTimeout(r, enabled ? 6_000 : 3_000)); // load/teardown + settle
|
||||
|
||||
// 1. PARKED timed renders.
|
||||
const parked = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const durations = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const t0 = performance.now();
|
||||
v.render();
|
||||
durations.push(performance.now() - t0);
|
||||
}
|
||||
return durations;
|
||||
});
|
||||
|
||||
// 2. PAN-STOP cycles: move, raise moveEnd, wait 650 ms, 20 timed renders.
|
||||
const cycles = [];
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
const frames = await page.evaluate(async (step) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
v.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: (-40 + step * 3) * Math.PI / 180,
|
||||
latitude: 35 * Math.PI / 180,
|
||||
height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const durations = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const t0 = performance.now();
|
||||
v.render();
|
||||
durations.push(performance.now() - t0);
|
||||
}
|
||||
return durations;
|
||||
}, cycle + 1);
|
||||
cycles.push(stats(frames));
|
||||
}
|
||||
|
||||
const worst = Math.max(...cycles.map((c) => c.max));
|
||||
console.log(` [${label}] parked render: ${JSON.stringify(stats(parked))}`);
|
||||
console.log(` [${label}] pan-stop cycles: ${JSON.stringify(cycles)} worstFrame=${worst}`);
|
||||
return { parked: stats(parked), cycles, worst };
|
||||
};
|
||||
|
||||
const on = await measure('cables ON ', true);
|
||||
const off = await measure('cables OFF', false);
|
||||
console.log(`\nparked delta (ON-OFF mean): ${(on.parked.mean - off.parked.mean).toFixed(2)}ms`);
|
||||
console.log(`pan-stop worst: ON ${on.worst}ms vs OFF ${off.worst}ms`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-cables-shot — capture the cables layer at two fixed cameras for
|
||||
* before/after visual-identity comparison. Writes to gitignored qa-shots/.
|
||||
* Usage: node scripts/qa-cables-shot.mjs [--url http://localhost:4214] [--tag before]
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
const argv = process.argv;
|
||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||
const tag = argv.includes('--tag') ? argv[argv.indexOf('--tag') + 1] : 'shot';
|
||||
const LAYER_ID = 'telegeography-submarine-cables';
|
||||
mkdirSync(new URL('../qa-shots', import.meta.url), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
protocolTimeout: 300_000,
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--window-size=1440,900',
|
||||
'--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 860 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
|
||||
await new Promise((r) => setTimeout(r, 12_000));
|
||||
await page.evaluate(async (layerId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.viewer.camera.cancelFlight();
|
||||
for (const [id, entry] of gev.dataManager.layers) {
|
||||
if (entry.enabled && id !== layerId) {
|
||||
try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* shot only */ }
|
||||
}
|
||||
}
|
||||
await gev.dataManager.setEnabled(layerId, true, { origin: 'user' });
|
||||
}, LAYER_ID);
|
||||
|
||||
const views = [
|
||||
{ name: 'atlantic', lon: -40, lat: 35, height: 4_500_000 },
|
||||
{ name: 'ny-coast', lon: -73.5, lat: 40.4, height: 500_000 },
|
||||
];
|
||||
for (const view of views) {
|
||||
await page.evaluate((v) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ell = viewer.scene.globe.ellipsoid;
|
||||
viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: v.lon * Math.PI / 180, latitude: v.lat * Math.PI / 180, height: v.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, view);
|
||||
// Let the sweep + labels settle with frames flowing.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
v.scene.requestRender?.();
|
||||
if (++ticks < 240) requestAnimationFrame(tick); else resolve();
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
|
||||
await page.screenshot({ path });
|
||||
console.log(`saved ${path}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
|
||||
*
|
||||
* The owner's scenario: a cockpit sitting at JFK, where the geoid runs ~34 m
|
||||
* under the ellipsoid so the HUD honestly reads a NEGATIVE altitude. Every
|
||||
* label against the open sky came back with a full dark plate, because a
|
||||
* sub-ellipsoid camera fell into `skyBackdropFactor`'s degenerate guard.
|
||||
*
|
||||
* WHAT THIS ASSERTS, AND WHY IT IS NOT A UNIT TEST IN DISGUISE. An earlier
|
||||
* version of this harness called `skyBackdropFactor` directly and would have
|
||||
* passed with the painter stubbed out — the number was right and nothing was
|
||||
* drawn. Every assertion here now comes from the RENDERED path instead:
|
||||
*
|
||||
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
|
||||
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
|
||||
* counts once the sweep, the arbiter and the placement solve have all run.
|
||||
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
|
||||
* `paintDetectionCallout` fills the plate at `alpha * plateScale` and the
|
||||
* accent bar at `alpha`, in that order, then draws the callsign, so the
|
||||
* ratio of the two fills IS the plate scale and the callsign attributes it
|
||||
* to a named contact. Nothing is recomputed; this reads what was painted.
|
||||
* pixels — the plate rect comes from the same tap (`roundRect` immediately
|
||||
* precedes its `fill`), and the harness reads back mean alpha inside that
|
||||
* rect on `#world-overlay-canvas`, the real compositing surface.
|
||||
*
|
||||
* `--teeth` proves those assertions have teeth: it stubs the canvas paint calls
|
||||
* the callout painter uses to no-ops, so nothing reaches the framebuffer, and
|
||||
* the plate-scale and pixel checks must go RED. Run it after any change here.
|
||||
*
|
||||
* Three poses over one synthetic contact field:
|
||||
* level — cockpit on the ramp, nose on the horizon. This is the frame
|
||||
* the owner shot: approach traffic against open sky.
|
||||
* tilted-down — same cockpit, pitched down over the apron. Asserts that NO
|
||||
* ground contact is labelled, which is not this fix's doing:
|
||||
* from a camera under the ellipsoid Cesium's
|
||||
* `EllipsoidalOccluder` culls every point below the local
|
||||
* horizontal before detection ever sees it.
|
||||
* lifted — the same view a moment after rotate, at +40 m of ellipsoid
|
||||
* height. The apron contacts are back, and they keep their
|
||||
* FULL plates: the proof that sky selectivity did not quietly
|
||||
* become "no plates anywhere".
|
||||
*
|
||||
* Run (dev server already up):
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
|
||||
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
|
||||
*
|
||||
* Defaults to the real GPU (ANGLE/Metal). `--swiftshader` selects software GL,
|
||||
* which is deterministic but is NOT real-GPU evidence; the banner says which
|
||||
* backend actually ran and the run records it alongside the results.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TEETH = argv.includes('--teeth');
|
||||
const SWIFTSHADER = argv.includes('--swiftshader');
|
||||
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
|
||||
|
||||
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
|
||||
const SKY_PLATE_SCALE = 0.22;
|
||||
/** Painted alpha is a ratio of two floats; this is generous slack on it. */
|
||||
const PLATE_SCALE_TOLERANCE = 0.02;
|
||||
|
||||
// JFK. Ramp ~4 m MSL with a geoid height of about -34 m, so ellipsoid height
|
||||
// on the deck is ~-30 m and a widebody cockpit reads ALT -18 m: the owner's
|
||||
// exact HUD number, and a camera genuinely INSIDE the ellipsoid.
|
||||
const JFK_LON = -73.7781;
|
||||
const JFK_LAT = 40.6413;
|
||||
const COCKPIT_H = -18;
|
||||
const RAMP_H = -30;
|
||||
const M_PER_DEG_LAT = 110_574;
|
||||
|
||||
/**
|
||||
* Contacts are placed due north of the cockpit, which faces north, so the sky
|
||||
* set and the ground set share one frame and one screenshot can show both.
|
||||
*/
|
||||
const CONTACTS = [
|
||||
// --- against open sky: above the cockpit's eye level ---
|
||||
{ id: 'AAL1042', metric: '168KT', range: 6_000, height: 620, want: 'sky' },
|
||||
{ id: 'DAL883', metric: '154KT', range: 9_000, height: 940, want: 'sky' },
|
||||
{ id: 'JBU221', metric: '178KT', range: 12_000, height: 1_500, want: 'sky' },
|
||||
{ id: 'UAL508', metric: '145KT', range: 4_200, height: 380, want: 'sky' },
|
||||
{ id: 'FDX1190', metric: '190KT', range: 16_000, height: 2_400, want: 'sky' },
|
||||
// --- against the apron: below the cockpit floor ---
|
||||
{ id: 'N6172G', metric: '12KT', range: 150, height: RAMP_H, want: 'ground' },
|
||||
{ id: 'SWA2255', metric: '8KT', range: 260, height: RAMP_H, want: 'ground' },
|
||||
{ id: 'DAL2190', metric: '15KT', range: 400, height: RAMP_H - 4, want: 'ground' },
|
||||
{ id: 'AAL77', metric: '6KT', range: 620, height: RAMP_H - 6, want: 'ground' },
|
||||
];
|
||||
|
||||
const SKY_IDS = CONTACTS.filter((contact) => contact.want === 'sky').map((contact) => contact.id);
|
||||
const GROUND_IDS = CONTACTS.filter((contact) => contact.want === 'ground').map((contact) => contact.id);
|
||||
|
||||
const POSES = [
|
||||
{
|
||||
name: 'level',
|
||||
height: COCKPIT_H,
|
||||
pitchDeg: -1.5,
|
||||
// Approach traffic is above eye level, so it survives the occluder and must
|
||||
// paint at the feathered scale.
|
||||
expectPlated: SKY_IDS,
|
||||
expectScale: SKY_PLATE_SCALE,
|
||||
expectAbsent: GROUND_IDS,
|
||||
note: 'sub-ellipsoid cockpit, nose on the horizon',
|
||||
},
|
||||
{
|
||||
name: 'tilted-down',
|
||||
height: COCKPIT_H,
|
||||
pitchDeg: -14,
|
||||
expectPlated: [],
|
||||
expectScale: null,
|
||||
expectAbsent: GROUND_IDS,
|
||||
note: 'same cockpit pitched down; the occluder owns this emptiness',
|
||||
},
|
||||
{
|
||||
name: 'lifted',
|
||||
height: 40,
|
||||
pitchDeg: -14,
|
||||
expectPlated: GROUND_IDS,
|
||||
expectScale: 1,
|
||||
expectAbsent: [],
|
||||
note: 'above the ellipsoid after rotate; apron plates at full strength',
|
||||
},
|
||||
];
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
|
||||
// its software-GL behavior shifts across majors and has produced false
|
||||
// negatives in this repo's harnesses before.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const stubJson = (body) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/**
|
||||
* Installed before any app code runs. Records the callout painter's own canvas
|
||||
* calls; under `--teeth` it replaces them with no-ops instead, so the painter
|
||||
* stops reaching the framebuffer and every rendered assertion should fail.
|
||||
*/
|
||||
function installPaintTap(teeth) {
|
||||
const proto = CanvasRenderingContext2D.prototype;
|
||||
const origRoundRect = proto.roundRect;
|
||||
const origFill = proto.fill;
|
||||
const origFillText = proto.fillText;
|
||||
window.__PLATE_TAP = { on: false, events: [], lastRect: null, teeth };
|
||||
|
||||
if (teeth) {
|
||||
// Stub the painter: no record, no pixels. This is the negative control.
|
||||
proto.roundRect = function () {};
|
||||
proto.fill = function () {};
|
||||
proto.fillText = function () {};
|
||||
return;
|
||||
}
|
||||
|
||||
proto.roundRect = function (x, y, w, h, r) {
|
||||
if (window.__PLATE_TAP.on) window.__PLATE_TAP.lastRect = { x, y, w, h };
|
||||
return origRoundRect.call(this, x, y, w, h, r);
|
||||
};
|
||||
proto.fill = function (...args) {
|
||||
if (window.__PLATE_TAP.on) {
|
||||
window.__PLATE_TAP.events.push({
|
||||
kind: 'fill',
|
||||
alpha: this.globalAlpha,
|
||||
rect: window.__PLATE_TAP.lastRect || null,
|
||||
});
|
||||
}
|
||||
return origFill.apply(this, args);
|
||||
};
|
||||
proto.fillText = function (text, x, y, ...rest) {
|
||||
if (window.__PLATE_TAP.on) {
|
||||
window.__PLATE_TAP.events.push({ kind: 'text', alpha: this.globalAlpha, text: String(text) });
|
||||
}
|
||||
return origFillText.call(this, text, x, y, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend = SWIFTSHADER ? 'software GL (SwiftShader)' : 'real GPU (ANGLE/Metal)';
|
||||
console.log('\nCockpit Plate Backdrop QA — rendered path');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Tag : ${TAG}${TEETH ? ' (TEETH: painter stubbed, expect RED)' : ''}`);
|
||||
console.log(` Backend : ${backend}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(APP_URL);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(SWIFTSHADER
|
||||
? ['--use-gl=angle', '--use-angle=swiftshader']
|
||||
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const perPose = [];
|
||||
let renderer = 'unknown';
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
await page.evaluateOnNewDocument(installPaintTap, TEETH);
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== APP_ORIGIN) return void request.continue();
|
||||
// Silence the live feeds: the injected field is the only population.
|
||||
if (url.pathname === '/api/ais-live') {
|
||||
return void request.respond(stubJson({
|
||||
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
|
||||
newestPositionAt: null, lastMessageAt: null,
|
||||
}));
|
||||
}
|
||||
if (url.pathname === '/api/adsblol/mil') {
|
||||
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky') {
|
||||
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/opensky-track') {
|
||||
return void request.respond(stubJson({ path: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary') {
|
||||
return void request.respond(stubJson({ summary: 'Cockpit plate QA' }));
|
||||
}
|
||||
return void request.continue();
|
||||
});
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
|
||||
renderer = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2')
|
||||
|| document.createElement('canvas').getContext('webgl');
|
||||
if (!gl) return 'no webgl';
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return ext
|
||||
? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL))
|
||||
: String(gl.getParameter(gl.RENDERER));
|
||||
});
|
||||
const softwareRenderer = /swiftshader|llvmpipe|software/i.test(renderer);
|
||||
console.log(` GL : ${renderer}\n`);
|
||||
record(
|
||||
SWIFTSHADER
|
||||
? 'running on software GL as requested (structural checks, not GPU evidence)'
|
||||
: 'running on the real GPU, so the screenshots are real-GPU evidence',
|
||||
SWIFTSHADER ? softwareRenderer : !softwareRenderer,
|
||||
renderer,
|
||||
);
|
||||
|
||||
// Inject the field. The layers keep their real pipelines; only the source
|
||||
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
|
||||
sourceId: contact.id.toLowerCase(),
|
||||
id: contact.id,
|
||||
metric: contact.metric,
|
||||
position: Cartesian3.fromDegrees(
|
||||
payload.lon,
|
||||
payload.lat + contact.range / payload.mPerDegLat,
|
||||
contact.height,
|
||||
),
|
||||
type: 'AIR',
|
||||
tier: 'civil',
|
||||
}));
|
||||
for (const layerId of ['flights', 'military', 'satellites']) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (!entry?.module) continue;
|
||||
entry.module.getDetectableObjects = () => (
|
||||
layerId === 'flights' ? window.__PLATE_QA_FIELD : []
|
||||
);
|
||||
}
|
||||
styleManager.setDetection({ enabled: true, densityPct: 100, allocationStrategy: 'elastic' });
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, mPerDegLat: M_PER_DEG_LAT, contacts: CONTACTS });
|
||||
|
||||
for (const pose of POSES) {
|
||||
console.log(`\n — pose "${pose.name}" (${pose.note})`);
|
||||
await page.evaluate((payload) => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
|
||||
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
|
||||
});
|
||||
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
|
||||
|
||||
// Let the tiles settle and the label solve run before the tap window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
await page.evaluate(() => {
|
||||
window.__PLATE_TAP.events = [];
|
||||
window.__PLATE_TAP.on = true;
|
||||
// Drive the frames this measurement needs, instead of assuming something
|
||||
// else is holding the render loop open.
|
||||
//
|
||||
// This harness enables no data layer and never enters real Cockpit (which
|
||||
// takes its own hold), so nothing in the scene animates. It used to get
|
||||
// its frames for free from detection's continuous-render hold — and that
|
||||
// hold was itself the bug: it pinned every idle tab at 60 fps, which is
|
||||
// why it was removed (see src/data/detectionRenderDemand.js). The scene is
|
||||
// now correctly idle during the tap window, so a measurement of PAINT
|
||||
// OUTPUT has to ask for its own renders. Production is unaffected: real
|
||||
// Cockpit holds, and a parked map keeps showing the last correct frame
|
||||
// precisely because nothing changed.
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const pump = () => {
|
||||
if (!window.__PLATE_TAP.on) return;
|
||||
scene.requestRender();
|
||||
requestAnimationFrame(pump);
|
||||
};
|
||||
pump();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
const observed = await page.evaluate((wantedIds) => {
|
||||
window.__PLATE_TAP.on = false;
|
||||
const events = window.__PLATE_TAP.events;
|
||||
const wanted = new Set(wantedIds);
|
||||
// paintDetectionCallout paints, in order: the plate (alpha*plateScale),
|
||||
// the accent bar (alpha), the leader, then the callsign. So the two
|
||||
// fills before a callsign are that callout's plate and accent, and
|
||||
// their ratio is the plate scale that was actually painted.
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
if (event.kind !== 'text') continue;
|
||||
const id = event.text.trim();
|
||||
if (!wanted.has(id)) continue;
|
||||
const fills = [];
|
||||
for (let j = i - 1; j >= 0 && fills.length < 2; j--) {
|
||||
if (events[j].kind === 'fill') fills.unshift(events[j]);
|
||||
}
|
||||
if (fills.length < 2) continue;
|
||||
const [plate, accent] = fills;
|
||||
if (!(accent.alpha > 0)) continue;
|
||||
byId.set(id, {
|
||||
id,
|
||||
plateAlpha: plate.alpha,
|
||||
accentAlpha: accent.alpha,
|
||||
plateScale: plate.alpha / accent.alpha,
|
||||
rect: plate.rect,
|
||||
});
|
||||
}
|
||||
|
||||
// Pixel readback from the real compositing surface, over the plate rect
|
||||
// the painter itself just used.
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const ctx = canvas?.getContext('2d', { willReadFrequently: true });
|
||||
const dpr = canvas && canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
|
||||
for (const entry of byId.values()) {
|
||||
entry.meanPixelAlpha = null;
|
||||
if (!ctx || !entry.rect || !(entry.rect.w > 0) || !(entry.rect.h > 0)) continue;
|
||||
const x = Math.max(0, Math.round(entry.rect.x * dpr));
|
||||
const y = Math.max(0, Math.round(entry.rect.y * dpr));
|
||||
const w = Math.min(canvas.width - x, Math.round(entry.rect.w * dpr));
|
||||
const h = Math.min(canvas.height - y, Math.round(entry.rect.h * dpr));
|
||||
if (!(w > 0) || !(h > 0)) continue;
|
||||
const data = ctx.getImageData(x, y, w, h).data;
|
||||
let total = 0;
|
||||
for (let p = 3; p < data.length; p += 4) total += data[p];
|
||||
entry.meanPixelAlpha = total / (data.length / 4) / 255;
|
||||
}
|
||||
|
||||
const diagnostics = window.__godsEyeView.styleManager.getDetectionDiagnostics();
|
||||
return {
|
||||
plated: [...byId.values()],
|
||||
visibleCount: diagnostics?.visibleCount ?? null,
|
||||
labeledKeys: diagnostics?.labeledKeys ?? null,
|
||||
profile: diagnostics?.profile ?? null,
|
||||
fillEvents: events.filter((event) => event.kind === 'fill').length,
|
||||
};
|
||||
}, [...pose.expectPlated, ...pose.expectAbsent]);
|
||||
|
||||
const labelledIds = (observed.labeledKeys || [])
|
||||
.filter((key) => key.startsWith('flights:'))
|
||||
.map((key) => key.slice('flights:'.length).toUpperCase());
|
||||
console.log(
|
||||
` diagnostics: profile=${observed.profile} visibleCount=${observed.visibleCount}`
|
||||
+ ` labelled=[${labelledIds.join(' ')}] fills=${observed.fillEvents}`,
|
||||
);
|
||||
for (const entry of observed.plated) {
|
||||
console.log(
|
||||
` ${entry.id.padEnd(8)} plateScale=${entry.plateScale.toFixed(4)}`
|
||||
+ ` (plate ${entry.plateAlpha.toFixed(4)} / accent ${entry.accentAlpha.toFixed(4)})`
|
||||
+ ` meanPixelAlpha=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. The overlay actually rendered something this pose.
|
||||
record(
|
||||
`${pose.name}: the overlay reports live rendered contacts`,
|
||||
Number.isFinite(observed.visibleCount) && observed.visibleCount > 0,
|
||||
`visibleCount=${observed.visibleCount}`,
|
||||
);
|
||||
// 2. The contacts we expect to be labelled are labelled, by id.
|
||||
const missingLabels = pose.expectPlated.filter((id) => !labelledIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: every expected contact reaches the label solve`,
|
||||
missingLabels.length === 0,
|
||||
missingLabels.length
|
||||
? `missing ${missingLabels.join(',')}`
|
||||
: `${pose.expectPlated.length} labelled`,
|
||||
);
|
||||
// 3. The contacts that must NOT be labelled are absent.
|
||||
const unexpected = pose.expectAbsent.filter((id) => labelledIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: below-eye-level contacts stay out of the solve`,
|
||||
unexpected.length === 0,
|
||||
unexpected.length
|
||||
? `unexpectedly labelled ${unexpected.join(',')}`
|
||||
: 'none labelled, as expected',
|
||||
);
|
||||
// 4/5/6. Painted plate scale and pixels, per named contact.
|
||||
if (pose.expectPlated.length > 0) {
|
||||
const platedIds = observed.plated.map((entry) => entry.id);
|
||||
const missingPaint = pose.expectPlated.filter((id) => !platedIds.includes(id));
|
||||
record(
|
||||
`${pose.name}: the callout painter ran for every expected contact`,
|
||||
missingPaint.length === 0,
|
||||
missingPaint.length
|
||||
? `no paint recorded for ${missingPaint.join(',')}`
|
||||
: `${platedIds.length} painted`,
|
||||
);
|
||||
const offScale = observed.plated.filter(
|
||||
(entry) => Math.abs(entry.plateScale - pose.expectScale) > PLATE_SCALE_TOLERANCE,
|
||||
);
|
||||
record(
|
||||
`${pose.name}: painted plate scale is ${pose.expectScale}`,
|
||||
observed.plated.length > 0 && offScale.length === 0,
|
||||
offScale.length
|
||||
? offScale.map((entry) => `${entry.id}=${entry.plateScale.toFixed(3)}`).join(' ')
|
||||
: observed.plated.map((entry) => entry.plateScale.toFixed(3)).join(' '),
|
||||
);
|
||||
const inked = observed.plated.filter((entry) => (entry.meanPixelAlpha ?? 0) > 0.01);
|
||||
record(
|
||||
`${pose.name}: the plate rect carries real pixels on the overlay canvas`,
|
||||
observed.plated.length > 0 && inked.length === observed.plated.length,
|
||||
observed.plated
|
||||
.map((entry) => `${entry.id}=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(3)}`)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
perPose.push({ pose: pose.name, ...observed, labelledIds });
|
||||
const shot = path.join(SHOT_DIR, `${TAG}-${pose.name}.png`);
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` shot: ${shot}`);
|
||||
}
|
||||
|
||||
// Cross-pose pixel comparison: the same theme, the same canvas, the same
|
||||
// painter — only the backdrop differs, so sky plates must be lighter.
|
||||
const skyInk = perPose.find((entry) => entry.pose === 'level')?.plated || [];
|
||||
const groundInk = perPose.find((entry) => entry.pose === 'lifted')?.plated || [];
|
||||
const meanOf = (rows) => (rows.length
|
||||
? rows.reduce((sum, entry) => sum + (entry.meanPixelAlpha ?? 0), 0) / rows.length
|
||||
: null);
|
||||
const skyMean = meanOf(skyInk);
|
||||
const groundMean = meanOf(groundInk);
|
||||
record(
|
||||
'sky-backed plates are measurably lighter on the canvas than ground-backed ones',
|
||||
Number.isFinite(skyMean) && Number.isFinite(groundMean) && skyMean < groundMean,
|
||||
`sky mean alpha ${skyMean === null ? 'n/a' : skyMean.toFixed(4)}`
|
||||
+ ` vs ground ${groundMean === null ? 'n/a' : groundMean.toFixed(4)}`,
|
||||
);
|
||||
|
||||
record(
|
||||
'no page errors while the overlay ran at ground level',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(SHOT_DIR, `${TAG}-rendered.json`),
|
||||
`${JSON.stringify({
|
||||
tag: TAG,
|
||||
teeth: TEETH,
|
||||
backend,
|
||||
renderer,
|
||||
cockpitHeight: COCKPIT_H,
|
||||
poses: perPose,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const failed = results.filter((entry) => !entry.ok);
|
||||
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
|
||||
if (TEETH) {
|
||||
// Under --teeth the run is EXPECTED to fail; a green run would mean the
|
||||
// assertions do not depend on the painter and the harness is decorative.
|
||||
const ok = failed.length > 0;
|
||||
console.log(ok
|
||||
? ` \x1b[32mTEETH OK\x1b[0m — ${failed.length} check(s) went red with the painter stubbed:\n ${failed.map((entry) => entry.name).join('\n ')}\n`
|
||||
: ' \x1b[31mTEETH FAILED\x1b[0m — every check passed with the painter stubbed; the harness proves nothing.\n');
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-enrich-ambient.mjs — headless proof for AMBIENT fleet type enrichment
|
||||
* (fleet-enrichment widening of skylight Task 14).
|
||||
*
|
||||
* Field data (2026-07-02): OpenSky's live category field is 0/"no info" for
|
||||
* ~94% of planes, so ambient classification defaulted nearly the whole fleet
|
||||
* to the airliner silhouette. This harness reproduces exactly that case —
|
||||
* 12 synthetic planes, ALL category 0 — and proves that the per-poll
|
||||
* on-screen enrichment sweep (`_sweepAmbientEnrichment` in flights.js) gives
|
||||
* them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while
|
||||
* honoring the politeness bounds.
|
||||
*
|
||||
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
|
||||
* - /api/opensky → 12 straight-flying planes, category 0
|
||||
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
|
||||
* F16, GLID, B77W, A320, B738, PC12, R44) + one
|
||||
* found:false miss; responses are HELD until the
|
||||
* harness releases them (deterministic baseline),
|
||||
* then served with 400 ms latency; every request
|
||||
* start/inflight count is logged for the bounds
|
||||
* assertions.
|
||||
*
|
||||
* Machine assertions:
|
||||
* E1 ingest : all 12 synthetic planes in the flights layer
|
||||
* E2 baseline : BEFORE any enrichment answer, all 12 billboards carry the
|
||||
* default airliner glyph at scale 1 (the real-world problem)
|
||||
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
||||
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
||||
* enrichment (expected: 8)
|
||||
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
|
||||
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
||||
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
||||
* drip), total ≤300 (budget ceiling)
|
||||
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
||||
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
||||
* stop being requested once the bucket is empty (shrunk via
|
||||
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
|
||||
* be waited out headlessly)
|
||||
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
||||
* for the still-unrequested planes (the 2026-07-03 field bug:
|
||||
* the old one-shot session cap never refilled, so hours-long
|
||||
* sessions showed airliner monoculture in every new region)
|
||||
* E12 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/enrich-ambient/, untracked): before/after top-down
|
||||
* spreads of the row.
|
||||
*
|
||||
* Run: node scripts/qa-enrich-ambient.mjs --url http://localhost:4304
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-sprites-b5.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
|
||||
|
||||
// Client-side bounds under test (flights.js constants):
|
||||
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
||||
const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
|
||||
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
||||
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
||||
|
||||
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
|
||||
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
|
||||
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
||||
// land mid-phase); E11 then shortens it in-page so refill windows have
|
||||
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
|
||||
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
|
||||
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
|
||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic fleet: one east-west row near Austin, ALL category 0 ("no info" —
|
||||
// the 94% real-world case). adsbdb type answers cover 8 distinct classes;
|
||||
// aa000c answers found:false (negative-cache case → stays airliner).
|
||||
// ---------------------------------------------------------------------------
|
||||
const ROW_LAT = 30.30;
|
||||
const ROW_LON0 = -97.82;
|
||||
const ROW_STEP_DEG = 0.012; // ~1.15 km — 12 planes span ~12.7 km, fits a 20 km-up frame
|
||||
|
||||
/** hex → adsbdb icao_type (null = adsbdb miss / negative cache). */
|
||||
const TYPES = {
|
||||
aa0001: 'C172', // light
|
||||
aa0002: 'B744', // quadjet
|
||||
aa0003: 'DH8D', // turboprop
|
||||
aa0004: 'H60', // helicopter
|
||||
aa0005: 'F16', // fastjet
|
||||
aa0006: 'GLID', // glider
|
||||
aa0007: 'B77W', // widebody
|
||||
aa0008: 'A320', // airliner
|
||||
aa0009: 'B738', // airliner (known code outside the special sets)
|
||||
aa000a: 'PC12', // turboprop
|
||||
aa000b: 'R44', // helicopter
|
||||
aa000c: null, // adsbdb 404 → found:false → stays airliner, never re-asked
|
||||
};
|
||||
|
||||
const SPEC = {
|
||||
timeOffsetSec: 0,
|
||||
responseDelayMs: 400, // adsbdb latency so concurrency is actually observable
|
||||
budgetQa: BUDGET_QA,
|
||||
types: TYPES,
|
||||
planes: Object.keys(TYPES).map((hex, i) => ({
|
||||
icao: hex,
|
||||
callsign: `AMB${String(i + 1).padStart(3, '0')}`,
|
||||
lon0: ROW_LON0 + i * ROW_STEP_DEG,
|
||||
lat0: ROW_LAT,
|
||||
courseDeg: 0,
|
||||
speedMps: 70,
|
||||
altM: 2400 + i * 40,
|
||||
})),
|
||||
};
|
||||
|
||||
// Node-side expectations (same classifier inputs the layer sees).
|
||||
const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
|
||||
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
||||
]));
|
||||
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log(`\nAmbient fleet type-enrichment QA (fleet-enrichment widening)`);
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
console.log(` Expected classes: ${[...expected.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
|
||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
window.__ENR = spec;
|
||||
window.__ENR.epochMs = Date.now();
|
||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||
// the shrunk knobs are read from the very first sweep (see BUDGET_QA).
|
||||
window.__GEV_ENRICH_AMBIENT_QA = { ...spec.budgetQa };
|
||||
// Request log: starts (hex + performance.now), live/max concurrency,
|
||||
// hold gate (responses park until the harness releases them so the
|
||||
// pre-enrichment baseline is deterministic).
|
||||
window.__ENRICH_LOG = { starts: [], inflight: 0, maxInflight: 0, held: true, holds: [] };
|
||||
window.__ENRICH_RELEASE = () => {
|
||||
window.__ENRICH_LOG.held = false;
|
||||
window.__ENRICH_LOG.holds.splice(0).forEach((fn) => fn());
|
||||
};
|
||||
|
||||
// Straight line from (lon0, lat0) along courseDeg at speedMps.
|
||||
window.__ENR.stateAt = (p, tSec) => {
|
||||
const cRad = (p.courseDeg * Math.PI) / 180;
|
||||
const dist = p.speedMps * tSec;
|
||||
const east = dist * Math.sin(cRad);
|
||||
const north = dist * Math.cos(cRad);
|
||||
const lat = p.lat0 + north / 111320;
|
||||
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
|
||||
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
|
||||
};
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
const S = window.__ENR;
|
||||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||||
const tRel = nowSec - S.epochMs / 1000;
|
||||
|
||||
if (url.includes('/api/adsbdb/type/')) {
|
||||
const hex = url.split('/').pop().split('?')[0].toLowerCase();
|
||||
const L = window.__ENRICH_LOG;
|
||||
L.starts.push({ hex, t: performance.now() });
|
||||
L.inflight += 1;
|
||||
L.maxInflight = Math.max(L.maxInflight, L.inflight);
|
||||
const tc = S.types[hex];
|
||||
const body = tc
|
||||
? { found: true, typeCode: tc, typeName: `Synthetic ${tc}`, registration: `N${hex.slice(-3).toUpperCase()}` }
|
||||
: { found: false };
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => setTimeout(() => { L.inflight -= 1; resolve(jsonResponse(body)); }, S.responseDelayMs);
|
||||
if (L.held) L.holds.push(finish); else finish();
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||||
if (url.includes('/api/adsblol/trace')) {
|
||||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||||
}
|
||||
if (url.includes('/api/adsblol/mil')) {
|
||||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac: [] }));
|
||||
}
|
||||
if (url.includes('/api/opensky')) {
|
||||
const states = S.planes.map((f) => {
|
||||
const s = S.stateAt(f, tRel);
|
||||
return [
|
||||
f.icao, f.callsign, 'Synthetica',
|
||||
Math.floor(nowSec), Math.floor(nowSec),
|
||||
s.lon, s.lat, f.altM,
|
||||
false, // on_ground
|
||||
s.speedMps, s.course,
|
||||
0, null, null, null, false, 0,
|
||||
0, // state[17] — category 0 = "no info" (the 94% case)
|
||||
];
|
||||
});
|
||||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SPEC);
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
||||
await page.evaluate(() => {
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Frame the row FIRST (top-down, all 12 on-screen), then prime ------
|
||||
// The ambient sweep runs inside update(), so the camera must already be
|
||||
// framing the fleet when the layer polls — otherwise the frustum test
|
||||
// (correctly) skips off-screen planes.
|
||||
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
|
||||
|
||||
console.log('Priming straight-flight history through the render delay (30 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__ENR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__ENR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
}
|
||||
window.__ENR.timeOffsetSec = 0;
|
||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
||||
});
|
||||
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
||||
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
||||
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
|
||||
if (primed.count === 0) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// E2 — deterministic pre-enrichment baseline (responses still held)
|
||||
// ========================================================================
|
||||
console.log('\nE2 — pre-enrichment baseline (adsbdb responses held)');
|
||||
const before = await page.evaluate(() => window.__collectBillboards());
|
||||
const beforeById = new Map(before.map((b) => [b.id, b]));
|
||||
const baselineBad = [];
|
||||
for (const hex of Object.keys(TYPES)) {
|
||||
const bb = beforeById.get(hex);
|
||||
if (!bb) baselineBad.push(`${hex}:missing`);
|
||||
else if (bb.image !== AIRLINER_ICON) baselineBad.push(`${hex}:not-default-glyph`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D.airliner || 1)) > 1e-9) baselineBad.push(`${hex}:scale=${bb.scale}`);
|
||||
}
|
||||
record('E2 baseline: all 12 category-0 planes default to the airliner glyph',
|
||||
baselineBad.length === 0,
|
||||
baselineBad.length ? baselineBad.join(' ') : 'all 12 identical airliner silhouettes (the field-data problem)');
|
||||
await sleep(1200); // let a fleet tick land rotations before the "before" shot
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-before-monoculture.png') });
|
||||
console.log(' saved enrich-before-monoculture.png (12 identical airliner glyphs)');
|
||||
|
||||
// ========================================================================
|
||||
// Release adsbdb + wait for the drip to finish, then for glyph swaps
|
||||
// ========================================================================
|
||||
console.log('\nReleasing adsbdb responses; waiting for the bounded drip to drain...');
|
||||
await page.evaluate(() => window.__ENRICH_RELEASE());
|
||||
const drained = await page.waitForFunction(
|
||||
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 30000, polling: 100 }, SPEC.planes.length
|
||||
).then(() => true).catch(() => false);
|
||||
if (!drained) console.log(' \x1b[33mdrip did not fully drain within 30 s\x1b[0m');
|
||||
// Give the enrichment callbacks a beat to apply billboard swaps.
|
||||
await page.waitForFunction(() => {
|
||||
const images = new Set(window.__collectBillboards().map((b) => b.image));
|
||||
return images.size >= 5;
|
||||
}, { timeout: 10000, polling: 200 }).catch(() => { /* asserted below */ });
|
||||
|
||||
// ========================================================================
|
||||
// E3–E6 — requests + ambient glyph/scale swaps (nothing tracked, 3D off)
|
||||
// ========================================================================
|
||||
console.log('\nE3–E6 — ambient enrichment results');
|
||||
const log1 = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.map((s) => ({ hex: s.hex, t: s.t })),
|
||||
maxInflight: window.__ENRICH_LOG.maxInflight,
|
||||
}));
|
||||
const startHexes = log1.starts.map((s) => s.hex);
|
||||
const uniqueHexes = new Set(startHexes);
|
||||
const allRequested = Object.keys(TYPES).every((h) => uniqueHexes.has(h));
|
||||
record('E3 requests: sweep requested all 12 hexes, each exactly once',
|
||||
allRequested && startHexes.length === Object.keys(TYPES).length,
|
||||
`${startHexes.length} requests, ${uniqueHexes.size} unique`);
|
||||
|
||||
const after = await page.evaluate(() => window.__collectBillboards());
|
||||
const afterById = new Map(after.map((b) => [b.id, b]));
|
||||
const syntheticImages = new Set(
|
||||
Object.keys(TYPES).map((hex) => afterById.get(hex)?.image).filter(Boolean)
|
||||
);
|
||||
record('E4 diversity: ≥5 distinct glyph data-URIs displayed ambiently',
|
||||
syntheticImages.size >= 5, `${syntheticImages.size} distinct glyphs (expected 8)`);
|
||||
|
||||
const glyphBad = [];
|
||||
const scaleBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterById.get(hex);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
||||
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
||||
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'enrich-after-diverse.png') });
|
||||
console.log(' saved enrich-after-diverse.png (type-diverse row, still untracked)');
|
||||
|
||||
// ========================================================================
|
||||
// E7 — politeness bounds (concurrency, drip rate, session cap)
|
||||
// ========================================================================
|
||||
console.log('\nE7 — client-side bounds');
|
||||
const gaps = [];
|
||||
for (let i = 1; i < log1.starts.length; i++) gaps.push(log1.starts[i].t - log1.starts[i - 1].t);
|
||||
const minGap = gaps.length ? Math.min(...gaps) : Infinity;
|
||||
// Max dispatches in any sliding 1 s window (reported for context).
|
||||
let maxPerSec = 0;
|
||||
for (let i = 0; i < log1.starts.length; i++) {
|
||||
let n = 0;
|
||||
for (let j = i; j < log1.starts.length && log1.starts[j].t - log1.starts[i].t < 1000; j++) n++;
|
||||
maxPerSec = Math.max(maxPerSec, n);
|
||||
}
|
||||
const boundsOk = log1.maxInflight <= MAX_INFLIGHT
|
||||
&& minGap >= (DISPATCH_GAP_MS - GAP_TOLERANCE_MS)
|
||||
&& log1.starts.length <= SESSION_CAP;
|
||||
record(`E7 bounds: ≤${MAX_INFLIGHT} concurrent, ≥${DISPATCH_GAP_MS}ms drip gap, ≤${SESSION_CAP}/session`,
|
||||
boundsOk,
|
||||
`maxConcurrent=${log1.maxInflight} minGap=${Number.isFinite(minGap) ? minGap.toFixed(1) : 'n/a'}ms `
|
||||
+ `maxIn1s=${maxPerSec} total=${log1.starts.length}`);
|
||||
|
||||
// ========================================================================
|
||||
// E8 — next poll: klass persists (poll re-derive keeps enriched types) and
|
||||
// the session dedupe never re-requests an answered/negative hex
|
||||
// ========================================================================
|
||||
console.log('\nE8 — next poll after enrichment');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
await dm.layers.get('flights').module.update(v);
|
||||
});
|
||||
await sleep(500);
|
||||
const afterPoll = await page.evaluate(() => ({
|
||||
billboards: window.__collectBillboards(),
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
}));
|
||||
const afterPollById = new Map(afterPoll.billboards.map((b) => [b.id, b]));
|
||||
const pollBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterPollById.get(hex);
|
||||
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
||||
}
|
||||
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
||||
record('E8 next poll: enriched glyphs persist and no hex is re-requested',
|
||||
pollBad.length === 0 && noReRequests,
|
||||
pollBad.length ? pollBad.join(' ') : `glyphs stable, requests still ${afterPoll.starts}`);
|
||||
|
||||
// ========================================================================
|
||||
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
||||
// old ONE-SHOT session cap burned out and never refilled, so every new
|
||||
// region after the first polls showed airliner monoculture. With the seam
|
||||
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
|
||||
// on-screen planes must stall at exactly 8 new requests, and further polls
|
||||
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
||||
// no refill can land mid-phase). Pre-fix code fails this phase: there is
|
||||
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
|
||||
// admits the whole batch).
|
||||
// ========================================================================
|
||||
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
|
||||
const BATCH_COUNT = 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
|
||||
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
||||
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
// Two fresh rows just north of the original one — still inside the
|
||||
// top-down 20 km frame, so the sweep's frustum test keeps them.
|
||||
// STATIONARY (speed 0): moving planes drift north out of the frustum at
|
||||
// run-time-dependent rates, which made this phase timing-sensitive.
|
||||
hexes.forEach((hex, i) => {
|
||||
window.__ENR.planes.push({
|
||||
icao: hex,
|
||||
callsign: `BGT${String(i + 1).padStart(3, '0')}`,
|
||||
lon0: rowLon0 + (i % 13) * stepDeg,
|
||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||
courseDeg: 0,
|
||||
speedMps: 0,
|
||||
altM: 2600 + i * 15,
|
||||
});
|
||||
});
|
||||
const fl = dm.layers.get('flights').module;
|
||||
await fl.update(v); // sweep enqueues the last 8 tokens
|
||||
await fl.update(v); // bucket empty — must enqueue nothing more
|
||||
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
||||
// Wait for the drip to drain whatever was enqueued, then settle.
|
||||
await page.waitForFunction(
|
||||
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
|
||||
).catch(() => { /* asserted below */ });
|
||||
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
||||
// One more poll while exhausted — still nothing new.
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
});
|
||||
await sleep(1000);
|
||||
const exhausted = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
inflight: window.__ENRICH_LOG.inflight,
|
||||
}));
|
||||
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
|
||||
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
|
||||
|
||||
// ========================================================================
|
||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||
// time since the bucket's refill anchor now spans whole windows; the next
|
||||
// sweep refills (clamped at ceil) and the waiting planes get requested.
|
||||
// ========================================================================
|
||||
console.log('\nE11 — refill window passes; ambient enrichment resumes');
|
||||
await page.evaluate((winMs) => { window.__GEV_ENRICH_AMBIENT_QA.windowMs = winMs; }, RESUME_WINDOW_MS);
|
||||
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
});
|
||||
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
||||
const resumed = await page.waitForFunction(
|
||||
(want) => window.__ENRICH_LOG.starts.length >= want && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 25000, polling: 100 }, expectedTotal
|
||||
).then(() => true).catch(() => false);
|
||||
const resumeLog = await page.evaluate(() => window.__ENRICH_LOG.starts.map((s) => s.hex));
|
||||
const batchRequested = batchHexes.filter((h) => resumeLog.includes(h)).length;
|
||||
const dupes = resumeLog.length !== new Set(resumeLog).size;
|
||||
record('E11 resume: enrichment resumes after a refill window (all waiting planes requested, once each)',
|
||||
resumed && batchRequested === BATCH_COUNT && resumeLog.length === expectedTotal && !dupes,
|
||||
`starts=${resumeLog.length} (want ${expectedTotal}) batchRequested=${batchRequested}/${BATCH_COUNT} dupes=${dupes}`);
|
||||
|
||||
record('E12: no console errors during QA run', consoleErrors.length === 0,
|
||||
consoleErrors.length ? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}` : 'clean');
|
||||
|
||||
finish();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const failed = results.filter((r) => r.ok === false);
|
||||
const passed = results.filter((r) => r.ok === true);
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-failstate-b10.mjs
|
||||
*
|
||||
* Targeted proof for Batch 10 — the "silent-failure" theme (finding H3 + H9).
|
||||
* Drives the REAL app in headless Chromium and asserts that dead/failed feeds
|
||||
* are SURFACED to the user instead of a healthy-looking empty state.
|
||||
*
|
||||
* Node-side request interception (page.setRequestInterception) fabricates the
|
||||
* upstream failures so the run is deterministic and never depends on live
|
||||
* AISStream / CelesTrak availability:
|
||||
*
|
||||
* (i) AIS invalid key — /api/ais-live is answered with
|
||||
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
|
||||
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
|
||||
*
|
||||
* (ii) CelesTrak outage — first serve a good catalog so there's something to
|
||||
* preserve, then flip ALL /api/celestrak/* groups to HTTP 503. Toggle
|
||||
* satellites off/on. Assert the catalog is NOT wiped to 0 AND
|
||||
* getStats().error is set.
|
||||
*
|
||||
* (iii) DETECT with no data — enable panoptic detection with NO data layers.
|
||||
* Assert the shared #world-overlay-canvas is non-empty (mode banner drawn),
|
||||
* so DETECT reads "armed, nothing in view" instead of a blank canvas.
|
||||
* The banner is developer telemetry, hidden from users by default, so this
|
||||
* harness loads the app with ?detectDebug=1 — without it the pixel check
|
||||
* would pass on scanlines alone and no longer assert what it names.
|
||||
* (Absent-by-default is pinned in src/data/detectionHost.test.mjs.)
|
||||
*
|
||||
* Run: node scripts/qa-failstate-b10.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. DOES NOT COMMIT anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
||||
|
||||
/**
|
||||
* Check (iii) asserts the detection MODE BANNER is drawn on a zero-object frame.
|
||||
* That banner is developer telemetry and is hidden from users by default, so the
|
||||
* harness must ask for it explicitly — otherwise the pixel check silently passes
|
||||
* on scanlines alone and the assertion name stops meaning anything.
|
||||
*/
|
||||
function withDetectDebug(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('detectDebug', '1');
|
||||
return parsed.toString();
|
||||
}
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const ARTIFACT_DIR = getOpt('--artifact-dir', null);
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function readLayerControl(page, layerId, expectedLabel) {
|
||||
await page.waitForFunction(
|
||||
(id, label) => {
|
||||
const button = document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`);
|
||||
return button?.textContent?.trim() === label;
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
layerId,
|
||||
expectedLabel,
|
||||
);
|
||||
return page.evaluate((id) => {
|
||||
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
const meta = row?.querySelector('.data-toggle-meta');
|
||||
return {
|
||||
label: button?.textContent?.trim() || '',
|
||||
feedState: button?.dataset?.feedState || '',
|
||||
ariaLabel: button?.getAttribute('aria-label') || '',
|
||||
meta: meta?.textContent?.trim() || '',
|
||||
};
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function captureLayerControl(page, layerId, filename) {
|
||||
if (!ARTIFACT_DIR) return;
|
||||
fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
|
||||
const dataPanel = await page.$('#data-panel');
|
||||
const panelCollapsed = await dataPanel?.evaluate((element) => element.classList.contains('collapsed'));
|
||||
if (panelCollapsed) await page.click('#data-panel .panel-collapse-btn');
|
||||
const row = await page.$(`[data-layer-id="${layerId}"]`);
|
||||
if (!row) throw new Error(`Layer control not found for screenshot: ${layerId}`);
|
||||
await row.evaluate((element) => element.scrollIntoView({ block: 'center' }));
|
||||
await page.waitForFunction((id) => {
|
||||
const element = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
return !!rect && rect.width > 0 && rect.height > 0;
|
||||
}, { timeout: 5_000 }, layerId);
|
||||
await sleep(100);
|
||||
await row.screenshot({ path: path.join(ARTIFACT_DIR, filename) });
|
||||
}
|
||||
|
||||
// A minimal but valid TLE for one satellite (ISS), so the "good catalog" pass
|
||||
// builds a non-zero catalog we can later prove is preserved across the outage.
|
||||
// parseTLE in satellites.js expects `NAME\n1 ...\n2 ...` triplets.
|
||||
const GOOD_TLE = [
|
||||
'ISS (ZARYA)',
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
'NOAA 19',
|
||||
'1 33591U 09005A 08264.51782528 -.00000045 00000-0 35116-4 0 9999',
|
||||
'2 33591 99.1949 123.4567 0013000 200.0000 160.0000 14.12345678123456',
|
||||
].join('\n');
|
||||
|
||||
async function main() {
|
||||
console.log('\nSilent-Failure Proof (Batch 10 — H3 + H9)');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
// Node-side controllable interception mode, flipped mid-run.
|
||||
// celestrak: 'good' → serve GOOD_TLE for every group
|
||||
// celestrak: 'partial' → fail one group while preserving a usable catalog
|
||||
// celestrak: 'down' → 503 for every group (total outage)
|
||||
const mode = { celestrak: 'good' };
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
// (i) AIS invalid-key: the exact payload the spec calls for.
|
||||
if (url.includes('/api/ais-live') && !url.includes('/track')) {
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// (ii) CelesTrak proxy — /api/celestrak/<group>
|
||||
if (url.includes('/api/celestrak/')) {
|
||||
if (mode.celestrak === 'down'
|
||||
|| (mode.celestrak === 'partial' && url.includes('/api/celestrak/stations'))) {
|
||||
req.respond({ status: 503, contentType: 'text/plain', body: 'upstream unavailable' });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'text/plain', body: GOOD_TLE });
|
||||
}
|
||||
return;
|
||||
}
|
||||
req.continue();
|
||||
});
|
||||
|
||||
console.log('Loading app...');
|
||||
await page.goto(withDetectDebug(APP_URL), { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView
|
||||
&& window.__godsEyeView.viewer
|
||||
&& window.__godsEyeView.dataManager
|
||||
&& window.__godsEyeView.styleManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
// ── (0) Manager-owned periodic refresh → shared work/failure/recovery ──
|
||||
console.log('\n(0) Universal periodic refresh feedback...');
|
||||
const refreshFeedback = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const ids = ['flights', 'military', 'satellites'];
|
||||
const styleManager = window.__godsEyeView.styleManager;
|
||||
const outcomes = [];
|
||||
const readBanner = () => ({
|
||||
hidden: document.getElementById('global-loading-status')?.hidden,
|
||||
state: document.getElementById('global-loading-status')?.dataset?.state || '',
|
||||
label: document.getElementById('global-loading-label')?.textContent?.trim() || '',
|
||||
detail: document.getElementById('global-loading-detail')?.textContent?.trim() || '',
|
||||
});
|
||||
|
||||
for (const id of ids) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry) {
|
||||
outcomes.push({ id, missing: true });
|
||||
continue;
|
||||
}
|
||||
const original = {
|
||||
enabled: entry.enabled,
|
||||
initialized: entry.initialized,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
update: entry.module.update,
|
||||
getStats: entry.module.getStats,
|
||||
managerRefreshError: entry.managerRefreshError,
|
||||
};
|
||||
const baseStats = typeof original.getStats === 'function'
|
||||
? original.getStats.call(entry.module)
|
||||
: { count: 0, lastUpdate: null };
|
||||
let rejectRefresh;
|
||||
try {
|
||||
entry.enabled = true;
|
||||
entry.initialized = true;
|
||||
entry.lifecycleState = 'enabled';
|
||||
entry.managerRefreshError = null;
|
||||
entry.module.getStats = () => ({
|
||||
...baseStats,
|
||||
error: null,
|
||||
lastError: null,
|
||||
available: true,
|
||||
unavailable: false,
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
});
|
||||
entry.module.update = () => new Promise((resolve, reject) => {
|
||||
rejectRefresh = reject;
|
||||
});
|
||||
const pendingFailure = dm._runPeriodicUpdate(id, entry);
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
const working = readBanner();
|
||||
rejectRefresh(new Error(`${id} QA refresh failure`));
|
||||
await pendingFailure;
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
const failed = readBanner();
|
||||
const managerError = entry.managerRefreshError;
|
||||
|
||||
entry.module.update = async () => true;
|
||||
const pendingRecovery = dm._runPeriodicUpdate(id, entry);
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
await pendingRecovery;
|
||||
styleManager._updateGlobalLoadingFeedback(performance.now());
|
||||
outcomes.push({
|
||||
id,
|
||||
name: entry.module.name,
|
||||
working,
|
||||
failed,
|
||||
managerError,
|
||||
recoveredError: entry.managerRefreshError,
|
||||
lifecycleState: entry.lifecycleState,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
} finally {
|
||||
entry.module.update = original.update;
|
||||
entry.module.getStats = original.getStats;
|
||||
entry.enabled = original.enabled;
|
||||
entry.initialized = original.initialized;
|
||||
entry.lifecycleState = original.lifecycleState;
|
||||
entry.managerRefreshError = original.managerRefreshError;
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
});
|
||||
for (const outcome of refreshFeedback) {
|
||||
const passed = !outcome.missing
|
||||
&& outcome.working.hidden === false
|
||||
&& outcome.working.label === 'REFRESHING LIVE DATA'
|
||||
&& outcome.working.detail.includes(outcome.name)
|
||||
&& outcome.failed.hidden === false
|
||||
&& outcome.failed.label === 'LOAD FAILED'
|
||||
&& outcome.failed.state === 'error'
|
||||
&& outcome.managerError?.includes('QA refresh failure')
|
||||
&& outcome.recoveredError === null
|
||||
&& outcome.lifecycleState === 'enabled'
|
||||
&& outcome.enabled === true;
|
||||
record(
|
||||
`${outcome.name || outcome.id}: periodic refresh reports work, failure, and recovery`,
|
||||
passed,
|
||||
JSON.stringify(outcome),
|
||||
);
|
||||
if (!passed) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (i) AIS invalid key → surfaced error, not a clean empty ──────────────
|
||||
console.log('\n(i) AIS invalid-key: enabling ais-live-vessels...');
|
||||
const aisStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('ais-live-vessels', true);
|
||||
const mod = dm.layers.get('ais-live-vessels').module;
|
||||
// DataLayerManager intentionally treats enable() as a synchronous
|
||||
// lifecycle hook, while this layer starts its first poll asynchronously
|
||||
// from enable(). Wait on the layer's public loading state instead of a
|
||||
// machine-speed-dependent fixed delay so the assertion always observes
|
||||
// the injected response, not an in-flight request.
|
||||
const deadline = Date.now() + 5000;
|
||||
while (mod.getStats().loading && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return mod.getStats();
|
||||
});
|
||||
{
|
||||
const hasError = typeof aisStats.error === 'string' && aisStats.error.length > 0;
|
||||
const cleanEmpty = !hasError && (aisStats.count === 0);
|
||||
record(
|
||||
'AIS: dead feed surfaces an error (not clean empty)',
|
||||
hasError && !cleanEmpty,
|
||||
`stats=${JSON.stringify({ error: aisStats.error, count: aisStats.count, lastUpdate: aisStats.lastUpdate })}`,
|
||||
);
|
||||
if (!hasError) exitCode = 1;
|
||||
}
|
||||
const aisControl = await readLayerControl(page, 'ais-live-vessels', 'UNAVAILABLE');
|
||||
const aisChipHonest = aisControl.feedState === 'unavailable'
|
||||
&& aisControl.ariaLabel === 'Live AIS Vessels: UNAVAILABLE';
|
||||
const aisMetaHonest = /^UNAVAILABLE · AISStream · /i.test(aisControl.meta)
|
||||
&& aisStats.error
|
||||
&& aisControl.meta.includes(aisStats.error);
|
||||
record(
|
||||
'AIS: layer control reads UNAVAILABLE',
|
||||
aisChipHonest,
|
||||
JSON.stringify(aisControl),
|
||||
);
|
||||
record(
|
||||
'AIS: layer metadata names the upstream error',
|
||||
aisMetaHonest,
|
||||
`meta=${JSON.stringify(aisControl.meta)}`,
|
||||
);
|
||||
if (!aisChipHonest || !aisMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'ais-live-vessels', 'failstate-ais-unavailable.png');
|
||||
|
||||
// ── (ii) CelesTrak outage on re-enable → catalog NOT wiped, error set ────
|
||||
console.log('\n(ii) Satellites: building a good catalog first...');
|
||||
const goodStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const mod = dm.layers.get('satellites').module;
|
||||
return mod.getStats();
|
||||
});
|
||||
record(
|
||||
'Satellites: good catalog loaded before outage (baseline)',
|
||||
goodStats.count > 0,
|
||||
`count=${goodStats.count} error=${goodStats.error ?? null}`,
|
||||
);
|
||||
if (!(goodStats.count > 0)) {
|
||||
// Without a baseline the preservation assertion is meaningless.
|
||||
record('Satellites: catalog preserved across total outage', null, 'no baseline catalog to preserve');
|
||||
} else {
|
||||
console.log(' Failing one CelesTrak group and verifying the visible degraded state...');
|
||||
mode.celestrak = 'partial';
|
||||
const partialStats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('satellites', false);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return dm.layers.get('satellites').module.getStats();
|
||||
});
|
||||
const partialControl = await readLayerControl(page, 'satellites', 'DEGRADED');
|
||||
const partialChipHonest = partialStats.count > 0
|
||||
&& /1 CelesTrak group unavailable/i.test(partialStats.error || '')
|
||||
&& partialControl.feedState === 'degraded'
|
||||
&& partialControl.ariaLabel === 'Satellites: DEGRADED';
|
||||
const partialMetaHonest = /^DEGRADED · CelesTrak · /i.test(partialControl.meta)
|
||||
&& /1 CelesTrak group unavailable/i.test(partialControl.meta);
|
||||
record(
|
||||
'Satellites: partial outage renders a DEGRADED chip',
|
||||
partialChipHonest,
|
||||
JSON.stringify({ stats: partialStats, control: partialControl }),
|
||||
);
|
||||
record(
|
||||
'Satellites: degraded metadata names partial coverage',
|
||||
partialMetaHonest,
|
||||
`meta=${JSON.stringify(partialControl.meta)}`,
|
||||
);
|
||||
if (!partialChipHonest || !partialMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'satellites', 'failstate-satellites-degraded.png');
|
||||
|
||||
console.log(' Flipping ALL CelesTrak groups to 503 and toggling satellites off→on...');
|
||||
mode.celestrak = 'down';
|
||||
const outageStats = await page.evaluate(async (baseline) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
// Toggle off then on — the re-enable's update() re-fetches (now all 503).
|
||||
await dm.setEnabled('satellites', false);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await dm.setEnabled('satellites', true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const mod = dm.layers.get('satellites').module;
|
||||
return { stats: mod.getStats(), baseline };
|
||||
}, goodStats.count);
|
||||
|
||||
const s = outageStats.stats;
|
||||
const notWiped = s.count > 0; // catalog preserved, not blanked to 0
|
||||
const errorSet = typeof s.error === 'string' && s.error.length > 0;
|
||||
const outageControl = await readLayerControl(page, 'satellites', 'UNAVAILABLE');
|
||||
const outageChipHonest = outageControl.feedState === 'unavailable'
|
||||
&& outageControl.ariaLabel === 'Satellites: UNAVAILABLE';
|
||||
const outageMetaHonest = /^UNAVAILABLE · CelesTrak · /i.test(outageControl.meta)
|
||||
&& outageControl.meta.includes(s.error || '');
|
||||
record(
|
||||
'Satellites: catalog NOT wiped to 0 on total outage',
|
||||
notWiped,
|
||||
`count=${s.count} (baseline=${outageStats.baseline})`,
|
||||
);
|
||||
record(
|
||||
'Satellites: outage surfaces stats.error',
|
||||
errorSet,
|
||||
`error=${JSON.stringify(s.error)} lastUpdate=${s.lastUpdate}`,
|
||||
);
|
||||
record(
|
||||
'Satellites: total outage renders an UNAVAILABLE chip',
|
||||
outageChipHonest,
|
||||
JSON.stringify(outageControl),
|
||||
);
|
||||
record(
|
||||
'Satellites: unavailable metadata names the outage',
|
||||
outageMetaHonest,
|
||||
`meta=${JSON.stringify(outageControl.meta)}`,
|
||||
);
|
||||
if (!notWiped || !errorSet || !outageChipHonest || !outageMetaHonest) exitCode = 1;
|
||||
await captureLayerControl(page, 'satellites', 'failstate-satellites-unavailable.png');
|
||||
}
|
||||
|
||||
// ── (iii) DETECT with no data layers → mode banner drawn (non-blank) ─────
|
||||
console.log('\n(iii) DETECT with no data layers: disabling data layers, enabling panoptic...');
|
||||
const detect = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
// Turn OFF every data layer so detection collects zero objects.
|
||||
for (const [id, entry] of dm.layers) {
|
||||
if (entry.enabled) { try { await dm.setEnabled(id, false); } catch { /* ignore */ } }
|
||||
}
|
||||
// Enable panoptic detection via the styleManager facade (the UI path).
|
||||
gev.styleManager.setDetection({ enabled: true, mode: 'panoptic' });
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
if (!canvas) return { present: false };
|
||||
const viewer = gev.viewer;
|
||||
const tileset = gev.tileset;
|
||||
const priorDefaultLoop = viewer.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
let nonEmpty = 0;
|
||||
let solid = 0;
|
||||
let sampled = 0;
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
if (tileset) tileset.show = false;
|
||||
try {
|
||||
const deadline = performance.now() + 5000;
|
||||
while (performance.now() < deadline && solid <= 20) {
|
||||
viewer.render();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const off = document.createElement('canvas');
|
||||
off.width = w; off.height = h;
|
||||
const octx = off.getContext('2d');
|
||||
// The detection pass (banner included) paints on the host-owned
|
||||
// blend-isolation surface beneath the shared canvas — composite
|
||||
// both so the sample sees whichever surface carries the banner.
|
||||
const detSurface = document.getElementById('world-overlay-detection-surface');
|
||||
if (detSurface) octx.drawImage(detSurface, 0, 0);
|
||||
octx.drawImage(canvas, 0, 0);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const bw = Math.min(w, Math.round(260 * dpr));
|
||||
const bh = Math.min(h, Math.round(48 * dpr));
|
||||
const data = octx.getImageData(0, 0, bw, bh).data;
|
||||
sampled = bw * bh;
|
||||
nonEmpty = 0;
|
||||
solid = 0;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 4) nonEmpty++;
|
||||
// The banner paints labelBg at full opacity (alpha 0.66-0.78 => 168+).
|
||||
// Scanlines paint the SAME color but scaled by _theme.scanline, and
|
||||
// only on every fourth row, so they never reach this threshold. This
|
||||
// is what separates "the banner is drawn" from "something is drawn".
|
||||
if (data[i] > 128) solid++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (tileset) tileset.show = priorTilesetShow;
|
||||
viewer.useDefaultRenderLoop = priorDefaultLoop;
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
const detState = gev.styleManager.getDetectionState?.() || null;
|
||||
return { present: true, nonEmpty, solid, sampled, mode: detState?.detectionMode ?? null };
|
||||
});
|
||||
|
||||
if (!detect.present) {
|
||||
record('DETECT: mode banner drawn on 0 objects', false, 'world-overlay canvas absent');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
// Solid pixels only: a non-empty canvas proves SOMETHING painted, which
|
||||
// faint scanlines alone satisfy. The banner's opaque background is what
|
||||
// this check is named for, so that is what it asserts.
|
||||
const bannerDrawn = detect.solid > 20;
|
||||
record(
|
||||
'DETECT: mode banner drawn on 0 objects (opaque banner fill present)',
|
||||
bannerDrawn,
|
||||
`solidPx=${detect.solid} nonEmptyPx=${detect.nonEmpty}/${detect.sampled} mode=${detect.mode}`,
|
||||
);
|
||||
if (!bannerDrawn) exitCode = 1;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-firms.mjs — headless proof for the LIVE NASA FIRMS fires layer.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server with a
|
||||
* FIRMS key (default :4420). The live section rides the proxy's 30-min disk
|
||||
* cache (zero upstream quota risk per run); the degraded states are
|
||||
* fabricated with request interception so they're deterministic.
|
||||
*
|
||||
* (i) LIVE — layer loads >1000 detections, no error, label 'LIVE ·',
|
||||
* /api/firms/status reports the key + transaction telemetry.
|
||||
* (ii) CARDS — the shared world-overlay canvas reports painted FIRMS
|
||||
* entries and is non-blank at a fire cluster (detections LOD) AND at
|
||||
* global zoom (cell cards); aggregate cells expose no action target.
|
||||
* (iii) KEYLESS — /api/firms intercepted to 503 {error:'no_key'}:
|
||||
* stats.error 'KEY REQUIRED', zero fires, no crash.
|
||||
* (iv) STALE — /api/firms intercepted to a stale:true payload with
|
||||
* fresh-enough detections: stats.stale true, error 'STALE · …',
|
||||
* fires still render (old data beats a wiped map).
|
||||
* (v) ACTION (synthetic) — one deterministic intercepted detection's
|
||||
* accessible card selects exactly that stable fire and transfers the
|
||||
* camera through the shared UI navigation policy.
|
||||
*
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4420');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Non-transparent pixels plus source-scoped host diagnostics. */
|
||||
async function cardCanvasInk(page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
const painted = diagnostics?.paintedBySource?.firms || 0;
|
||||
const entries = diagnostics?.entriesBySource?.firms || 0;
|
||||
if (!canvas) return {
|
||||
present: false, ink: 0, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { width, height } = canvas;
|
||||
if (!width || !height) return {
|
||||
present: true, ink: 0, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
let ink = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i] > 8) ink++;
|
||||
return {
|
||||
present: true, ink, painted, entries,
|
||||
candidates: diagnostics?.candidateCount || 0,
|
||||
projected: diagnostics?.projectedCount || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for two consecutive painted frames instead of sampling a render gap. */
|
||||
async function waitForCardCanvasInk(page, { timeoutMs = 12000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let consecutive = 0;
|
||||
let sample = null;
|
||||
while (Date.now() < deadline) {
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await sleep(150);
|
||||
sample = await cardCanvasInk(page);
|
||||
if (sample.present && sample.entries > 0 && sample.painted > 0 && sample.ink > 500) {
|
||||
consecutive += 1;
|
||||
if (consecutive >= 2) return sample;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
return sample || {
|
||||
present: false, ink: 0, painted: 0, entries: 0, candidates: 0, projected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Currently painted FIRMS actions mirrored for keyboard/assistive input. */
|
||||
async function firmsActionSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
return {
|
||||
count: buttons.length,
|
||||
selectedCount: buttons.filter((button) => button.getAttribute('aria-pressed') === 'true').length,
|
||||
labels: buttons.map((button) => button.getAttribute('aria-label') || button.textContent || ''),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait until the accessible mirror reflects the requested FIRMS action count. */
|
||||
async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let snapshot = await firmsActionSnapshot(page);
|
||||
while (Date.now() < deadline && snapshot.count !== expected) {
|
||||
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
||||
await sleep(175);
|
||||
snapshot = await firmsActionSnapshot(page);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Boot the app and enable the fires layer, polling until settled. */
|
||||
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
return page.evaluate(async (tS) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('local-firms', true);
|
||||
const mod = dm.layers.get('local-firms').module;
|
||||
let s = null;
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if ((s.count > 0 || s.error) && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, timeoutS);
|
||||
}
|
||||
|
||||
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
||||
async function setView(page, lon, lat, height) {
|
||||
await page.evaluate((lo, la, h) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ }
|
||||
gev.viewer.camera.setView({
|
||||
destination: ell.cartographicToCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h }),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
}, lon, lat, height);
|
||||
}
|
||||
|
||||
/** Fabricated stale-but-recent proxy payload for section (iv). */
|
||||
function stalePayload() {
|
||||
const now = Date.now();
|
||||
const acq = new Date(now - 2 * 3600000);
|
||||
const acqDate = acq.toISOString().slice(0, 10);
|
||||
const acqTime = `${String(acq.getUTCHours()).padStart(2, '0')}${String(acq.getUTCMinutes()).padStart(2, '0')}`;
|
||||
const mk = (lat, lon, frp, confidence) => ({
|
||||
lat, lon, frp, confidence, brightness: 330, brightnessTi5: 290,
|
||||
daynight: 'N', acqDate, acqTime, satellite: 'N20', instrument: 'VIIRS',
|
||||
});
|
||||
return {
|
||||
fetchedAt: now - 2 * 3600000,
|
||||
stale: true,
|
||||
ttlMs: 1800000,
|
||||
sources: [{ source: 'VIIRS_NOAA20_NRT', count: 3, ok: false }],
|
||||
count: 3,
|
||||
fires: [mk(61.9, -122.9, 900, 'h'), mk(61.95, -122.8, 45, 'n'), mk(61.85, -123.0, 4, 'l')],
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nLive NASA FIRMS Proof (qa-firms)');
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// ── (i) LIVE feed ────────────────────────────────────────────────────────
|
||||
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
|
||||
const live = await bootAndEnable(page);
|
||||
{
|
||||
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
|
||||
record('LIVE: >1000 detections, no error, LIVE label', ok,
|
||||
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
|
||||
record('LIVE: /api/firms/status has key + transaction telemetry',
|
||||
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
|
||||
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (ii) CARDS — canvas overlay draws at both LODs ──────────────────────
|
||||
console.log('\n(ii) CARDS — tactical card overlay ink at both LODs...');
|
||||
{
|
||||
const strongest = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('local-firms').module;
|
||||
return mod.getStrongestFire();
|
||||
});
|
||||
if (!strongest) {
|
||||
record('CARDS: detections-LOD cards drawn', false, 'no strongest fire available');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
await setView(page, strongest.longitude, strongest.latitude, 60000);
|
||||
const det = await waitForCardCanvasInk(page);
|
||||
record('CARDS: detections-LOD cards drawn (host source + canvas ink)',
|
||||
det.present && det.painted > 0 && det.ink > 500,
|
||||
`entries=${det.entries} candidates=${det.candidates} projected=${det.projected} `
|
||||
+ `painted=${det.painted} inkPx=${det.ink} @ strongest fire FRP ${strongest.frp}`);
|
||||
if (!(det.present && det.painted > 0 && det.ink > 500)) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-cards-detections.png') });
|
||||
|
||||
// Global LOD band starts at 9,000 km; its labelDistance is 12,000 km
|
||||
// (cards fade to zero beyond it, matching the legacy labels) — so the
|
||||
// cell-card proof must sit inside that window.
|
||||
await setView(page, strongest.longitude, strongest.latitude, 10000000);
|
||||
// Do not let the previous detections frame satisfy the ink predicate
|
||||
// before the 650 ms LOD watcher has swapped in aggregate cell cards.
|
||||
const aggregateActions = await waitForFirmsActionCount(page, 0);
|
||||
const cells = await waitForCardCanvasInk(page);
|
||||
record('CARDS: global-LOD cell cards drawn (host source + canvas ink)',
|
||||
cells.present && cells.painted > 0 && cells.ink > 500,
|
||||
`entries=${cells.entries} candidates=${cells.candidates} projected=${cells.projected} `
|
||||
+ `painted=${cells.painted} inkPx=${cells.ink}`);
|
||||
if (!(cells.present && cells.painted > 0 && cells.ink > 500)) exitCode = 1;
|
||||
record('CARDS: aggregate cells expose no FIRMS action target',
|
||||
aggregateActions.count === 0,
|
||||
`actionButtons=${aggregateActions.count}`);
|
||||
if (aggregateActions.count !== 0) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-global-cells.png') });
|
||||
}
|
||||
}
|
||||
|
||||
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
|
||||
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = 'keyless';
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/firms/status')) {
|
||||
req.respond({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(interceptMode === 'keyless'
|
||||
? { hasKey: false }
|
||||
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/firms')) {
|
||||
if (interceptMode === 'keyless') {
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
|
||||
const keyless = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
const ok = keyless.error === 'KEY REQUIRED' && keyless.count === 0;
|
||||
record('KEYLESS: error "KEY REQUIRED", zero fires, no crash', ok,
|
||||
`error=${JSON.stringify(keyless.error)} count=${keyless.count} label=${JSON.stringify(keyless.loadingLabel)}`);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-keyless.png') });
|
||||
}
|
||||
|
||||
// ── (iv) STALE (intercepted) ─────────────────────────────────────────────
|
||||
console.log('\n(iv) STALE — intercepted stale:true payload...');
|
||||
interceptMode = 'stale';
|
||||
const stale = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
const ok = stale.stale === true
|
||||
&& String(stale.error || '').startsWith('STALE')
|
||||
&& stale.count === 3;
|
||||
record('STALE: stats.stale, "STALE · cached" error, fires still render', ok,
|
||||
`stale=${stale.stale} error=${JSON.stringify(stale.error)} count=${stale.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-stale.png') });
|
||||
}
|
||||
|
||||
// ── (v) ACTION (deterministic intercepted detection) ────────────────────────
|
||||
console.log('\n(v) ACTION — synthetic intercepted fire card through shared camera policy...');
|
||||
await setView(page, -122.9, 61.9, 60000);
|
||||
const actionCards = await waitForCardCanvasInk(page);
|
||||
const beforeActions = await firmsActionSnapshot(page);
|
||||
if (!(actionCards.present && actionCards.painted > 0 && beforeActions.count > 0)) {
|
||||
record('ACTION (synthetic): individual fire action is available', false,
|
||||
`painted=${actionCards.painted} actionButtons=${beforeActions.count}`);
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const prepared = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const mod = gev.dataManager.layers.get('local-firms').module;
|
||||
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
||||
if (!target?.position) return null;
|
||||
|
||||
const sentinel = gev.viewer.entities.add({
|
||||
id: 'qa-firms-prior-camera-owner',
|
||||
position: target.position,
|
||||
});
|
||||
gev.viewer.trackedEntity = sentinel;
|
||||
|
||||
const camera = gev.viewer.camera;
|
||||
const originalFly = camera.flyToBoundingSphere;
|
||||
const proof = {
|
||||
sentinel,
|
||||
originalFly,
|
||||
flightCount: 0,
|
||||
trackedAtFlight: 'not-called',
|
||||
request: null,
|
||||
generationBefore: gev.styleManager._navigationGeneration,
|
||||
};
|
||||
proof.onRequest = (event) => {
|
||||
proof.request = {
|
||||
kind: event.detail?.kind || null,
|
||||
id: event.detail?.id || null,
|
||||
};
|
||||
};
|
||||
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
||||
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
||||
proof.flightCount += 1;
|
||||
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
||||
return originalFly.apply(this, args);
|
||||
};
|
||||
window.__qaFirmsActionProof = proof;
|
||||
return { generationBefore: proof.generationBefore };
|
||||
});
|
||||
|
||||
if (!prepared) {
|
||||
record('ACTION (synthetic): individual fire action is available', false,
|
||||
'no deterministic fire position');
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const clicked = await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].find((candidate) => String(candidate.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
button?.click();
|
||||
return Boolean(button);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const settled = await page.evaluate(() => {
|
||||
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
||||
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
||||
});
|
||||
if (settled) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await sleep(250);
|
||||
|
||||
const proof = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const state = window.__qaFirmsActionProof;
|
||||
const actionButtons = [...document.querySelectorAll(
|
||||
'#world-overlay-action-list button[data-overlay-action-key]',
|
||||
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
||||
const selectedEntityId = window.__gevContextStore?.selectedEntityId || null;
|
||||
const result = {
|
||||
flightCount: state?.flightCount || 0,
|
||||
trackedAtFlight: state?.trackedAtFlight,
|
||||
trackingReleased: gev.viewer.trackedEntity == null,
|
||||
generationBefore: state?.generationBefore,
|
||||
generationAfter: gev.styleManager._navigationGeneration,
|
||||
request: state?.request || null,
|
||||
selectedEntityId,
|
||||
selectedActionCount: actionButtons.filter(
|
||||
(button) => button.getAttribute('aria-pressed') === 'true',
|
||||
).length,
|
||||
};
|
||||
if (state) {
|
||||
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
||||
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
||||
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
||||
}
|
||||
delete window.__qaFirmsActionProof;
|
||||
return result;
|
||||
});
|
||||
|
||||
const actionOk = clicked
|
||||
&& proof.flightCount === 1
|
||||
&& proof.trackedAtFlight == null
|
||||
&& proof.trackingReleased
|
||||
&& proof.generationAfter === proof.generationBefore + 1
|
||||
&& proof.request?.kind === 'fire'
|
||||
&& proof.request?.id === proof.selectedEntityId
|
||||
&& proof.selectedActionCount === 1;
|
||||
record('ACTION (synthetic): stable fire selects once and shared policy owns flight', actionOk,
|
||||
`clicked=${clicked} flights=${proof.flightCount} trackedAtFlight=${JSON.stringify(proof.trackedAtFlight)} `
|
||||
+ `generation=${proof.generationBefore}->${proof.generationAfter} `
|
||||
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
|
||||
+ `selectedActions=${proof.selectedActionCount}`);
|
||||
if (!actionOk) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(3); });
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the first-run launcher's pins actually load-bearing?
|
||||
*
|
||||
* A pin that only goes red when you delete the whole feature proves very little.
|
||||
* This reverts each decision INDIVIDUALLY — the smallest edit that reintroduces
|
||||
* the original defect or contradicts the owner's ruling — and requires
|
||||
* src/firstRunExperience.test.mjs to go red for it. Every entry names what it
|
||||
* restores, so the count is reproducible rather than asserted in a commit
|
||||
* message.
|
||||
*
|
||||
* node scripts/qa-firstrun-mutations.mjs
|
||||
*
|
||||
* Every touched file is restored on exit, including on failure.
|
||||
*
|
||||
* NOTE: two mutations edit vite.config.js, and a running dev server watches that
|
||||
* file and restarts on every write. Writes are therefore content-guarded below
|
||||
* so the file is touched exactly twice per mutation instead of on every
|
||||
* iteration — enough that a dev server survives, but expect it to restart. If
|
||||
* you are mid-QA on a live server, run this before or after, not during.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
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';
|
||||
|
||||
const FILES = {
|
||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||
html: path.join(ROOT, 'index.html'),
|
||||
css: path.join(ROOT, 'style.css'),
|
||||
vite: path.join(ROOT, 'vite.config.js'),
|
||||
main: path.join(ROOT, 'src', 'main.js'),
|
||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||
};
|
||||
|
||||
/** @type {Array<{defect: string, file: keyof FILES, from: string, to: string}>} */
|
||||
const MUTATIONS = [
|
||||
// ── Show policy (owner ruling: session-scoped dismiss vs durable checkbox) ──
|
||||
{
|
||||
defect: 'dismissing writes the DURABLE key, so the launcher never returns',
|
||||
file: 'module',
|
||||
from: "writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed');",
|
||||
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
defect: 'unticking the box cannot take the suppression back',
|
||||
file: 'module',
|
||||
from: " : removeStored('local', storage, FIRST_RUN_STORAGE_KEY);",
|
||||
to: ' : true;',
|
||||
},
|
||||
{
|
||||
defect: 'the session flag is never read, so it re-nags on every reload',
|
||||
file: 'module',
|
||||
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
|
||||
to: '// session check removed',
|
||||
},
|
||||
{
|
||||
defect: 'the durable flag is never read, so the checkbox does nothing',
|
||||
file: 'module',
|
||||
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
|
||||
to: '// durable check removed',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
defect: '?welcome=0 stops suppressing',
|
||||
file: 'module',
|
||||
from: "if (params.get('welcome') === '0') return false;",
|
||||
to: '// suppression param removed',
|
||||
},
|
||||
{
|
||||
defect: 'a share link gets the launcher over the view its author chose',
|
||||
file: 'module',
|
||||
from: 'if (hasShareState) return false;',
|
||||
to: '// share bypass removed',
|
||||
},
|
||||
{
|
||||
defect: 'storage is read from a DEFAULT PARAMETER, outside every guard',
|
||||
file: 'module',
|
||||
from: 'export function rememberFirstRunSessionDismissed(sessionStorageRef) {',
|
||||
to: 'export function rememberFirstRunSessionDismissed(sessionStorageRef = globalThis.sessionStorage) {',
|
||||
},
|
||||
{
|
||||
defect: 'shouldShowFirstRun resolves storage in its parameter list again',
|
||||
file: 'module',
|
||||
from: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage,\n sessionStorageRef,',
|
||||
to: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage = globalThis.localStorage,\n sessionStorageRef = globalThis.sessionStorage,',
|
||||
},
|
||||
{
|
||||
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
||||
file: 'module',
|
||||
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
||||
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
||||
},
|
||||
{
|
||||
defect: 'a blocked getItem throws instead of reading as "nothing stored"',
|
||||
file: 'module',
|
||||
from: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } catch {\n return null;\n }',
|
||||
to: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } finally {\n // no catch\n }',
|
||||
},
|
||||
|
||||
// ── ESC arbitration ───────────────────────────────────────────────────────
|
||||
{
|
||||
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
|
||||
file: 'module',
|
||||
from: 'if (closing || !isTopmost()) return;',
|
||||
to: 'if (closing) return;',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
|
||||
file: 'module',
|
||||
from: 'if (revealed && blocked) yieldToExclusiveSurface();\n else if (!revealed && !blocked) reveal();',
|
||||
to: 'if (!revealed) reveal();',
|
||||
},
|
||||
{
|
||||
defect: 'nothing watches for another surface taking the screen',
|
||||
file: 'module',
|
||||
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
|
||||
to: '/* observer never attached */',
|
||||
},
|
||||
{
|
||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||
file: 'module',
|
||||
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
|
||||
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
|
||||
},
|
||||
{
|
||||
defect: 'a scene hides the launcher in CSS but the JS never learns of it',
|
||||
file: 'css',
|
||||
from: 'body.scene-playback-mode #first-run-launcher {',
|
||||
to: 'body.scene-playback-mode-typo #first-run-launcher {',
|
||||
},
|
||||
{
|
||||
defect: 'yielding steals focus back from the surface that just took over',
|
||||
file: 'module',
|
||||
from: 'dismiss({ restoreFocus: false });',
|
||||
to: 'dismiss();',
|
||||
},
|
||||
{
|
||||
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',
|
||||
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',
|
||||
file: 'module',
|
||||
from: ' if (event.defaultPrevented) return;',
|
||||
to: ' /* belt removed */',
|
||||
},
|
||||
{
|
||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||
file: 'ui',
|
||||
from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
},
|
||||
{
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
},
|
||||
|
||||
// ── The checkbox may not promise what storage refused ─────────────────────
|
||||
{
|
||||
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;',
|
||||
},
|
||||
{
|
||||
defect: 'a missing storage area reports the write as saved',
|
||||
file: 'module',
|
||||
from: " if (typeof store?.setItem !== 'function') return false;\n store.setItem(key, value);\n return true;",
|
||||
to: ' store?.setItem?.(key, value);\n return true;',
|
||||
},
|
||||
{
|
||||
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;",
|
||||
},
|
||||
|
||||
{
|
||||
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;",
|
||||
},
|
||||
{
|
||||
defect: 'the first-run rail default overrides a stored user choice',
|
||||
file: 'ui',
|
||||
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
|
||||
to: "if (panelId === 'pp-toggles') collapsed = true;",
|
||||
},
|
||||
|
||||
// ── Mission definitions ────────────────────────────────────────────────────
|
||||
{
|
||||
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',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['local-firms']),",
|
||||
},
|
||||
{
|
||||
defect: 'FIRMS is dropped again from the tile whose subcopy promises it',
|
||||
file: 'module',
|
||||
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
|
||||
to: "layerIds: Object.freeze(['earthquakes']),",
|
||||
},
|
||||
{
|
||||
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>',
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
defect: 'the ledgered global-chip defect loses its pointer to the real fix',
|
||||
file: 'module',
|
||||
from: ' // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state',
|
||||
to: ' // (note removed)',
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions never pull the camera out',
|
||||
file: 'module',
|
||||
from: ' .then(() => flyToGlobe())',
|
||||
to: ' .then(() => null)',
|
||||
},
|
||||
{
|
||||
defect: 'a refused layer is reported as a successful mission',
|
||||
file: 'module',
|
||||
from: 'return { ok: failedLayerIds.length === 0, choice, failedLayerIds };',
|
||||
to: 'return { ok: true, choice, failedLayerIds };',
|
||||
},
|
||||
{
|
||||
defect: 'a cancelled globe flight fails the whole mission',
|
||||
file: 'module',
|
||||
from: ' .catch(() => null);',
|
||||
to: ' .catch(() => { throw new Error("flight failed"); });',
|
||||
},
|
||||
{
|
||||
defect: 'Explore manually quietly runs a mission instead of nothing',
|
||||
file: 'module',
|
||||
from: "if (mission.kind === 'none') return { ok: true, choice };",
|
||||
to: "if (mission.kind === 'none') return { ok: true, choice, ran: await setContextMode('contacts') };",
|
||||
},
|
||||
{
|
||||
defect: 'an unknown choice is treated as a success',
|
||||
file: 'module',
|
||||
from: 'if (!mission) return { ok: false, choice };',
|
||||
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;',
|
||||
},
|
||||
|
||||
// ── Defaults interplay (the ruling this whole integration turns on) ────────
|
||||
{
|
||||
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',
|
||||
file: 'module',
|
||||
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
|
||||
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
|
||||
},
|
||||
{
|
||||
defect: 'a mission persists a 3D-models choice nobody made',
|
||||
file: 'module',
|
||||
from: ' const result = await styleManager.setContextMode(mode);',
|
||||
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
|
||||
},
|
||||
{
|
||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||
file: 'module',
|
||||
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
|
||||
to: '// panel reveal removed',
|
||||
},
|
||||
{
|
||||
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)',
|
||||
},
|
||||
|
||||
// ── 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) button.setAttribute('aria-disabled', String(next));",
|
||||
to: 'for (const button of buttons) button.disabled = next;',
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
file: 'module',
|
||||
from: "if (event.key !== 'Tab') return;",
|
||||
to: 'return;',
|
||||
},
|
||||
{
|
||||
defect: 'ESC no longer dismisses the launcher',
|
||||
file: 'module',
|
||||
from: "if (event.key === 'Escape') {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'focus is never returned to where the visitor left it',
|
||||
file: 'module',
|
||||
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
||||
file: 'module',
|
||||
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
|
||||
to: "documentRef.addEventListener('keydown', onKeyDown);",
|
||||
},
|
||||
{
|
||||
defect: 'the card stays clickable through its fade-out (double-launch)',
|
||||
file: 'css',
|
||||
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
||||
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher survives Clean-UI and recording mode',
|
||||
file: 'css',
|
||||
from: 'body.ui-clean-view #first-run-launcher,',
|
||||
to: 'body.never-matches-anything #first-run-launcher,',
|
||||
},
|
||||
{
|
||||
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',
|
||||
file: 'css',
|
||||
from: ' max-height: calc(100dvh - 1.5rem);',
|
||||
to: ' /* cap removed */',
|
||||
},
|
||||
{
|
||||
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 */',
|
||||
},
|
||||
{
|
||||
// Anchored to the launcher's OWN selectors: style.css has four other
|
||||
// reduced-motion blocks, and a bare `@media` anchor would mutate one of
|
||||
// those instead and prove nothing about this feature.
|
||||
defect: "the launcher's reduced-motion opt-out is dropped",
|
||||
file: 'css',
|
||||
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
|
||||
to: ' .first-run-nothing { transition: none; }',
|
||||
},
|
||||
|
||||
// ── Markup and startup ordering ───────────────────────────────────────────
|
||||
{
|
||||
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">',
|
||||
},
|
||||
{
|
||||
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">',
|
||||
},
|
||||
{
|
||||
defect: 'the "don\'t show again" checkbox is removed',
|
||||
file: 'html',
|
||||
from: '<input type="checkbox" data-first-run-suppress />',
|
||||
to: '<span data-first-run-suppress-removed></span>',
|
||||
},
|
||||
{
|
||||
defect: 'the status line stops being a polite live region',
|
||||
file: 'html',
|
||||
from: 'data-first-run-status role="status" aria-live="polite"',
|
||||
to: 'data-first-run-status',
|
||||
},
|
||||
{
|
||||
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"',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher is revealed before the loading cover yields',
|
||||
file: 'main',
|
||||
from: " loadingScreen.classList.add('hidden');",
|
||||
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
|
||||
},
|
||||
{
|
||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||
file: 'main',
|
||||
from: 'initFirstRunExperience({ styleManager, dataManager });',
|
||||
to: 'initFirstRunExperience({ styleManager });',
|
||||
},
|
||||
|
||||
// ── Voice: schema must not drift ──────────────────────────────────────────
|
||||
{
|
||||
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
||||
file: 'vite',
|
||||
from: " 'earthquakes',\n 'satellites',",
|
||||
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
|
||||
},
|
||||
{
|
||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||
file: 'vite',
|
||||
from: " 'NAMED VIEWS are shorthand",
|
||||
to: " // 'NAMED VIEWS are shorthand",
|
||||
},
|
||||
];
|
||||
|
||||
const originals = new Map();
|
||||
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));
|
||||
};
|
||||
process.on('exit', restoreAll);
|
||||
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
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)) {
|
||||
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
|
||||
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
|
||||
continue;
|
||||
}
|
||||
write(FILES[file], original.replace(from, to));
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
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('; ');
|
||||
}
|
||||
write(FILES[file], original);
|
||||
if (red) {
|
||||
caught += 1;
|
||||
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
|
||||
} else {
|
||||
missed.push(defect);
|
||||
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
|
||||
}
|
||||
}
|
||||
restoreAll();
|
||||
|
||||
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
|
||||
if (missed.length) {
|
||||
console.log('\n uncovered:');
|
||||
for (const item of missed) console.log(` - ${item}`);
|
||||
}
|
||||
console.log('');
|
||||
process.exitCode = missed.length ? 1 : 0;
|
||||
@@ -0,0 +1,896 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-firstrun — the mission launcher's contract, proved in the real app.
|
||||
*
|
||||
* The unit suite pins the show policy and the mission table against fakes. This
|
||||
* harness answers the questions only a running app can: does a mission actually
|
||||
* enable those layers, does the camera actually reach the globe, does a keyless
|
||||
* visitor actually get the honest FIRMS state instead of a silent empty layer,
|
||||
* and does any of it disturb the reasonable-defaults startup look.
|
||||
*
|
||||
* It also captures the owner's taste-pass screenshots into qa-shots/firstrun/.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-firstrun.mjs --url http://localhost:4278
|
||||
* node scripts/qa-firstrun.mjs --url http://localhost:4278 --teeth
|
||||
*
|
||||
* `--teeth` is the negative control: it suppresses the launcher's own reveal, so
|
||||
* every rendered/behavioral assertion below must go RED. A green --teeth run
|
||||
* means the assertions are not measuring what they claim to.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getOpt = (flag, fallback) => {
|
||||
const index = args.indexOf(flag);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173').replace(/\/$/, '');
|
||||
const TEETH = args.includes('--teeth');
|
||||
const HEADFUL = args.includes('--headful');
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun');
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean);
|
||||
|
||||
const results = [];
|
||||
let currentSection = 'setup';
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail, section: currentSection });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one independent section.
|
||||
*
|
||||
* Sections are isolated on purpose. The first version of this harness ran as a
|
||||
* single straight line, so the FIRST throw ended the run — under `--teeth` that
|
||||
* meant it stopped at 9 of 38 checks and never exercised the mission, viewport
|
||||
* or console assertions at all, while still claiming the control had teeth. A
|
||||
* throw now fails its own section and the rest still run, which is what makes
|
||||
* the per-section reachability tally below meaningful.
|
||||
*/
|
||||
async function section(name, body) {
|
||||
currentSection = name;
|
||||
console.log(`\n \x1b[2m── ${name} ──\x1b[0m`);
|
||||
try {
|
||||
await body();
|
||||
} catch (error) {
|
||||
record(`[${name}] section completed without throwing`, false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const LAUNCHER = '#first-run-launcher';
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Load the app and wait for the launcher to settle one way or the other.
|
||||
*
|
||||
* `errorSink` is emptied on the way out: navigating away aborts whatever the
|
||||
* previous page had in flight, and Chrome reports those aborts as
|
||||
* "TypeError: Failed to fetch". Those belong to this harness, not to the app,
|
||||
* so the console assertion only ever judges the page it is actually looking at.
|
||||
*/
|
||||
async function open(page, { hash = '', query = '', clearAll = true, clearSession = true, errorSink = null } = {}) {
|
||||
await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
if (clearAll || clearSession) {
|
||||
await page.evaluate((all) => {
|
||||
if (all) localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
}, clearAll);
|
||||
await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
// The launcher is revealed after the loading cover yields (~T+1.9 s).
|
||||
await page.waitForFunction(
|
||||
(sel) => !!window.__godsEyeView?.styleManager
|
||||
&& (document.querySelector(sel)?.classList.contains('visible') || !document.querySelector(sel)),
|
||||
{ timeout: 45000 },
|
||||
LAUNCHER,
|
||||
).catch(() => {});
|
||||
await sleep(400);
|
||||
if (errorSink) errorSink.length = 0;
|
||||
}
|
||||
|
||||
const launcherVisible = (page) => page.evaluate(
|
||||
(sel) => !!document.querySelector(sel)?.classList.contains('visible'),
|
||||
LAUNCHER,
|
||||
);
|
||||
|
||||
const appState = (page) => page.evaluate(() => {
|
||||
const gev = window.__godsEyeView || {};
|
||||
const sm = gev.styleManager;
|
||||
const dm = gev.dataManager;
|
||||
const carto = sm?.viewer?.camera?.positionCartographic;
|
||||
const layers = {};
|
||||
const counts = {};
|
||||
const all = dm?.getAll?.() || [];
|
||||
for (const id of [
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables',
|
||||
'local-firms', 'earthquakes', 'flights', 'military', 'rocket-launches', 'satellites',
|
||||
]) {
|
||||
layers[id] = !!dm?.isEnabled?.(id);
|
||||
counts[id] = all.find((entry) => entry.id === id)?.stats?.count ?? null;
|
||||
}
|
||||
const firms = all.find((entry) => entry.id === 'local-firms');
|
||||
return {
|
||||
heightKm: carto ? Math.round(carto.height / 1000) : null,
|
||||
layers,
|
||||
counts,
|
||||
contextMode: sm?.getContextModeState?.().mode ?? null,
|
||||
firmsError: firms?.stats?.error ?? null,
|
||||
firmsCount: firms?.stats?.count ?? null,
|
||||
detectionOverridden: sm?._detectionUserOverridden ?? null,
|
||||
durable: localStorage.getItem('gev:first-run-mission:v1'),
|
||||
session: sessionStorage.getItem('gev:first-run-mission-session:v1'),
|
||||
layerStateBlob: localStorage.getItem('gev:layer-state:v2'),
|
||||
allocation: localStorage.getItem('gev:detection-allocation:v1'),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Click a mission tile, wait for the launcher to close, then let the layers it
|
||||
* asked for actually settle. Asserting (or screenshotting) before the feeds have
|
||||
* drawn measures the harness's own impatience, not the mission.
|
||||
*/
|
||||
/**
|
||||
* Start recording every state the GLOBAL loading chip passes through. The chip
|
||||
* is transient — sampling it once after a mission would miss a failure banner
|
||||
* that flashed and cleared — so this watches it continuously instead.
|
||||
*/
|
||||
async function watchLoadingChip(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__chipSeen = [];
|
||||
const el = document.getElementById('global-loading-status');
|
||||
if (!el) return;
|
||||
const sample = () => {
|
||||
const label = document.getElementById('global-loading-label')?.textContent?.trim() || '';
|
||||
const state = el.dataset.state || '';
|
||||
const hidden = el.hidden;
|
||||
const last = window.__chipSeen[window.__chipSeen.length - 1];
|
||||
const entry = `${hidden ? 'hidden' : 'shown'}:${state}:${label}`;
|
||||
if (entry !== last) window.__chipSeen.push(entry);
|
||||
};
|
||||
sample();
|
||||
window.__chipObserver?.disconnect();
|
||||
window.__chipObserver = new MutationObserver(sample);
|
||||
window.__chipObserver.observe(el, {
|
||||
attributes: true, childList: true, subtree: true, characterData: true,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const readLoadingChip = (page) => page.evaluate(() => window.__chipSeen || []);
|
||||
|
||||
async function pick(page, choice, { timeout = 40000, settle = [] } = {}) {
|
||||
await page.click(`[data-first-run-choice="${choice}"]`);
|
||||
await page.waitForFunction(
|
||||
(sel) => !document.querySelector(sel) || !document.querySelector(sel).classList.contains('visible'),
|
||||
{ timeout },
|
||||
LAUNCHER,
|
||||
).catch(() => {});
|
||||
if (settle.length) {
|
||||
await page.evaluate(async (ids) => {
|
||||
const dm = window.__godsEyeView?.dataManager;
|
||||
await Promise.all(ids.map((id) => dm?.waitForLayerSettled?.(id)));
|
||||
}, settle).catch(() => {});
|
||||
}
|
||||
// Plus a render beat: settled data still has to reach the framebuffer.
|
||||
await sleep(settle.length ? 3500 : 1500);
|
||||
}
|
||||
|
||||
/** Every section the run is expected to reach; the teeth verdict requires all. */
|
||||
const EXPECTED_SECTIONS = [
|
||||
'show-policy', 'esc-arbitration',
|
||||
'mission-environmental', 'mission-contacts', 'mission-space', 'mission-explore',
|
||||
'viewports', 'console',
|
||||
];
|
||||
|
||||
/*
|
||||
* Sections that are NOT expected to go red under --teeth.
|
||||
*
|
||||
* `console` is a hygiene check, not a launcher check: with the launcher removed
|
||||
* the page legitimately still logs nothing, so demanding a red there would be
|
||||
* demanding a false failure. Everything else exists to test the launcher and
|
||||
* must collapse without it. This list is deliberately tiny and named — it is the
|
||||
* one place a section can be excused, so an unjustified entry is visible.
|
||||
*/
|
||||
const HYGIENE_SECTIONS = ['console'];
|
||||
|
||||
/**
|
||||
* ESC arbitration — the two defects that motivated the yield design, plus the
|
||||
* yield itself. Driven through the real body classes, because that is exactly
|
||||
* what Cockpit and the Scene director set.
|
||||
*/
|
||||
async function runArbitrationSection(page, { shots, consoleErrors }) {
|
||||
await section('esc-arbitration', async () => {
|
||||
const launcherState = () => page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
const rect = node?.getBoundingClientRect();
|
||||
// Topmost, not merely present: hit-test the card's own centre the way the
|
||||
// launcher does, so an overlay that leaves the box intact is visible here.
|
||||
const hit = rect && rect.width > 0 && rect.height > 0
|
||||
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||||
: null;
|
||||
return {
|
||||
present: !!node,
|
||||
classVisible: !!node?.classList.contains('visible'),
|
||||
// Real visibility: `display:none` yields zero client rects.
|
||||
onScreen: !!node && node.getClientRects().length > 0,
|
||||
topmost: !!(hit && node && node.contains(hit)),
|
||||
session: sessionStorage.getItem('gev:first-run-mission-session:v1'),
|
||||
};
|
||||
}, LAUNCHER);
|
||||
|
||||
// ── Blocker 2 repro: a Scene starts while the launcher is up ────────────
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeScene = await launcherState();
|
||||
record('the launcher is up before the scene starts', beforeScene.onScreen);
|
||||
|
||||
await page.evaluate(() => document.body.classList.add('scene-playback-mode'));
|
||||
await sleep(300);
|
||||
const duringScene = await launcherState();
|
||||
record(
|
||||
'a scene taking the screen makes the launcher YIELD, not just hide',
|
||||
beforeScene.onScreen && !duringScene.classVisible,
|
||||
`onScreen=${duringScene.onScreen} classVisible=${duringScene.classVisible} — a hidden card that kept "visible" is the defect`,
|
||||
);
|
||||
record('yielding is session-scoped, like any other dismissal',
|
||||
duringScene.session === 'dismissed', `session=${duringScene.session}`);
|
||||
|
||||
// The key that used to dismiss an invisible launcher must now do nothing
|
||||
// to it — proven by the scene class still being set afterwards.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
const afterEsc = await page.evaluate(() => ({
|
||||
sceneStillOn: document.body.classList.contains('scene-playback-mode'),
|
||||
}));
|
||||
record(
|
||||
'ESC during a scene never lands on the launcher',
|
||||
afterEsc.sceneStillOn,
|
||||
afterEsc.sceneStillOn ? 'scene class untouched by the launcher' : 'something else consumed the key',
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('scene-playback-mode'));
|
||||
await sleep(200);
|
||||
const afterScene = await launcherState();
|
||||
record('a yielded launcher does not pop back when the scene ends',
|
||||
!afterScene.classVisible, `classVisible=${afterScene.classVisible}`);
|
||||
|
||||
// ── Blocker 3 repro: Cockpit engages while the launcher is up ───────────
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeCockpit = await launcherState();
|
||||
record('the launcher is up before cockpit engages', beforeCockpit.onScreen);
|
||||
await page.evaluate(() => document.body.classList.add('cockpit-mode'));
|
||||
await sleep(300);
|
||||
const duringCockpit = await launcherState();
|
||||
record(
|
||||
'cockpit engaging makes the launcher YIELD before it can contest ESC',
|
||||
beforeCockpit.onScreen && !duringCockpit.classVisible,
|
||||
`classVisible=${duringCockpit.classVisible} — cockpit registers its capture listener first, so stacking must never arise`,
|
||||
);
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
await sleep(200);
|
||||
|
||||
// ── Blocker repro: a surface that takes the screen with NO class ────────
|
||||
// The attribution lightbox is full-screen at z-index 200 against the card's
|
||||
// 175 and announces itself with nothing at all. The card keeps its box, so a
|
||||
// getClientRects()-only check called it visible: ESC dismissed a launcher
|
||||
// the visitor could not see, and burned the session flag, behind a lightbox
|
||||
// that binds no ESC of its own and therefore stayed open.
|
||||
const lightboxState = () => page.evaluate(() => {
|
||||
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
|
||||
return {
|
||||
present: !!overlay,
|
||||
shown: !!overlay && getComputedStyle(overlay).display !== 'none',
|
||||
z: overlay ? getComputedStyle(overlay).zIndex : null,
|
||||
};
|
||||
});
|
||||
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeLightbox = await launcherState();
|
||||
record('the launcher is up before the attribution lightbox opens', beforeLightbox.onScreen);
|
||||
|
||||
const linkClicked = await page.evaluate(() => {
|
||||
const link = document.querySelector('#cesium-credits .cesium-credit-expand-link');
|
||||
link?.click();
|
||||
return !!link;
|
||||
});
|
||||
await sleep(300);
|
||||
const lightboxUp = await lightboxState();
|
||||
record(
|
||||
'the real "Data attribution" lightbox opens above the card',
|
||||
linkClicked && lightboxUp.shown && lightboxUp.z === '200',
|
||||
`link=${linkClicked} shown=${lightboxUp.shown} z-index=${lightboxUp.z} vs the launcher's 175`,
|
||||
);
|
||||
const buried = await launcherState();
|
||||
record(
|
||||
'an unclassed overlay leaves the card MEASURABLE but no longer topmost',
|
||||
buried.onScreen && !buried.topmost,
|
||||
`onScreen=${buried.onScreen} topmost=${buried.topmost} — the class watch cannot see this surface at all`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const escUnderLightbox = await launcherState();
|
||||
const lightboxAfterEsc = await lightboxState();
|
||||
record(
|
||||
'ESC under the lightbox never lands on the buried launcher',
|
||||
beforeLightbox.onScreen && escUnderLightbox.classVisible,
|
||||
`classVisible=${escUnderLightbox.classVisible} — the card was up, covered, and must stay up`,
|
||||
);
|
||||
record(
|
||||
'that ESC does not burn the session flag either',
|
||||
beforeLightbox.onScreen && escUnderLightbox.session !== 'dismissed',
|
||||
`session=${escUnderLightbox.session}`,
|
||||
);
|
||||
record(
|
||||
'the lightbox keeps its OWN ESC semantics (Cesium binds none, so it stays)',
|
||||
lightboxAfterEsc.shown,
|
||||
`overlay shown=${lightboxAfterEsc.shown}`,
|
||||
);
|
||||
|
||||
// The guard disarms the launcher; it must never break it. Close the overlay
|
||||
// the way a visitor does and the key comes straight back.
|
||||
await page.evaluate(() => document.querySelector('.cesium-credit-lightbox-close')?.click());
|
||||
await sleep(300);
|
||||
const uncovered = await launcherState();
|
||||
record('closing the lightbox hands the card back the key',
|
||||
uncovered.topmost, `topmost=${uncovered.topmost}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const afterUncoveredEsc = await launcherState();
|
||||
record(
|
||||
'ESC dismisses normally again once nothing is on top',
|
||||
beforeLightbox.onScreen && !afterUncoveredEsc.classVisible
|
||||
&& afterUncoveredEsc.session === 'dismissed',
|
||||
`classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`,
|
||||
);
|
||||
|
||||
// ── Blocker repro: a control that claims only the KEY ───────────────────
|
||||
// The compact Radio disclosure closes on ESC from a capture listener bound
|
||||
// long before this module exists. It called stopPropagation(), which does
|
||||
// NOT stop later listeners on the same document — so one key closed the
|
||||
// disclosure AND dismissed the launcher. The card is not hiding behind this
|
||||
// one and must not yield to it: the key is simply already spoken for.
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeRadio = await launcherState();
|
||||
record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen);
|
||||
const radioOpened = await page.evaluate(() => {
|
||||
document.getElementById('context-radio-toggle-btn')?.click();
|
||||
return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open');
|
||||
});
|
||||
await sleep(250);
|
||||
record('the compact Radio disclosure opens', radioOpened);
|
||||
const withRadio = await launcherState();
|
||||
record(
|
||||
'a small disclosure is a key contest, not a screen takeover — no yield',
|
||||
withRadio.classVisible && withRadio.topmost,
|
||||
`classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`,
|
||||
);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
const radioAfterEsc = await page.evaluate(() => (
|
||||
!!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')
|
||||
));
|
||||
const launcherAfterRadioEsc = await launcherState();
|
||||
record('ESC closes the disclosure', radioOpened && !radioAfterEsc,
|
||||
`disclosure still open=${radioAfterEsc}`);
|
||||
record(
|
||||
'that SAME ESC does not also dismiss the launcher',
|
||||
beforeRadio.onScreen && launcherAfterRadioEsc.classVisible
|
||||
&& launcherAfterRadioEsc.session !== 'dismissed',
|
||||
`classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`,
|
||||
);
|
||||
// ...and the NEXT press is the launcher's, so nothing was permanently taken.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const escAfterRadioClosed = await launcherState();
|
||||
record(
|
||||
'the next ESC belongs to the launcher again',
|
||||
beforeRadio.onScreen && !escAfterRadioClosed.classVisible
|
||||
&& escAfterRadioClosed.session === 'dismissed',
|
||||
`classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`,
|
||||
);
|
||||
|
||||
// ── Deferred reveal: a surface already up when the launcher would show ──
|
||||
// The class goes on as soon as the app exists, which is well before the
|
||||
// launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not
|
||||
// own. Cockpit's own exit() strips this class, so it is re-asserted right up
|
||||
// to the check rather than set once and hoped for.
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
// Install the synthetic blocker before any application module can run. A
|
||||
// warm Vite cache can otherwise finish first-run initialization between
|
||||
// DOMContentLoaded and the first page.evaluate(), turning this into the
|
||||
// already-covered "surface engages after reveal" case and burning the
|
||||
// session flag exactly as that path is designed to do.
|
||||
const earlyCockpitBlocker = await page.evaluateOnNewDocument(() => {
|
||||
const blockAsSoonAsBodyExists = () => {
|
||||
if (!document.body) return false;
|
||||
document.body.classList.add('cockpit-mode');
|
||||
return true;
|
||||
};
|
||||
if (blockAsSoonAsBodyExists()) return;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!blockAsSoonAsBodyExists()) return;
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
try {
|
||||
await page.goto(`${APP_URL}/?welcome=1`, { waitUntil: 'domcontentloaded' });
|
||||
} finally {
|
||||
await page.removeScriptToEvaluateOnNewDocument(earlyCockpitBlocker.identifier);
|
||||
}
|
||||
await page.waitForFunction(() => !!document.body, { timeout: 45000 }).catch(() => {});
|
||||
const holdCockpit = async (ms) => {
|
||||
const until = Date.now() + ms;
|
||||
while (Date.now() < until) {
|
||||
await page.evaluate(() => document.body?.classList.add('cockpit-mode')).catch(() => {});
|
||||
await sleep(200);
|
||||
}
|
||||
};
|
||||
await holdCockpit(5000);
|
||||
const withCockpitUp = await launcherState();
|
||||
const classHeld = await page.evaluate(() => document.body.classList.contains('cockpit-mode'));
|
||||
record(
|
||||
'the launcher WAITS rather than appearing over a surface already up',
|
||||
classHeld && withCockpitUp.present && !withCockpitUp.classVisible,
|
||||
`cockpitClassHeld=${classHeld} present=${withCockpitUp.present} classVisible=${withCockpitUp.classVisible}`,
|
||||
);
|
||||
record('waiting does not burn the session flag',
|
||||
withCockpitUp.session !== 'dismissed', `session=${withCockpitUp.session}`);
|
||||
|
||||
// ...and appears once that surface clears.
|
||||
await page.evaluate(() => document.body.classList.remove('cockpit-mode'));
|
||||
await sleep(800);
|
||||
const afterCockpitCleared = await launcherState();
|
||||
record('the launcher appears once the surface clears',
|
||||
afterCockpitCleared.onScreen, `onScreen=${afterCockpitCleared.onScreen}`);
|
||||
if (afterCockpitCleared.onScreen) shots.push(await shoot(page, 'arbitration-revealed-after-cockpit'));
|
||||
});
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
// The teeth run deliberately has no launcher, so its frames are pictures of
|
||||
// an empty loading screen. Writing them would overwrite the real evidence
|
||||
// this directory exists to hold, so the control captures nothing.
|
||||
if (TEETH) return `${name}.png (skipped: teeth run)`;
|
||||
const file = path.join(SHOT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
return path.relative(ROOT, file);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nFirst-Run Mission Launcher QA');
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${TEETH ? 'TEETH (launcher suppressed — expect RED)' : 'normal'}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${APP_URL}/`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const executablePath = CHROME_CANDIDATES.find((candidate) => {
|
||||
try { return fs.existsSync(candidate); } catch { return false; }
|
||||
});
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist',
|
||||
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
const shots = [];
|
||||
const consoleErrors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (!/Failed to load resource.*(404|429|503)/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||
if (TEETH) {
|
||||
// Negative control: the markup never arrives, so nothing can be revealed.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('first-run-launcher')?.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await section('show-policy', async () => {
|
||||
await open(page);
|
||||
const freshVisible = await launcherVisible(page);
|
||||
record('a fresh session gets the launcher', freshVisible);
|
||||
if (freshVisible) shots.push(await shoot(page, 'launcher-desktop'));
|
||||
|
||||
const focused = await page.evaluate(() => document.activeElement?.dataset?.firstRunChoice ?? null);
|
||||
record('focus lands on the first mission tile', focused === 'contacts', `activeElement=${focused}`);
|
||||
|
||||
// The card is a flex column (so its list can scroll on short viewports),
|
||||
// and an author `display` on this id outranks the UA's `[hidden]` rule.
|
||||
// Prove the attribute still hides it, or it sits in the a11y tree from
|
||||
// page parse until reveal — and on a share link, until removal.
|
||||
const hiddenHonored = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
node.hidden = true;
|
||||
const display = getComputedStyle(node).display;
|
||||
node.hidden = false;
|
||||
return display;
|
||||
}, LAUNCHER);
|
||||
record('the hidden attribute still hides the flex card', hiddenHonored === 'none',
|
||||
`computed display while hidden = ${hiddenHonored}`);
|
||||
|
||||
const tiles = await page.$$eval('[data-first-run-choice]', (nodes) => nodes.map((n) => n.dataset.firstRunChoice));
|
||||
record(
|
||||
'four tiles in the owner\'s order',
|
||||
JSON.stringify(tiles) === JSON.stringify(['contacts', 'space-missions', 'environmental', 'explore']),
|
||||
tiles.join(' · '),
|
||||
);
|
||||
|
||||
// ESC dismisses. Stated as a TRANSITION (was up, now gone) — "not visible"
|
||||
// alone passes vacuously whenever the launcher never appeared at all.
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(600);
|
||||
const state = await appState(page);
|
||||
record('ESC dismisses the launcher', freshVisible && !(await launcherVisible(page)),
|
||||
freshVisible ? 'visible → dismissed' : 'never appeared, so nothing was dismissed');
|
||||
record('ESC writes the SESSION flag only', state.session === 'dismissed' && state.durable === null,
|
||||
`session=${state.session} durable=${state.durable}`);
|
||||
|
||||
// Same session, reload → stays gone. Also non-vacuous: it only means
|
||||
// anything if this session had seen the launcher in the first place.
|
||||
await open(page, { clearAll: false, clearSession: false });
|
||||
record('a reload in the same session does not re-nag',
|
||||
freshVisible && !(await launcherVisible(page)),
|
||||
freshVisible ? 'seen this session, stayed away on reload' : 'never appeared, so the check is empty');
|
||||
|
||||
// New session (sessionStorage cleared, localStorage kept) → back.
|
||||
await open(page, { clearAll: false, clearSession: true });
|
||||
record('the next fresh session gets it again', await launcherVisible(page));
|
||||
|
||||
// Checkbox → durable suppression.
|
||||
await page.click('[data-first-run-suppress]');
|
||||
await sleep(200);
|
||||
const ticked = await appState(page);
|
||||
record('the checkbox writes durable suppression immediately', ticked.durable === 'suppressed', `durable=${ticked.durable}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(400);
|
||||
await open(page, { clearAll: false, clearSession: true });
|
||||
record('a suppressed profile stays quiet in later sessions',
|
||||
ticked.durable === 'suppressed' && !(await launcherVisible(page)),
|
||||
ticked.durable === 'suppressed' ? 'suppressed and stayed away' : 'nothing was ever suppressed');
|
||||
|
||||
// ?welcome=1 outranks the checkbox; ?welcome=0 suppresses a fresh session.
|
||||
await open(page, { clearAll: false, clearSession: true, query: "?welcome=1", errorSink: consoleErrors });
|
||||
record('?welcome=1 replays past durable suppression', await launcherVisible(page));
|
||||
await open(page, { query: '?welcome=0' });
|
||||
record('?welcome=0 suppresses a fresh session', !(await launcherVisible(page)));
|
||||
|
||||
// Share links bypass entirely.
|
||||
await open(page, { hash: '#lat=30.2672&lon=-97.7431&alt=2500' });
|
||||
const shareState = await page.evaluate(() => !!window.__godsEyeView?.styleManager?.hasShareState);
|
||||
const shareShowed = await launcherVisible(page);
|
||||
record('a share link bypasses the launcher', shareState && !shareShowed,
|
||||
`hasShareState=${shareState} launcherVisible=${shareShowed}`);
|
||||
});
|
||||
|
||||
await runArbitrationSection(page, { shots, consoleErrors });
|
||||
|
||||
// ── Mission outcomes ────────────────────────────────────────────────────
|
||||
// One section PER MISSION: a throw in one (a tile that never rendered under
|
||||
// --teeth, say) must not take the other four down with it, or the teeth
|
||||
// tally reports "reached" for assertions that never ran.
|
||||
let state;
|
||||
let before;
|
||||
await section('mission-environmental', async () => {
|
||||
// Full clear, not just the session: layer enables are DURABLE, so without it
|
||||
// an earlier mission's layers ride along and this stops measuring
|
||||
// Environmental alone (and its screenshot stops showing it alone).
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
before = await appState(page);
|
||||
await watchLoadingChip(page);
|
||||
await pick(page, 'environmental', { settle: ['earthquakes', 'local-firms'] });
|
||||
state = await appState(page);
|
||||
record('ENVIRONMENTAL leaves the detection override untouched',
|
||||
state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`);
|
||||
record('a mission never auto-suppresses itself', state.durable === null, `durable=${state.durable}`);
|
||||
record('a mission never writes the detection-allocation pref',
|
||||
state.allocation === before.allocation, `${before.allocation} → ${state.allocation}`);
|
||||
record('ENVIRONMENTAL enables BOTH of its feeds', state.layers.earthquakes && state.layers['local-firms'],
|
||||
`quakes=${state.layers.earthquakes} fires=${state.layers['local-firms']}`);
|
||||
record('ENVIRONMENTAL loads real quake records', (state.counts.earthquakes ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes`);
|
||||
record('ENVIRONMENTAL reaches the full-earth camera', state.heightKm !== null && state.heightKm > 12000,
|
||||
`${state.heightKm} km`);
|
||||
|
||||
/*
|
||||
* The tile optimizes for the FULLY CONFIGURED app, so the assertion splits
|
||||
* on what this server actually has rather than pretending one answer fits
|
||||
* both. The branch is reported, so a green run always says which one it was.
|
||||
*
|
||||
* KEYED — both datasets must actually arrive, and a failure banner in
|
||||
* that state is a real defect, so the chip IS asserted.
|
||||
* KEYLESS — only the LAYER ROW is asserted: FIRMS reports KEY REQUIRED,
|
||||
* which is the honest surface a keyless visitor is judged on.
|
||||
* The GLOBAL chip is deliberately NOT asserted in either
|
||||
* direction here: it has no key-required terminal state and
|
||||
* folds that row into a misleading LOAD FAILED. That
|
||||
* aggregation is a defect in the shared state machine
|
||||
* (`src/loadingFeedback.js`), LEDGERED post-launch — it is not
|
||||
* a desirable outcome and not this tile's contract.
|
||||
*/
|
||||
const keyless = state.firmsError === 'KEY REQUIRED';
|
||||
console.log(` \x1b[2m FIRMS key state: ${keyless ? 'KEYLESS' : 'KEYED'} `
|
||||
+ `(row="${state.firmsError ?? 'none'}", count=${state.firmsCount})\x1b[0m`);
|
||||
if (keyless) {
|
||||
record(
|
||||
'KEYLESS: the FIRMS row tells the visitor the truth (KEY REQUIRED)',
|
||||
state.firmsError === 'KEY REQUIRED',
|
||||
`layer row reports "${state.firmsError}"`,
|
||||
);
|
||||
record(
|
||||
'KEYLESS: the quakes half of the tile still delivers in full',
|
||||
(state.counts.earthquakes ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes`,
|
||||
);
|
||||
} else {
|
||||
record(
|
||||
'KEYED: both datasets actually arrive',
|
||||
(state.counts.earthquakes ?? 0) > 0 && (state.firmsCount ?? 0) > 0,
|
||||
`${state.counts.earthquakes} quakes · ${state.firmsCount} fire detections`,
|
||||
);
|
||||
const chip = await readLoadingChip(page);
|
||||
const failed = chip.filter((entry) => /LOAD FAILED/i.test(entry));
|
||||
// A STALE served-from-cache FIRMS payload is degraded, not failed, and it
|
||||
// still carries data — so the failure test is the banner, not any
|
||||
// non-empty error string.
|
||||
record(
|
||||
'KEYED: no unexpected global failure while the mission runs',
|
||||
failed.length === 0,
|
||||
failed.length
|
||||
? `chip showed: ${failed.join(' | ')}`
|
||||
: `firms row="${state.firmsError ?? 'none'}"; chip states seen: ${chip.join(' → ') || 'none'}`,
|
||||
);
|
||||
}
|
||||
record(
|
||||
'no mission turns on the bundled infrastructure layers any more',
|
||||
!state.layers['local-datacenters'] && !state.layers['local-dams']
|
||||
&& !state.layers['telegeography-submarine-cables'],
|
||||
'the removed tile left nothing enabling ~5,700 entities at globe scale',
|
||||
);
|
||||
shots.push(await shoot(page, 'mission-environmental'));
|
||||
});
|
||||
|
||||
await section('mission-contacts', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
await pick(page, 'contacts');
|
||||
state = await appState(page);
|
||||
record('LIVE CONTACTS activates the Contacts context mode', state.contextMode === 'flights',
|
||||
`contextMode=${state.contextMode}`);
|
||||
const panelOpen = await page.evaluate(() => {
|
||||
const panel = document.getElementById('global-context-panel');
|
||||
return !!panel && !panel.classList.contains('collapsed');
|
||||
});
|
||||
record('LIVE CONTACTS reveals the Context panel', panelOpen);
|
||||
record('LIVE CONTACTS still leaves the detection override untouched',
|
||||
state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`);
|
||||
shots.push(await shoot(page, 'mission-contacts'));
|
||||
});
|
||||
|
||||
await section('mission-space', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
await pick(page, 'space-missions');
|
||||
state = await appState(page);
|
||||
record('SPACE MISSIONS activates its context mode', state.contextMode === 'space-missions',
|
||||
`contextMode=${state.contextMode}`);
|
||||
shots.push(await shoot(page, 'mission-space-missions'));
|
||||
});
|
||||
|
||||
await section('mission-explore', async () => {
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const beforeExplore = await appState(page);
|
||||
await pick(page, 'explore');
|
||||
state = await appState(page);
|
||||
record('EXPLORE MANUALLY enables no layer and no context mode',
|
||||
Object.values(state.layers).every((on) => !on) && state.contextMode === null,
|
||||
Object.entries(state.layers).filter(([, on]) => on).map(([id]) => id).join(', ') || 'nothing enabled');
|
||||
// The real claim is that Explore issues no camera command of its own: the
|
||||
// arrival fly-in owns the camera and must still be heading down, not out.
|
||||
record('EXPLORE MANUALLY never commandeers the camera',
|
||||
(state.heightKm ?? 0) < 12000 && (state.heightKm ?? 1e9) <= (beforeExplore.heightKm ?? 1e9),
|
||||
`${beforeExplore.heightKm} → ${state.heightKm} km (globe would be 18000)`);
|
||||
shots.push(await shoot(page, 'mission-explore'));
|
||||
});
|
||||
|
||||
// ── Viewports ───────────────────────────────────────────────────────────
|
||||
await section('viewports', async () => {
|
||||
// Portrait phone, LANDSCAPE phone (the short viewport that clipped the card
|
||||
// at both ends before it grew a max-height), and a small desktop window.
|
||||
const VIEWPORTS = [
|
||||
{ name: 'mobile-375', width: 375, height: 812, dpr: 2, label: '375 x 812 portrait phone' },
|
||||
{ name: 'landscape-667', width: 667, height: 390, dpr: 2, label: '667 x 390 landscape phone' },
|
||||
{ name: 'desktop-800', width: 800, height: 600, dpr: 1, label: '800 x 600 small desktop' },
|
||||
];
|
||||
for (const vp of VIEWPORTS) {
|
||||
await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: vp.dpr });
|
||||
await open(page, { query: "?welcome=1", errorSink: consoleErrors });
|
||||
const box = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const list = node.querySelector('.first-run-choices');
|
||||
const checkbox = node.querySelector('[data-first-run-suppress]');
|
||||
const cb = checkbox?.getBoundingClientRect();
|
||||
return {
|
||||
top: Math.round(rect.top), bottom: Math.round(rect.bottom),
|
||||
left: Math.round(rect.left), right: Math.round(rect.right),
|
||||
vw: window.innerWidth, vh: window.innerHeight,
|
||||
listScrolls: !!list && list.scrollHeight > list.clientHeight + 1,
|
||||
// The checkbox and the status line live OUTSIDE the scrolling list, so
|
||||
// they must be on screen at every height, scrollable list or not.
|
||||
checkboxOnScreen: !!cb && cb.top >= 0 && cb.bottom <= window.innerHeight,
|
||||
};
|
||||
}, LAUNCHER);
|
||||
const fits = !!box && box.top >= 0 && box.bottom <= box.vh
|
||||
&& box.left >= 0 && box.right <= box.vw;
|
||||
record(
|
||||
`the launcher fits a ${vp.label} with no clipping`,
|
||||
fits,
|
||||
box ? `${box.left},${box.top} → ${box.right},${box.bottom} in ${box.vw}x${box.vh}${box.listScrolls ? ' (list scrolls)' : ''}` : 'absent',
|
||||
);
|
||||
record(`"Don't show this again" stays on screen at ${vp.width}x${vp.height}`,
|
||||
!!box?.checkboxOnScreen);
|
||||
|
||||
// Discoverability: if the list scrolls, the LAST tile must still peek
|
||||
// above the fold. A fold that lands exactly between tiles looks like a
|
||||
// complete list of four, and the fifth mission is simply never found.
|
||||
const peek = await page.evaluate((sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
const list = node?.querySelector('.first-run-choices');
|
||||
if (!list) return null;
|
||||
list.scrollTop = 0;
|
||||
const tiles = [...list.querySelectorAll('[data-first-run-choice]')];
|
||||
const last = tiles[tiles.length - 1];
|
||||
if (!last) return null;
|
||||
const listBox = list.getBoundingClientRect();
|
||||
const lastBox = last.getBoundingClientRect();
|
||||
const visible = Math.max(0, Math.min(listBox.bottom, lastBox.bottom) - Math.max(listBox.top, lastBox.top));
|
||||
return {
|
||||
scrolls: list.scrollHeight > list.clientHeight + 1,
|
||||
fraction: lastBox.height ? visible / lastBox.height : 0,
|
||||
label: last.dataset.firstRunChoice,
|
||||
};
|
||||
}, LAUNCHER);
|
||||
record(
|
||||
`the last mission is discoverable at ${vp.width}x${vp.height}`,
|
||||
!!peek && (!peek.scrolls || peek.fraction >= 0.25),
|
||||
peek
|
||||
? `${peek.label} ${(peek.fraction * 100).toFixed(0)}% visible${peek.scrolls ? ' (list scrolls)' : ' (no scroll needed)'}`
|
||||
: 'absent',
|
||||
);
|
||||
|
||||
// Every tile plus the checkbox must still be reachable by Tab when the
|
||||
// list scrolls — and reaching one must bring it into view.
|
||||
const reach = await page.evaluate(async (sel) => {
|
||||
const node = document.querySelector(sel);
|
||||
if (!node) return null;
|
||||
const targets = [...node.querySelectorAll('[data-first-run-choice], [data-first-run-suppress]')];
|
||||
const offscreen = [];
|
||||
for (const target of targets) {
|
||||
target.focus();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rect = target.getBoundingClientRect();
|
||||
const host = node.getBoundingClientRect();
|
||||
if (document.activeElement !== target
|
||||
|| rect.bottom <= host.top || rect.top >= host.bottom) {
|
||||
offscreen.push(target.dataset.firstRunChoice || 'suppress-checkbox');
|
||||
}
|
||||
}
|
||||
return { count: targets.length, offscreen };
|
||||
}, LAUNCHER);
|
||||
// Derived, not a magic number: every rendered tile plus the checkbox.
|
||||
// A hardcoded count silently turns into a failure the day the menu
|
||||
// changes, which is exactly what it did when a tile was removed.
|
||||
const expectedFocusable = await page.$$eval(
|
||||
'[data-first-run-choice]', (nodes) => nodes.length,
|
||||
) + 1;
|
||||
record(
|
||||
`every tile and the checkbox are keyboard-reachable at ${vp.width}x${vp.height}`,
|
||||
!!reach && reach.count === expectedFocusable && reach.offscreen.length === 0,
|
||||
reach
|
||||
? `${reach.count}/${expectedFocusable} focusable${reach.offscreen.length ? `, unreachable: ${reach.offscreen.join(', ')}` : ''}`
|
||||
: 'absent',
|
||||
);
|
||||
// The reachability probe above tabs to the last tile and scrolls the list
|
||||
// with it. Put it back to the top so the taste-pass shot shows what a
|
||||
// visitor actually opens on.
|
||||
await page.evaluate((sel) => {
|
||||
const list = document.querySelector(sel)?.querySelector('.first-run-choices');
|
||||
if (list) list.scrollTop = 0;
|
||||
document.activeElement?.blur?.();
|
||||
}, LAUNCHER);
|
||||
await sleep(250);
|
||||
shots.push(await shoot(page, `launcher-${vp.name}`));
|
||||
}
|
||||
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||
});
|
||||
|
||||
await section('console', async () => {
|
||||
record('no console errors across the whole run', consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || 'clean');
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((entry) => !entry.ok);
|
||||
const sections = [...new Set(results.map((entry) => entry.section))];
|
||||
const tally = sections.map((name) => {
|
||||
const rows = results.filter((entry) => entry.section === name);
|
||||
return { name, total: rows.length, red: rows.filter((entry) => !entry.ok).length };
|
||||
});
|
||||
|
||||
console.log(`\n Screenshots: ${shots.join(', ')}`);
|
||||
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
|
||||
|
||||
if (!TEETH) process.exit(failed.length === 0 ? 0 : 1);
|
||||
|
||||
/*
|
||||
* NEGATIVE CONTROL VERDICT.
|
||||
*
|
||||
* Two things have to be true for the control to mean anything, and the old
|
||||
* version checked neither properly: it exited ZERO after a single throw ended
|
||||
* the run at 9 of 38 checks, so most assertions were never even reached.
|
||||
*
|
||||
* (a) the harness must REPORT FAILURE under the stub — a run with the
|
||||
* launcher removed is a failing run, and it exits non-zero like one;
|
||||
* (b) EVERY section must produce at least one red — a section that stays
|
||||
* fully green without the launcher is not testing the launcher.
|
||||
*
|
||||
* Because (a) means the process always exits non-zero here, the control's own
|
||||
* verdict is carried in the exit CODE: 1 = healthy, 2 = the control is broken.
|
||||
*/
|
||||
console.log(' TEETH — per-section reachability (each section must go red):');
|
||||
for (const row of tally) {
|
||||
const hygiene = HYGIENE_SECTIONS.includes(row.name);
|
||||
const ok = row.red > 0 || hygiene;
|
||||
const tag = hygiene ? '\x1b[2mHYGN\x1b[0m' : (row.red > 0 ? '\x1b[32mRED \x1b[0m' : '\x1b[31mNONE\x1b[0m');
|
||||
void ok;
|
||||
console.log(` [${tag}] ${row.name}: ${row.red}/${row.total} red`);
|
||||
}
|
||||
const toothless = tally.filter((row) => row.red === 0 && !HYGIENE_SECTIONS.includes(row.name));
|
||||
const unreached = EXPECTED_SECTIONS.filter((name) => !sections.includes(name));
|
||||
const healthy = failed.length > 0 && toothless.length === 0 && unreached.length === 0;
|
||||
if (unreached.length) console.log(`\n UNREACHED sections: ${unreached.join(', ')}`);
|
||||
if (toothless.length) console.log(` TOOTHLESS sections: ${toothless.map((row) => row.name).join(', ')}`);
|
||||
console.log(healthy
|
||||
? `\n TEETH HEALTHY: harness failed as required (${failed.length} red) and every one of ${tally.length} sections went red. Exit 1 by design.\n`
|
||||
: '\n TEETH BROKEN: the control did not collapse the way it claims to. Exit 2.\n');
|
||||
process.exit(healthy ? 1 : 2);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floor-hold.mjs — a grounded contact holds its floor through a
|
||||
* terrain-proxy outage (owner incident, 2026-08-21).
|
||||
*
|
||||
* Reproduces the incident end to end against the RENDERED mesh:
|
||||
*
|
||||
* phase A the terrain proxy is healthy. A GROUNDED contact reporting no
|
||||
* altitude at all (the AXEL21 profile — a few knots, alt unknown)
|
||||
* settles onto its resolved floor.
|
||||
* phase B every `/api/terrain/heights` request answers 504 — the same
|
||||
* "[terrain-heights-proxy] refresh incomplete" the server logged —
|
||||
* and the contact taxis into cells that were never warmed.
|
||||
*
|
||||
* The contact must stay ON the mesh through phase B. Without the hold it falls
|
||||
* to the geoid, which at an inland field is ~150 m of burial.
|
||||
*
|
||||
* node scripts/qa-floor-hold.mjs # real GPU (ANGLE/Metal)
|
||||
* node scripts/qa-floor-hold.mjs --headful # watch it
|
||||
* QA_BASE_URL=http://localhost:4257 node scripts/qa-floor-hold.mjs
|
||||
*
|
||||
* GPU BACKEND MATTERS. `scene.sampleHeight` answers on both backends, but
|
||||
* software rasterization streams a coarser LOD. Measured directly, one probe
|
||||
* point on the Austin apron read ~20.9 m under swiftshader against ~168.4 m
|
||||
* under Metal — a backend comparison at one point, not to be confused with the
|
||||
* same-backend run-to-run spread (122.1 m vs 20.6 m on a cell centre) that
|
||||
* retired the hold chain's unvalidated tier. The
|
||||
* default is the real GPU. Under `--angle=swiftshader` the rendered-mesh oracle
|
||||
* is reported as unavailable rather than trusted, and the run falls back to the
|
||||
* bare-earth DEM (fetched server-side, so the simulated outage cannot reach
|
||||
* it) — a weaker but still decisive check, since the geoid sits ~150 m below it.
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const argv = Object.fromEntries(process.argv.slice(2)
|
||||
.map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true]));
|
||||
const ANGLE = String(argv.angle || 'metal');
|
||||
const HEADFUL = !!argv.headful;
|
||||
// Austin airport apron by default — the site qa-floor-verify pins, where the
|
||||
// mesh sits ~150 m above the geoid and burial is unmistakable.
|
||||
const SITE = {
|
||||
lat: Number.isFinite(Number(argv.lat)) ? Number(argv.lat) : 30.1975,
|
||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||
};
|
||||
const ICAO = 'aa0f01';
|
||||
const OUT_DIR = 'qa-shots/floorhold';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
executablePath: chrome,
|
||||
args: ['--use-gl=angle', `--use-angle=${ANGLE}`, '--enable-webgl',
|
||||
'--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
|
||||
await page.evaluateOnNewDocument((site, icao) => {
|
||||
window.__CONTACT = { icao, lat: site.lat, lon: site.lon };
|
||||
window.__TERRAIN = { ok: 0, failed: 0 };
|
||||
window.__TERRAIN_FAIL = false;
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const json = (o) => new Response(JSON.stringify(o), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
window.fetch = (input, init) => {
|
||||
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
|
||||
let url; try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
|
||||
const app = url.origin === window.location.origin;
|
||||
if (app && url.pathname === '/api/terrain/heights') {
|
||||
if (window.__TERRAIN_FAIL) {
|
||||
window.__TERRAIN.failed += 1;
|
||||
return Promise.resolve(new Response('upstream timeout', { status: 504 }));
|
||||
}
|
||||
window.__TERRAIN.ok += 1;
|
||||
return realFetch(input, init);
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky') {
|
||||
const c = window.__CONTACT;
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
|
||||
return Promise.resolve(json({
|
||||
time: t,
|
||||
states: [[c.icao, 'AXEL21', 'Synthetica', t, t, c.lon, c.lat,
|
||||
null, true, 1.5, 90, 0, null, null, null, false, 0]],
|
||||
}));
|
||||
}
|
||||
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
|
||||
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}, SITE, ICAO);
|
||||
|
||||
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 150000 });
|
||||
await sleep(12000); // let the boot fly-to settle before pinning
|
||||
|
||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||
const pin = () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
});
|
||||
await pin();
|
||||
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
||||
|
||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||
const measure = () => page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const sprites = []; const models = []; let bb = null;
|
||||
const walk = (coll) => {
|
||||
for (let i = 0; i < coll.length; i++) {
|
||||
let p; try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
sprites.push(p); if (p.id === icao) bb = p;
|
||||
} else if (p.boundingSphere && p.modelMatrix) models.push(p);
|
||||
}
|
||||
};
|
||||
try { walk(v.scene.primitives); } catch { /* mid-teardown */ }
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = C.fromCartesian(bb.position);
|
||||
const lat = carto.latitude * 180 / Math.PI;
|
||||
const lon = carto.longitude * 180 / Math.PI;
|
||||
// `sampleHeight` runs an offscreen PICK pass, so it needs the scene to be
|
||||
// rendering. This app parks its render loop when idle (the wave-2 governor),
|
||||
// and a probe fired against a parked scene — or one whose tiles are still
|
||||
// streaming after a camera move — reads `undefined`. Pump a frame through the
|
||||
// app's own governor hook before each attempt, exactly as the fly_route
|
||||
// harness does, and accept the coarse cell centre as a fallback point.
|
||||
const pump = () => new Promise((resolve) => {
|
||||
try { window.__godsEyeView.requestRender?.(); } catch { /* no governor */ }
|
||||
try { v.scene.requestRender(); } catch { /* explicit-render off */ }
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
});
|
||||
let mesh = null;
|
||||
for (let attempt = 0; attempt < 10 && mesh == null; attempt += 1) {
|
||||
await pump();
|
||||
for (const [pLat, pLon] of [[lat, lon], [+lat.toFixed(3), +lon.toFixed(3)]]) {
|
||||
for (const exclude of [[...sprites, ...models], []]) {
|
||||
try {
|
||||
const h = v.scene.sampleHeight(C.fromDegrees(pLon, pLat, 0), exclude);
|
||||
if (Number.isFinite(h)) { mesh = h; break; }
|
||||
} catch { /* mid-stream */ }
|
||||
}
|
||||
if (mesh != null) break;
|
||||
}
|
||||
if (mesh == null) await new Promise((r) => setTimeout(r, 1200));
|
||||
}
|
||||
return {
|
||||
spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||
lat: +lat.toFixed(5), lon: +lon.toFixed(5), terrain: { ...window.__TERRAIN },
|
||||
};
|
||||
}, ICAO);
|
||||
|
||||
/** Bare-earth ground truth, fetched SERVER-SIDE so the page's simulated outage
|
||||
* cannot touch it. The photoreal mesh sits ABOVE bare earth, so this is the
|
||||
* weaker of the two oracles — but the geoid is ~150 m below it, so burial is
|
||||
* still unmistakable. */
|
||||
const truth = new Map();
|
||||
async function demTruth(lat, lon) {
|
||||
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
if (truth.has(key)) return truth.get(key);
|
||||
let h = null;
|
||||
try {
|
||||
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
if (res.ok) {
|
||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||
if (Number.isFinite(v)) h = v;
|
||||
}
|
||||
} catch { /* the oracle is best-effort too */ }
|
||||
truth.set(key, h);
|
||||
return h;
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
async function sample(phase, note) {
|
||||
const m = await measure();
|
||||
if (m.error) { console.log(` ${phase} ${note}: ${m.error}`); return null; }
|
||||
const dem = await demTruth(m.lat, m.lon);
|
||||
const row = {
|
||||
phase, note, ...m, demH: dem,
|
||||
meshClearM: m.meshH != null ? +(m.spriteH - m.meshH).toFixed(2) : null,
|
||||
demClearM: dem != null ? +(m.spriteH - dem).toFixed(2) : null,
|
||||
};
|
||||
samples.push(row);
|
||||
console.log(` ${phase} ${note}: @${m.lat},${m.lon} sprite=${m.spriteH.toFixed(1)}`
|
||||
+ ` mesh=${m.meshH?.toFixed?.(1) ?? 'n/a'} (${row.meshClearM})`
|
||||
+ ` DEM=${dem?.toFixed?.(1) ?? 'n/a'} (${row.demClearM})`
|
||||
+ ` terrain=${JSON.stringify(m.terrain)}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
console.log('\nphase A — terrain healthy, warming cells (3 polls)');
|
||||
for (let i = 0; i < 3; i++) { await sleep(32000); await pin(); }
|
||||
await sample('A', 'warm');
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-A-warm-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
||||
|
||||
console.log('\nphase B — every terrain request answers 504 from here');
|
||||
await page.evaluate(() => { window.__TERRAIN_FAIL = true; });
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await page.evaluate(() => { window.__CONTACT.lon += 0.0016; }); // ~150 m east per poll
|
||||
await sleep(32000);
|
||||
await pin();
|
||||
await sample('B', `poll+${i}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-B-outage-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
||||
await browser.close();
|
||||
|
||||
console.log('');
|
||||
const outage = samples.filter((s) => s.phase === 'B');
|
||||
const meshRows = outage.filter((s) => s.meshClearM != null);
|
||||
const demRows = outage.filter((s) => s.demClearM != null);
|
||||
record('the terrain proxy really went down mid-run',
|
||||
outage.length > 0 && outage[outage.length - 1].terrain.failed > 0,
|
||||
`${outage[outage.length - 1]?.terrain?.failed ?? 0} requests answered 504`);
|
||||
record('the contact stayed visible throughout', outage.every((s) => s.shown === true));
|
||||
if (meshRows.length) {
|
||||
const worst = Math.min(...meshRows.map((s) => s.meshClearM));
|
||||
record('the contact never rendered below the RENDERED MESH', worst >= -0.5,
|
||||
`worst clearance ${worst} m across ${meshRows.length} samples`);
|
||||
} else {
|
||||
record('rendered-mesh oracle available', false,
|
||||
`sampleHeight never answered on ANGLE/${ANGLE} — falling back to the bare-earth DEM`);
|
||||
}
|
||||
if (demRows.length) {
|
||||
const worst = Math.min(...demRows.map((s) => s.demClearM));
|
||||
record('the contact never fell to the geoid (bare-earth DEM oracle)', worst >= -2,
|
||||
`worst clearance ${worst} m above bare earth; the geoid here is ~150 m below it`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT_DIR, `hold-${ANGLE}.json`), JSON.stringify(samples, null, 2));
|
||||
console.log(`\n evidence: ${OUT_DIR}/hold-*-${ANGLE}.png, ${OUT_DIR}/hold-${ANGLE}.json`);
|
||||
|
||||
const failed = results.filter((r) => r.ok === false).length;
|
||||
console.log(`\n RESULT: ${results.length - failed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,163 @@
|
||||
// scripts/qa-floor-verify.mjs — live floor verification at AUS (round 5).
|
||||
// Pins the camera at Austin airport, enables flights, waits ~3 polls, then
|
||||
// measures every nearby contact's render height against the ACTUAL rendered
|
||||
// mesh (sprite- and model-excluded scene.sampleHeight probes). Caught the mesh-latch
|
||||
// coarse-LOD poison and the taxiing cold-cell regression on 2026-07-06.
|
||||
// Run: node scripts/qa-floor-verify.mjs (dev server on :4173, real GPU best)
|
||||
// with the poison fix + simplified chain live.
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
|
||||
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a
|
||||
// secondary checkout can verify against its own dev server instead of the default :4173.
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
|
||||
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
|
||||
const SITE = {
|
||||
lat: Number.isFinite(argv.lat) ? argv.lat : 30.197,
|
||||
lon: Number.isFinite(argv.lon) ? argv.lon : -97.666,
|
||||
floorMin: Number.isFinite(argv['floor-min']) ? argv['floor-min'] : 100,
|
||||
floorMax: Number.isFinite(argv['floor-max']) ? argv['floor-max'] : 250,
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system Chrome
|
||||
// (2026-07-30 lesson, already applied to the other harnesses): /Applications
|
||||
// auto-updates underneath the harnesses and its software-GL behavior shifts
|
||||
// across majors.
|
||||
const chrome = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
].filter(Boolean).find((p) => fs.existsSync(p));
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', executablePath: chrome,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.viewer, { timeout: 150000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
||||
// setView, leaving the camera away from Austin — every probe then read
|
||||
// coarse global tiles at ~25 m and the oracle was garbage).
|
||||
await sleep(12000);
|
||||
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
|
||||
const camPin = async () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
|
||||
v.camera.moveEnd.raiseEvent();
|
||||
const c = v.camera.positionCartographic;
|
||||
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
|
||||
});
|
||||
console.log('camera pinned:', JSON.stringify(await camPin()));
|
||||
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
||||
console.log('waiting 100s: tiles stream + 3 polls...');
|
||||
await sleep(100000);
|
||||
// re-pin + report readiness right before probing
|
||||
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
||||
const ready = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const prims = v.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.tilesLoaded !== undefined && p.show) return { tilesLoaded: !!p.tilesLoaded };
|
||||
}
|
||||
return { tilesLoaded: null };
|
||||
});
|
||||
console.log('tileset ready:', JSON.stringify(ready));
|
||||
await sleep(8000);
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const layer = gev.dataManager.layers.get('flights')?.module;
|
||||
if (!layer) return { err: 'no flights module' };
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
|
||||
const nearby = layer.getNearby(center, 15000, 60);
|
||||
// Exclude EVERY billboard from the probes — sprites are pickable, so an
|
||||
// unexcluded probe can return another aircraft's height as "the mesh".
|
||||
// Exclude every fleet/tracked 3D Model too: getNearby() also returns contacts
|
||||
// whose billboard is hidden because a model owns the visual, and those probes
|
||||
// would otherwise land on aircraft geometry. `activeAnimations` +
|
||||
// `minimumPixelSize` are Model-only — the 3D-Tiles tileset has neither, and
|
||||
// excluding IT would leave sampleHeight with no mesh to read at all.
|
||||
const excludes = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { walk(pr); continue; }
|
||||
if (pr.image !== undefined && pr.alignedAxis !== undefined) { excludes.push(pr); continue; }
|
||||
if (pr.activeAnimations !== undefined && pr.minimumPixelSize !== undefined) excludes.push(pr);
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const out = [];
|
||||
for (const p of nearby) {
|
||||
const c = ell.cartesianToCartographic(p.position);
|
||||
const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI;
|
||||
let meshH = null;
|
||||
try {
|
||||
const h = v.scene.sampleHeight(C.fromDegrees(lonDeg, latDeg), excludes);
|
||||
if (Number.isFinite(h)) meshH = h;
|
||||
} catch { /* ignore */ }
|
||||
out.push({
|
||||
id: p.id,
|
||||
renderAltM: +c.height.toFixed(1),
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||
});
|
||||
}
|
||||
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
||||
// 3D model is actually drawing, so also walk the raw collections — a culled
|
||||
// "invisible plane" shows up here.
|
||||
let shown = 0, hidden = 0;
|
||||
const censusWalk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { censusWalk(pr); continue; }
|
||||
if (pr.image === undefined || pr.alignedAxis === undefined) continue;
|
||||
const cc = ell.cartesianToCartographic(pr.position);
|
||||
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||
if (pr.show) shown++; else hidden++;
|
||||
}
|
||||
};
|
||||
censusWalk(v.scene.primitives);
|
||||
return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden };
|
||||
});
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
|
||||
// verdict — plausible mesh readings only (a wild meshM means the probe ray
|
||||
// missed the tileset entirely; those rows prove nothing either way)
|
||||
// Trust only probes that read a plausible AUS-area surface (~100..250 m
|
||||
// ellipsoidal) — anything else hit a coarse tile or nothing (proves nothing).
|
||||
const lows = (report.contacts || []).filter((c) =>
|
||||
c.renderAltM < SITE.floorMax + 450 && c.aboveMeshM != null && c.meshM > SITE.floorMin && c.meshM < SITE.floorMax);
|
||||
const buried = lows.filter((c) => c.aboveMeshM < -2);
|
||||
console.log(`low contacts with plausible mesh readings: ${lows.length}; buried (< -2m): ${buried.length}`);
|
||||
for (const b of buried) {
|
||||
console.log(` BURIED ${b.id}: render ${b.renderAltM} m vs mesh ${b.meshM} m (${b.aboveMeshM} m)`);
|
||||
}
|
||||
const verdict = lows.length === 0 ? 'INCONCLUSIVE' : (buried.length === 0 ? 'PASS' : 'FAIL');
|
||||
console.log(`VERDICT: ${verdict}`);
|
||||
await browser.close();
|
||||
// Exit code (2026-08-19): this harness printed VERDICT: FAIL and still exited 0,
|
||||
// so a real regression (taxiing contacts rendering under the mesh) stayed
|
||||
// invisible to every automated caller. Now it reports like its siblings
|
||||
// (qa-height-datum / qa-cctv-v2): 0 = PASS, 1 = FAIL, 2 = could-not-measure.
|
||||
// INCONCLUSIVE is deliberately NOT 0 — a run that measured no usable contacts
|
||||
// proves nothing, and must not read as a pass. The VERDICT line is unchanged,
|
||||
// so anything scraping stdout (the L9 QA matrix) is unaffected.
|
||||
process.exit(verdict === 'PASS' ? 0 : (verdict === 'FAIL' ? 1 : 2));
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the floor-hold pins actually load-bearing?
|
||||
*
|
||||
* A pin that is red only when you delete the whole feature proves very little.
|
||||
* This reverts each fix INDIVIDUALLY — the smallest edit that reintroduces the
|
||||
* original defect — and requires the floor suites to go red for it. Every entry
|
||||
* names the defect it restores, so the count is reproducible rather than
|
||||
* asserted in a commit message.
|
||||
*
|
||||
* node scripts/qa-floorhold-mutations.mjs
|
||||
*
|
||||
* Restores every file on exit, including on failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = [
|
||||
'src/data/flights.test.mjs',
|
||||
'src/data/groundFloor.test.mjs',
|
||||
'src/data/meshFloorSampler.test.mjs',
|
||||
'src/data/renderAltitude.test.mjs',
|
||||
];
|
||||
|
||||
const FLIGHTS = 'src/data/flights.js';
|
||||
const FLOOR = 'src/data/groundFloor.js';
|
||||
const ALT = 'src/data/renderAltitude.js';
|
||||
|
||||
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
||||
const MUTATIONS = [
|
||||
{
|
||||
defect: 'the display hold is gone — a cold cell clamps nothing (the original bug)',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (true) return null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the adjacent-cell tier is gone',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const near = neighborFloorM(cell);',
|
||||
to: ' const near = null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a borrowed floor is owned forever — no upgrade when a better one warms',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (state.heldTier && !state.seeded) return state.heldM;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The round-1 shape verbatim: memo on the RAW cell floor, checked BEFORE
|
||||
// the hold chain runs. A parked contact whose cell never warms then
|
||||
// returns its own unresolved answer forever.
|
||||
defect: 'a stationary unresolved contact memo-returns before the chain can retry',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const floor = cachedGroundFloor(cell.lat, cell.lon);',
|
||||
to: ' const floor = cachedGroundFloor(cell.lat, cell.lon);\n'
|
||||
+ ' if (state && state.rawFloorM === floor\n'
|
||||
+ ' && Cesium.Cartesian3.equals(pos, state.in)) return state.out || pos;',
|
||||
},
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' next.effectiveM = effective;',
|
||||
to: ' next.effectiveM = effective;\n next.rawFloorM = floor;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the hold survives an airborne / model-owned interval',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' _retireDisplayFloorState(icao24, nowMs);\n return pos;',
|
||||
to: ' return pos;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the held floor is stretched without bound (no drift limit)',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a hold release SNAPS instead of easing',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'rises are eased too — time spent under the mesh',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (!Number.isFinite(effective) || effective >= next.easedM) {',
|
||||
to: ' if (!Number.isFinite(effective)) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the ease escapes its release scope — every floor drop eased',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The owner-playtest regression: a roof neighbour taken as ground.
|
||||
defect: 'the neighbour lean takes the HIGHEST cell and floats a contact onto a roof',
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a LONE neighbour is borrowed, with nothing to check it against',
|
||||
edits: [
|
||||
{
|
||||
file: FLOOR,
|
||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||
to: ' return resolved >= 1 ? lowest : null;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the geoid guess outranks a held render height again (the poll-step bug)',
|
||||
edits: [
|
||||
{ file: ALT, from: ' if (Number.isFinite(priorRenderM)) return null;', to: ' // guard removed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a re-probe onto a LOWER neighbour re-latches with a snap',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
to: ' if (next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The from/duration shape this replaced: a FIXED anchor interpolated by
|
||||
// CUMULATIVE elapsed time. Re-evaluated against a target that moved
|
||||
// mid-approach it jumps by the eased fraction of the change.
|
||||
defect: 'the approach interpolates a fixed anchor, so a mid-approach retarget is a seam',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' next.easeMs = nowMs;\n const closed', to: ' const closed' },
|
||||
{ file: FLIGHTS, from: ' next.easedM = value;', to: ' /* anchor stays fixed */' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a global per-tick probe budget is reintroduced over the per-contact throttle',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' state.probeMs = nowMs;\n const near = neighborFloorM(cell);',
|
||||
to: ' globalThis.__mutGlobalProbeBudget = (globalThis.__mutGlobalProbeBudget || 0) + 1;\n'
|
||||
+ ' if (globalThis.__mutGlobalProbeBudget > 12) return state.heldM;\n'
|
||||
+ ' state.probeMs = nowMs;\n const near = neighborFloorM(cell);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the per-tick step is unclamped, so a delayed tick snaps',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS));',
|
||||
to: ' const closed = 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The first cut: delete outright. An on_ground flap through a takeoff roll
|
||||
// then cold-starts the contact under the runway (owner sighting VIR138M).
|
||||
defect: 'retiring the hold DELETES it, so an on_ground flap cold-starts',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.retiredMs == null) {',
|
||||
to: ' _displayFloorState.delete(icao24);\n if (false) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the rehydration seed never expires — an hour-old floor still answers',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;',
|
||||
to: ' return false;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Expiry checked ONLY on the retire path: a contact that is parked and then
|
||||
// makes no calls at all never reaches it, and rehydration cleared
|
||||
// `retiredMs` before anything looked at it. Measured: parked 198 s, reused.
|
||||
defect: 'seed age is never validated at rehydration, only while still retired',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (_seedExpired(next, nowMs)) _dropHeldFloor(next);',
|
||||
to: ' // rehydration takes whatever was parked, however old',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// The measured float: a 200 m seed reused 0.56 km away while the new
|
||||
// neighbourhood read 100 m and 105 m.
|
||||
defect: 'a rehydrated seed outranks fresh neighbour evidence',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.heldTier === \'own\' && !state.seeded) return state.heldM;',
|
||||
to: ' if (state.heldTier === \'own\') return state.heldM;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'a parked floor is not marked as a memory, so nothing can demote it',
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' state.seeded = true; // what it answers with next is a memory, not a reading',
|
||||
to: ' state.seeded = false;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defect: 'the seed flag survives a live reading, so a measured floor stays demoted',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' state.seeded = false;\n return floorM;', to: ' return floorM;' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
const write = (f, s) => fs.writeFileSync(path.join(ROOT, f), s);
|
||||
|
||||
function runTests() {
|
||||
try {
|
||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
return { failed: 0, names: [] };
|
||||
} catch (e) {
|
||||
const raw = `${e.stdout || ''}${e.stderr || ''}`;
|
||||
const names = [...new Set([...raw.matchAll(/^✖ (.+?) \(/gm)].map((m) => m[1]))];
|
||||
const m = raw.match(/^ℹ fail (\d+)/m);
|
||||
return { failed: m ? Number(m[1]) : -1, names, raw };
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = runTests();
|
||||
console.log(`baseline: ${baseline.failed} failing`);
|
||||
if (baseline.failed !== 0) {
|
||||
console.error('the suites must be green before mutating');
|
||||
if (baseline.raw) console.error(baseline.raw.slice(-2000));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let allRed = true;
|
||||
for (const mut of MUTATIONS) {
|
||||
const backups = new Map();
|
||||
let applied = true;
|
||||
for (const e of mut.edits) {
|
||||
if (!backups.has(e.file)) backups.set(e.file, read(e.file));
|
||||
const cur = read(e.file);
|
||||
if (!cur.includes(e.from)) {
|
||||
console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`);
|
||||
applied = false;
|
||||
break;
|
||||
}
|
||||
write(e.file, cur.replace(e.from, e.to));
|
||||
}
|
||||
if (applied) {
|
||||
const r = runTests();
|
||||
if (r.failed > 0) {
|
||||
console.log(` [RED] ${mut.defect} — ${r.failed} failing`);
|
||||
for (const n of r.names) console.log(` ↳ ${n}`);
|
||||
} else {
|
||||
console.log(` [GREEN] ${mut.defect} — NOT PINNED`);
|
||||
allRed = false;
|
||||
}
|
||||
} else {
|
||||
allRed = false;
|
||||
}
|
||||
for (const [file, text] of backups) write(file, text);
|
||||
}
|
||||
|
||||
const after = runTests();
|
||||
console.log(`\nrestored: ${after.failed} failing (must be 0)`);
|
||||
console.log(allRed && after.failed === 0
|
||||
? `RESULT: ${MUTATIONS.length}/${MUTATIONS.length} defects pinned`
|
||||
: 'RESULT: FAIL');
|
||||
process.exit(allRed && after.failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floorhold-probe-cost.mjs — what does an unrationed adjacent-cell
|
||||
* probe actually cost?
|
||||
*
|
||||
* The display-floor hold borrows a floor from the eight cells adjacent to a
|
||||
* contact's own when that one is cold (`neighborFloorM`). An early draft
|
||||
* rationed those probes with a global per-tick budget and a fairness queue,
|
||||
* which produced two starvation defects in a row. This measures the thing the
|
||||
* scheduler was protecting, so the decision to delete it is a number rather
|
||||
* than an opinion.
|
||||
*
|
||||
* A probe is eight synchronous `Map` reads against the shared floor cache — no
|
||||
* fetch, no `sampleHeight`, nothing async. Every DEM request is driven by
|
||||
* `warmGroundFloor` from the poll loop instead, already bounded there by
|
||||
* DISPLAY_CORRIDOR_CELL_BUDGET and the resolver's single-flight chain.
|
||||
*
|
||||
* node scripts/qa-floorhold-probe-cost.mjs
|
||||
*/
|
||||
import {
|
||||
neighborFloorM, reportMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest,
|
||||
} from '../src/data/groundFloor.js';
|
||||
|
||||
const CONTACTS = 200; // a dense airport view, every contact probing at once
|
||||
const TICK_MS = 80; // the fleet dead-reckoning cadence
|
||||
const THROTTLE_MS = 500; // NEIGHBOR_FLOOR_PROBE_MS
|
||||
|
||||
setMeshFloorPreferred(true);
|
||||
for (const [label, warm] of [
|
||||
['all cold (every neighbour a miss — the outage case)', false],
|
||||
['all warm (all eight neighbours resolved)', true],
|
||||
]) {
|
||||
_clearMeshFloorCellsForTest();
|
||||
const cells = [];
|
||||
for (let i = 0; i < CONTACTS; i += 1) {
|
||||
const cell = { lat: +(30 + i * 0.003).toFixed(3), lon: -97.66 };
|
||||
cells.push(cell);
|
||||
if (warm) {
|
||||
for (let dLat = -1; dLat <= 1; dLat += 1) {
|
||||
for (let dLon = -1; dLon <= 1; dLon += 1) {
|
||||
if (dLat === 0 && dLon === 0) continue; // the contact's OWN cell stays cold
|
||||
reportMeshFloorCell(cell.lat + dLat * 0.001, cell.lon + dLon * 0.001, 100 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let w = 0; w < 200; w += 1) for (const c of cells) neighborFloorM(c); // settle the JIT
|
||||
const runs = [];
|
||||
for (let r = 0; r < 60; r += 1) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
for (const c of cells) neighborFloorM(c);
|
||||
runs.push(Number(process.hrtime.bigint() - t0) / 1e6);
|
||||
}
|
||||
runs.sort((a, b) => a - b);
|
||||
const median = runs[Math.floor(runs.length / 2)];
|
||||
const worst = runs[runs.length - 1];
|
||||
console.log(`\n${label}`);
|
||||
console.log(` ${CONTACTS} contacts x 8 cells on ONE tick : median ${median.toFixed(3)} ms, worst ${worst.toFixed(3)} ms`);
|
||||
console.log(` share of one ${TICK_MS} ms fleet tick : ${(worst / TICK_MS * 100).toFixed(2)}%`);
|
||||
console.log(` sustained under the ${THROTTLE_MS} ms throttle : ${(median * 1000 / THROTTLE_MS).toFixed(2)} ms per second`);
|
||||
}
|
||||
console.log('\nA global scheduler over this protects single-digit milliseconds.\n');
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/qa-floorhold-staircase.mjs — what a grounded contact DOES while its
|
||||
* floor data arrives, tick by tick.
|
||||
*
|
||||
* The unit pins assert the clamp's answer for a given cache state. They cannot
|
||||
* see the shape of the transition, which is what an owner actually watches: a
|
||||
* contact that reaches the right height by way of a jump into midair and a
|
||||
* visible stair-step down is wrong even though every individual answer is
|
||||
* defensible. An owner playtest found exactly that — planes floating at
|
||||
* terminal gates — and this is the rig that reproduces it.
|
||||
*
|
||||
* A stationary grounded contact at a cold cell, driven at the 80 ms fleet
|
||||
* cadence, with cells warming on a fixed schedule. Reports time-to-surface,
|
||||
* time visibly buried, time visibly FLOATING, and the visible step count.
|
||||
*
|
||||
* node scripts/qa-floorhold-staircase.mjs
|
||||
*
|
||||
* Pure module-level: no browser, no network, no GPU.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickRenderAltitudeM } from '../src/data/renderAltitude.js';
|
||||
import {
|
||||
_floorGroundedDisplayPositionForTest, _clearDisplayFloorStateForTest,
|
||||
} from '../src/data/flights.js';
|
||||
import {
|
||||
reportMeshFloorCell, setMeshFloorPreferred, _clearMeshFloorCellsForTest, GROUND_FLOOR_LIFT_M,
|
||||
} from '../src/data/groundFloor.js';
|
||||
|
||||
const LAT = 30.2004, LON = -97.6604; // own cell 30.200 / -97.660
|
||||
const APRON = -22.0, ROOF = -4.0, START = -32.0; // ellipsoidal; SFO-ish geoid start
|
||||
const TARGET = APRON + GROUND_FLOOR_LIFT_M; // -20.5
|
||||
const N = { up: [30.2014, LON], down: [30.1994, LON], left: [LAT, -97.6614], right: [LAT, -97.6594] };
|
||||
|
||||
const SCENARIOS = [
|
||||
['S1 flat apron cold start (own cell warms last)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, APRON); reportMeshFloorCell(...N.down, APRON); }],
|
||||
[5200, () => reportMeshFloorCell(LAT, LON, APRON)],
|
||||
]],
|
||||
['S2 apron beside a terminal (one neighbour is a roof)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, ROOF); reportMeshFloorCell(...N.down, APRON); }],
|
||||
[5200, () => reportMeshFloorCell(LAT, LON, APRON)],
|
||||
]],
|
||||
['S3 own cell never warms (roof neighbour only)', [
|
||||
[1120, () => reportMeshFloorCell(...N.up, ROOF)],
|
||||
]],
|
||||
['S3b own cell never warms (roof + apron neighbours)', [
|
||||
[1120, () => { reportMeshFloorCell(...N.up, ROOF); reportMeshFloorCell(...N.down, APRON);
|
||||
reportMeshFloorCell(...N.left, APRON); }],
|
||||
]],
|
||||
];
|
||||
|
||||
const summary = [];
|
||||
for (const [name, schedule] of SCENARIOS) {
|
||||
_clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true);
|
||||
const pos = Cesium.Cartesian3.fromDegrees(LON, LAT, START);
|
||||
const samples = [];
|
||||
let pending = [...schedule];
|
||||
for (let t = 0; t <= 12000; t += 80) {
|
||||
while (pending.length && pending[0][0] <= t) pending.shift()[1]();
|
||||
const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, name, 1000 + t);
|
||||
samples.push([t, Cesium.Cartographic.fromCartesian(out, Cesium.Ellipsoid.WGS84).height]);
|
||||
}
|
||||
const settle = samples.find(([, h]) => Math.abs(h - TARGET) < 0.1);
|
||||
const buried = samples.filter(([, h]) => h < APRON - 0.05).length * 80;
|
||||
const floatMax = Math.max(0, ...samples.map(([, h]) => h - TARGET));
|
||||
const floatMs = samples.filter(([, h]) => h > TARGET + 0.5).length * 80;
|
||||
const steps = [];
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
const d = samples[i][1] - samples[i - 1][1];
|
||||
if (Math.abs(d) > 0.5) steps.push([samples[i][0], d]);
|
||||
}
|
||||
const plateaus = new Set(samples.map(([, h]) => h.toFixed(1))).size;
|
||||
console.log(`\n### ${name}`);
|
||||
console.log(` target = ${TARGET.toFixed(2)} m start = ${START.toFixed(2)} m`);
|
||||
console.log(` time-to-surface = ${settle ? `${settle[0]} ms` : 'NEVER'}`);
|
||||
console.log(` time visibly BURIED = ${buried} ms`);
|
||||
console.log(` time visibly FLOATING = ${floatMs} ms (worst +${floatMax.toFixed(1)} m)`);
|
||||
console.log(` visible steps (>0.5m) = ${steps.length} (up ${steps.filter(([, d]) => d > 0).length} / down ${steps.filter(([, d]) => d < 0).length})`);
|
||||
console.log(` distinct plateaus = ${plateaus}`);
|
||||
summary.push(` ${name} :: settle=${settle ? settle[0] : 'NEVER'} steps=${steps.length} float=${floatMs}ms(+${floatMax.toFixed(1)}m) buried=${buried}ms`);
|
||||
}
|
||||
console.log(`\nSUMMARY (this tree, post-fix)\n${summary.join('\n')}\n`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// F1 — takeoff roll with the on_ground flag FLAPPING (owner sighting: VIR138M
|
||||
// at JFK, 45 kt, "clearly on good ground, then suddenly popped below the
|
||||
// ground, then popped back up").
|
||||
//
|
||||
// Two mechanisms meet here. OpenSky's on_ground flag is not clean through a
|
||||
// rotation, and the fix's own height source switches at the same moment: a
|
||||
// grounded fix is the resolved surface, an airborne one with baro just
|
||||
// appearing is baro + geoid N, which at a sea-level field IS the geoid — below
|
||||
// the ground the contact is still rolling on. Whether that dip is VISIBLE is
|
||||
// then entirely down to whether the display clamp still remembers a floor.
|
||||
//
|
||||
// EVERY tick below is counted, the airborne one included. That tick renders
|
||||
// under the runway and always will: the display clamp passes airborne positions
|
||||
// through by design — an airborne height is the fix-time clamp's job, and
|
||||
// flooring one would put a rotating aircraft back on the ground. So this rig
|
||||
// reports two numbers, not one. The grounded count is what the hold owns and it
|
||||
// must be zero. The airborne count is the ACCEPTED transition residual: the
|
||||
// flap's own cost at the source, one tick wide, bounded by the field's geoid
|
||||
// separation (~4 m here). What the hold changed is what happens AFTER the flap
|
||||
// — 12 of 22 grounded ticks buried and not recovering, versus none.
|
||||
// ---------------------------------------------------------------------------
|
||||
const JFK_GROUND = -28.5; // ~4 m MSL field, geoid N ~ -32.5 m
|
||||
const JFK_GEOID = -32.5;
|
||||
const JFK_LON = -73.78;
|
||||
|
||||
console.log('\n### F1 takeoff roll, on_ground flapping for one poll');
|
||||
console.log(' poll-path height source at the flap:');
|
||||
console.log(` grounded, own cell warm = ${pickRenderAltitudeM({ geoAltM: null, baroAltM: null, onGround: true, surfaceM: JFK_GROUND, geoidN: JFK_GEOID })} m`);
|
||||
console.log(` airborne, baro appears at 0 ft = ${pickRenderAltitudeM({ geoAltM: null, baroAltM: 0, onGround: false, surfaceM: null, geoidN: JFK_GEOID })} m`);
|
||||
console.log(` the source switch alone drops the fix ${(JFK_GROUND - JFK_GEOID).toFixed(1)} m, to below the runway`);
|
||||
|
||||
_clearDisplayFloorStateForTest(); _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true);
|
||||
// Only the cell it STARTED on is warm: at 23 m/s it outruns its own floor data,
|
||||
// which is the whole reason the hold exists.
|
||||
reportMeshFloorCell(40.64, JFK_LON, JFK_GROUND);
|
||||
let rollLat = 40.64;
|
||||
const rollRows = [];
|
||||
for (let i = 0; i <= 22; i += 1) {
|
||||
const onGround = i !== 10; // ONE airborne poll mid-roll
|
||||
const pos = Cesium.Cartesian3.fromDegrees(JFK_LON, rollLat, JFK_GEOID);
|
||||
const out = _floorGroundedDisplayPositionForTest({ onGround }, pos, false, 'VIR138M', 1000 + i * 80);
|
||||
rollRows.push([i, onGround, Cesium.Cartographic.fromCartesian(out, Cesium.Ellipsoid.WGS84).height]);
|
||||
rollLat += 0.00021; // ~23 m per 80 ms tick
|
||||
}
|
||||
const below = ([, , h]) => h < JFK_GROUND - 0.05;
|
||||
for (const row of rollRows) {
|
||||
const [i, og, h] = row;
|
||||
const mark = below(row)
|
||||
? (og ? ' <-- BELOW THE RUNWAY' : ' <-- below the runway (airborne pass-through)')
|
||||
: '';
|
||||
console.log(` tick ${String(i).padStart(2)} onGround=${og ? 'T' : 'F'} render=${h.toFixed(2)}${mark}`);
|
||||
}
|
||||
const groundedRows = rollRows.filter(([, og]) => og);
|
||||
const airborneRows = rollRows.filter(([, og]) => !og);
|
||||
const dipped = rollRows.filter(below);
|
||||
console.log(`\n ticks rendering BELOW the runway: ${dipped.length} / ${rollRows.length} (every tick counted)`);
|
||||
console.log(` grounded : ${groundedRows.filter(below).length} / ${groundedRows.length} <- what the hold owns; must be 0`);
|
||||
console.log(` airborne : ${airborneRows.filter(below).length} / ${airborneRows.length} <- pass-through by design; accepted transition residual`);
|
||||
const worst = Math.min(JFK_GROUND, ...dipped.map(([, , h]) => h));
|
||||
console.log(` worst burial = ${(worst - JFK_GROUND).toFixed(2)} m (the field's geoid separation, one tick wide)`);
|
||||
@@ -0,0 +1,605 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fly_route cinematic evidence — drives the REAL voice runner headlessly and
|
||||
* measures the REAL camera (Cesium heading/pitch/roll + position) every
|
||||
* rendered frame, so the proof is the shot the owner will watch, not our own
|
||||
* internal numbers.
|
||||
*
|
||||
* node scripts/qa-flyroute-cinema.mjs --url http://localhost:4247
|
||||
*
|
||||
* Writes a screenshot sequence plus a JSON trace to qa-shots/flyroute/.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const getOpt = (flag, fallback) => {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4247');
|
||||
const OUT_DIR = getOpt('--out', path.join(ROOT, 'qa-shots', 'flyroute'));
|
||||
const MIRROR_DIR = getOpt('--mirror', '');
|
||||
const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000'));
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
].filter(Boolean);
|
||||
const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } });
|
||||
|
||||
// A 6-waypoint downtown Austin route: north, right, left, right, left.
|
||||
const ROUTE_POINTS = [
|
||||
{ latitude: 30.2620, longitude: -97.7431 },
|
||||
{ latitude: 30.2650, longitude: -97.7431 },
|
||||
{ latitude: 30.2650, longitude: -97.7397 },
|
||||
{ latitude: 30.2680, longitude: -97.7397 },
|
||||
{ latitude: 30.2680, longitude: -97.7363 },
|
||||
{ latitude: 30.2712, longitude: -97.7363 },
|
||||
];
|
||||
|
||||
const results = [];
|
||||
const report = (ok, name, detail = '') => {
|
||||
results.push({ ok, name, detail });
|
||||
const mark = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` ${mark} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
const note = (name, detail) => {
|
||||
results.push({ ok: null, name, detail });
|
||||
console.log(` \x1b[33mNOTE\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
const wrapDeg = (deg) => ((deg + 540) % 360) - 180;
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (MIRROR_DIR) fs.mkdirSync(MIRROR_DIR, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
// Real GPU when the host has one: the dolly is frame-rate independent, but
|
||||
// a higher sample rate makes the roll and the ease ramps far easier to see.
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1500,950',
|
||||
],
|
||||
protocolTimeout: 240000,
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1500, height: 950 });
|
||||
page.on('pageerror', (e) => console.log(` [page error] ${String(e).slice(0, 160)}`));
|
||||
|
||||
// Cold-corridor proof: hold the terrain proxy back so the dolly has to survive
|
||||
// a corridor that has no floor data when the flight is asked for. Without this
|
||||
// the harness only ever measures a machine whose cache happens to be warm.
|
||||
const TERRAIN_DELAY_MS = Number(getOpt('--terrain-delay-ms', '0'));
|
||||
let terrainRequests = 0;
|
||||
if (TERRAIN_DELAY_MS > 0) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/terrain/heights')) {
|
||||
terrainRequests += 1;
|
||||
setTimeout(() => { request.continue().catch(() => {}); }, TERRAIN_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
request.continue().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/** Install a postRender sampler: one row per RENDERED frame. */
|
||||
async function installSampler() {
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
window.__gevFlyTrace = { rows: [], marks: [] };
|
||||
if (window.__gevFlyTraceRemove) window.__gevFlyTraceRemove();
|
||||
let frame = 0;
|
||||
const listener = () => {
|
||||
const cam = viewer.camera;
|
||||
const carto = cam.positionCartographic;
|
||||
// Every 6th frame, ask the RENDERED WORLD what is under the camera. This
|
||||
// is the only measurement that can prove "never below terrain": it reads
|
||||
// the surface the user is actually looking at, not our own floor cache.
|
||||
let surfaceM = null;
|
||||
frame += 1;
|
||||
if (frame % 6 === 0 && typeof viewer.scene.sampleHeight === 'function') {
|
||||
try {
|
||||
const probe = viewer.scene.sampleHeight(carto.clone());
|
||||
if (Number.isFinite(probe)) surfaceM = probe;
|
||||
} catch { /* tiles not loaded under the camera */ }
|
||||
}
|
||||
window.__gevFlyTrace.rows.push({
|
||||
t: performance.now(),
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
height: carto.height,
|
||||
headingDeg: (cam.heading * 180) / Math.PI,
|
||||
pitchDeg: (cam.pitch * 180) / Math.PI,
|
||||
rollDeg: (cam.roll * 180) / Math.PI,
|
||||
surfaceM,
|
||||
});
|
||||
};
|
||||
viewer.scene.postRender.addEventListener(listener);
|
||||
window.__gevFlyTraceRemove = () => viewer.scene.postRender.removeEventListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
async function readTrace() {
|
||||
return page.evaluate(() => ({
|
||||
rows: window.__gevFlyTrace.rows.slice(),
|
||||
marks: window.__gevFlyTrace.marks.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Metres between two samples, on the ground plane plus height. */
|
||||
function sampleDistanceM(a, b) {
|
||||
const mPerDegLat = 111320;
|
||||
const mPerDegLon = 111320 * Math.cos((a.lat * Math.PI) / 180);
|
||||
const dx = (b.lon - a.lon) * mPerDegLon;
|
||||
const dy = (b.lat - a.lat) * mPerDegLat;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`\nfly_route cinematic evidence — ${APP_URL}`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.runner && window.__gevAnnotations,
|
||||
{ timeout: 150000, polling: 250 },
|
||||
);
|
||||
const run = (name, args = {}) => page.evaluate(
|
||||
(n, a) => window.__gevVoiceCommands.runner(n, a), name, args,
|
||||
);
|
||||
|
||||
// ── Setup: get over downtown Austin, draw the route ───────────────────
|
||||
await run('fly_to_location', { query: 'Texas State Capitol, Austin' });
|
||||
await sleep(9000);
|
||||
await run('clear_annotations', {});
|
||||
await sleep(500);
|
||||
const drawn = await run('annotate_map', {
|
||||
annotations: [{
|
||||
type: 'route', mode: 'driving', label: 'cinema evidence route', points: ROUTE_POINTS,
|
||||
}],
|
||||
});
|
||||
await sleep(6000);
|
||||
const routeInfo = await page.evaluate(() => {
|
||||
const route = (window.__gevAnnotations.list?.() || []).filter((a) => a.type === 'route').at(-1);
|
||||
return route ? { label: route.label, waypoints: route.path?.length ?? 0 } : null;
|
||||
});
|
||||
report(Boolean(routeInfo?.waypoints >= 2), 'route drawn on the board',
|
||||
`waypoints=${routeInfo?.waypoints} drawn=${drawn?.drawn}`);
|
||||
if (!routeInfo) throw new Error('no route on the board — cannot fly it');
|
||||
|
||||
// ── Run 1: the full cinematic flight ──────────────────────────────────
|
||||
await installSampler();
|
||||
const flight = await run('fly_route', { label: 'cinema evidence', speed: 'normal' });
|
||||
await page.evaluate(() => window.__gevFlyTrace.marks.push({ label: 'flight-start', t: performance.now() }));
|
||||
report(flight?.ok === true, 'fly_route accepted',
|
||||
`distanceM=${flight?.distanceM} durationS=${flight?.durationS} waypoints=${flight?.waypoints}`);
|
||||
if (!flight?.ok) throw new Error(`fly_route refused: ${flight?.error}`);
|
||||
const coldPath = await page.evaluate(async () => {
|
||||
try {
|
||||
const mod = await import('/src/cameraVerbs.js');
|
||||
return mod.getActiveCameraMotion?.() ?? null;
|
||||
} catch { return 'module-unavailable'; }
|
||||
});
|
||||
if (coldPath && coldPath !== 'module-unavailable') {
|
||||
report(true, 'cold-path telemetry read from the live flight',
|
||||
`arming=${coldPath.arming} floorKnown=${coldPath.floorKnown} viaMeshProbe=${coldPath.floorFromMeshProbe}`
|
||||
+ (TERRAIN_DELAY_MS ? ` (terrain proxy held back ${TERRAIN_DELAY_MS} ms, ${terrainRequests} request(s))` : ''));
|
||||
}
|
||||
|
||||
// Liveness is read off the CAMERA, never off a module import: under Vite the
|
||||
// dev server hands a dynamic import its own module instance after any HMR
|
||||
// update, whose motion slot is empty no matter what the app is doing.
|
||||
const stillForMs = () => page.evaluate(() => {
|
||||
const rows = window.__gevFlyTrace.rows;
|
||||
if (rows.length < 3) return 0;
|
||||
const last = rows.at(-1);
|
||||
for (let i = rows.length - 2; i >= 0; i -= 1) {
|
||||
const moved = Math.abs(rows[i].lon - last.lon) + Math.abs(rows[i].lat - last.lat)
|
||||
+ (Math.abs(rows[i].height - last.height) / 1e5);
|
||||
if (moved > 1e-7) return last.t - rows[i].t;
|
||||
}
|
||||
return last.t - rows[0].t;
|
||||
});
|
||||
|
||||
const budgetMs = Math.min(180000, ((flight.durationS || 40) + 10) * 1000);
|
||||
const shots = [];
|
||||
const startedAt = Date.now();
|
||||
let shotIndex = 0;
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
const file = path.join(OUT_DIR, `flight-${String(shotIndex).padStart(2, '0')}.png`);
|
||||
const at = await page.evaluate(() => performance.now());
|
||||
await page.screenshot({ path: file });
|
||||
shots.push({ file, at });
|
||||
shotIndex += 1;
|
||||
await sleep(SHOT_EVERY_MS);
|
||||
if (Date.now() - startedAt > 5000 && (await stillForMs()) > 1500) break;
|
||||
}
|
||||
const trace = await readTrace();
|
||||
await page.evaluate(() => window.__gevFlyTraceRemove?.());
|
||||
report(shots.length >= 6, 'screenshot sequence captured', `${shots.length} frames @ ${SHOT_EVERY_MS}ms`);
|
||||
|
||||
// ── Measure the REAL camera ───────────────────────────────────────────
|
||||
// Only the dolly's own frames count. The sampler is installed before the
|
||||
// flight, so the pre-flight view and the single frame that jumps the camera
|
||||
// onto the route start are dropped — neither is dolly motion.
|
||||
const flightStartT = trace.marks.find((m) => m.label === 'flight-start')?.t ?? 0;
|
||||
const rows = trace.rows.filter((r) => r.t > flightStartT).slice(1);
|
||||
const fps = rows.length / Math.max(0.001, (rows.at(-1).t - rows[0].t) / 1000);
|
||||
report(rows.length > 60, 'camera sampled every rendered frame',
|
||||
`${rows.length} dolly samples (${trace.rows.length} total) at ${fps.toFixed(1)} fps`);
|
||||
|
||||
const rolls = rows.map((r) => wrapDeg(r.rollDeg));
|
||||
const peakRoll = Math.max(...rolls.map(Math.abs));
|
||||
report(peakRoll <= 10.5, 'bank never exceeds the 10° cap', `peak |roll| = ${peakRoll.toFixed(2)}°`);
|
||||
report(peakRoll > 3, 'turns actually bank', `peak |roll| = ${peakRoll.toFixed(2)}°`);
|
||||
report(Math.abs(rolls[0]) < 1 && Math.abs(rolls.at(-1)) < 3,
|
||||
'the flight starts and finishes near wings level',
|
||||
`first=${rolls[0].toFixed(2)}° last=${rolls.at(-1).toFixed(2)}°`);
|
||||
|
||||
// Roll follows the turn: while banked, roll sign must match heading rate.
|
||||
// Sampled over ~1 s of heading change, and only where BOTH the roll and the
|
||||
// turn are unambiguous (a roll-out trails its turn by design, so the tail of
|
||||
// every corner is deliberately outside the window).
|
||||
let agree = 0;
|
||||
let disagree = 0;
|
||||
const span = Math.max(2, Math.round(fps));
|
||||
for (let i = span; i < rows.length; i += 1) {
|
||||
const dt = (rows[i].t - rows[i - span].t) / 1000;
|
||||
if (!(dt > 0)) continue;
|
||||
const headingRate = wrapDeg(rows[i].headingDeg - rows[i - span].headingDeg) / dt;
|
||||
const roll = wrapDeg(rows[i].rollDeg);
|
||||
if (Math.abs(roll) < 2 || Math.abs(headingRate) < 3) continue;
|
||||
if (Math.sign(roll) === Math.sign(headingRate)) agree += 1; else disagree += 1;
|
||||
}
|
||||
const agreement = agree / Math.max(1, agree + disagree);
|
||||
report(agreement > 0.85, 'the camera rolls INTO the turn (right turn → right bank)',
|
||||
`${(agreement * 100).toFixed(1)}% of banked samples agree (${agree}/${agree + disagree})`);
|
||||
|
||||
// Every rate below is measured over ~250 ms windows. A per-frame difference
|
||||
// is dominated by the pairing jitter between the motion tick's own clock and
|
||||
// postRender (a 36 ms frame next to an 8 ms one doubles any per-frame rate),
|
||||
// which measures the harness, not the dolly.
|
||||
const RATE_WINDOW_MS = 250;
|
||||
const peakRate = (valueAt) => {
|
||||
let worst = 0;
|
||||
for (let i = 0, j = 0; i < rows.length; i += 1) {
|
||||
while (j < rows.length - 1 && rows[j].t - rows[i].t < RATE_WINDOW_MS) j += 1;
|
||||
const dt = (rows[j].t - rows[i].t) / 1000;
|
||||
if (dt >= RATE_WINDOW_MS / 2000) worst = Math.max(worst, Math.abs(valueAt(j) - valueAt(i)) / dt);
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
|
||||
const peakRollRate = peakRate((i) => rolls[i]);
|
||||
report(peakRollRate < 20, 'the roll enters and exits smoothly, never snaps',
|
||||
`peak roll rate ${peakRollRate.toFixed(1)} °/s over ${RATE_WINDOW_MS} ms`);
|
||||
|
||||
// Speed: eased at both ends, no step in between. Measured over 400 ms
|
||||
// windows — the per-frame delta is dominated by the pairing jitter between
|
||||
// the motion tick's own clock and postRender, not by the dolly.
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < rows.length; i += 1) {
|
||||
cumulative.push(cumulative[i - 1] + sampleDistanceM(rows[i - 1], rows[i]));
|
||||
}
|
||||
const speeds = [];
|
||||
for (let i = 0, j = 0; i < rows.length; i += 1) {
|
||||
while (j < rows.length - 1 && rows[j].t - rows[i].t < 400) j += 1;
|
||||
const dt = (rows[j].t - rows[i].t) / 1000;
|
||||
if (dt >= 0.3) speeds.push({ t: rows[i].t - rows[0].t, v: (cumulative[j] - cumulative[i]) / dt });
|
||||
}
|
||||
const window1s = (from, to) => {
|
||||
const inWindow = speeds.filter((s) => s.t >= from && s.t <= to);
|
||||
return inWindow.length ? inWindow.reduce((sum, s) => sum + s.v, 0) / inWindow.length : Number.NaN;
|
||||
};
|
||||
const peakV = Math.max(...speeds.map((s) => s.v));
|
||||
const firstSecond = window1s(0, 1200);
|
||||
report(firstSecond < peakV * 0.5, 'the dolly eases IN (no velocity step at the start)',
|
||||
`first 1.2 s ${firstSecond.toFixed(1)} m/s vs peak ${peakV.toFixed(1)} m/s`);
|
||||
|
||||
// Ease-out is measured as the SHAPE of the decay, not as a terminal window.
|
||||
// The sampler keeps running after the dolly stops, so a trailing average
|
||||
// includes stationary frames — under which a hard stop also reports ~0 m/s
|
||||
// and passes. How long the speed takes to fall from 90% to 10% of peak is
|
||||
// immune to that tail: a ramp spreads it over a second or more, a hard stop
|
||||
// collapses it into a single frame.
|
||||
const lastAbove = (fraction) => {
|
||||
for (let i = speeds.length - 1; i >= 0; i -= 1) if (speeds[i].v >= peakV * fraction) return speeds[i].t;
|
||||
return Number.NaN;
|
||||
};
|
||||
const decayMs = lastAbove(0.1) - lastAbove(0.9);
|
||||
report(decayMs > 800, 'the dolly eases OUT over a real ramp, not a hard stop',
|
||||
`speed fell 90% → 10% of peak over ${decayMs.toFixed(0)} ms (a hard stop collapses to one frame)`);
|
||||
const riseMs = (() => {
|
||||
const first = (fraction) => speeds.find((s) => s.v >= peakV * fraction)?.t ?? Number.NaN;
|
||||
return first(0.9) - first(0.1);
|
||||
})();
|
||||
report(riseMs > 800, 'and eases IN over one too',
|
||||
`speed rose 10% → 90% of peak over ${riseMs.toFixed(0)} ms`);
|
||||
report(peakV < 40 * 1.35, 'the easing keeps the shipped pace — the plateau IS the speed word',
|
||||
`peak ${peakV.toFixed(1)} m/s over a 40 m/s mean (${(flight.distanceM / flight.durationS).toFixed(1)} m/s reported)`);
|
||||
// Acceleration, differenced across NON-overlapping speed windows so the
|
||||
// 400 ms averaging is not differentiated against itself.
|
||||
let peakAccel = 0;
|
||||
for (let i = 0, j = 0; i < speeds.length; i += 1) {
|
||||
while (j < speeds.length - 1 && speeds[j].t - speeds[i].t < 400) j += 1;
|
||||
const dt = (speeds[j].t - speeds[i].t) / 1000;
|
||||
if (dt >= 0.3) peakAccel = Math.max(peakAccel, Math.abs(speeds[j].v - speeds[i].v) / dt);
|
||||
}
|
||||
report(peakAccel < 40, 'no velocity discontinuity anywhere on the route',
|
||||
`peak |acceleration| ${peakAccel.toFixed(1)} m/s² (a hard start would read in the hundreds)`);
|
||||
|
||||
// Altitude shaping and terrain clearance, read off the real camera.
|
||||
const heights = rows.map((r) => r.height);
|
||||
const floors = await page.evaluate(async (samples) => {
|
||||
try {
|
||||
const mod = await import('/src/data/groundFloor.js');
|
||||
return samples.map(({ lat, lon }) => mod.cachedGroundFloor(lat, lon));
|
||||
} catch { return samples.map(() => null); }
|
||||
}, rows.map((r) => ({ lat: r.lat, lon: r.lon })));
|
||||
// The strongest terrain check available: the eye against the RENDERED
|
||||
// surface under it, sampled live. Independent of our own floor cache, and
|
||||
// therefore the one that would catch flying inside a building or a hillside.
|
||||
const probed = rows.filter((r) => Number.isFinite(r.surfaceM));
|
||||
if (probed.length > 20) {
|
||||
let worst = Infinity;
|
||||
let worstAt = null;
|
||||
for (const row of probed) {
|
||||
const clearance = row.height - row.surfaceM;
|
||||
if (clearance < worst) { worst = clearance; worstAt = row; }
|
||||
}
|
||||
report(worst > 0, 'the eye is never inside the RENDERED world',
|
||||
`min clearance over the rendered surface ${worst.toFixed(1)} m across ${probed.length} live probes`
|
||||
+ (worstAt ? ` (worst at ${worstAt.lat.toFixed(5)}, ${worstAt.lon.toFixed(5)})` : ''));
|
||||
} else {
|
||||
note('rendered-surface clearance', `only ${probed.length} live mesh probes answered`);
|
||||
}
|
||||
const warm = floors.map((f, i) => (Number.isFinite(f) ? heights[i] - f : null)).filter((v) => v !== null);
|
||||
if (warm.length > 20) {
|
||||
const minAgl = Math.min(...warm);
|
||||
const maxAgl = Math.max(...warm);
|
||||
report(minAgl >= 90, 'the eye always clears the rendered floor',
|
||||
`min AGL ${minAgl.toFixed(1)} m, max ${maxAgl.toFixed(1)} m over ${warm.length} warm samples`);
|
||||
} else {
|
||||
note('terrain clearance', `only ${warm.length} warm floor cells under the route — clearance clamp is pinned in npm test`);
|
||||
}
|
||||
// The floor ACQUISITION — the one frame where a cold-corridor safety seed is
|
||||
// replaced by real terrain — is deliberately a single step, and it lands in
|
||||
// the same moment the camera teleports onto the route start. It is measured
|
||||
// separately from the shaping, which must be a swell for the whole flight.
|
||||
const cruiseFrom = rows.findIndex((r) => r.t - rows[0].t > 1500);
|
||||
const cruiseRows = cruiseFrom > 0 ? rows.slice(cruiseFrom) : rows;
|
||||
const cruiseHeights = cruiseRows.map((r) => r.height);
|
||||
// Split the two directions: a DESCENT is capped by the dolly (never drop the
|
||||
// eye toward ground it is still learning about), while a CLIMB is deliberately
|
||||
// uncapped — rising is the safety direction, and a terrain rise plus the
|
||||
// shaping swell can legitimately exceed the descent cap.
|
||||
let peakDescentMps = 0;
|
||||
let peakClimbMps = 0;
|
||||
for (let i = 0, j = 0; i < cruiseRows.length; i += 1) {
|
||||
while (j < cruiseRows.length - 1 && cruiseRows[j].t - cruiseRows[i].t < RATE_WINDOW_MS) j += 1;
|
||||
const dt = (cruiseRows[j].t - cruiseRows[i].t) / 1000;
|
||||
if (dt < RATE_WINDOW_MS / 2000) continue;
|
||||
const rate = (cruiseHeights[j] - cruiseHeights[i]) / dt;
|
||||
if (rate < 0) peakDescentMps = Math.max(peakDescentMps, -rate);
|
||||
else peakClimbMps = Math.max(peakClimbMps, rate);
|
||||
}
|
||||
const peakVerticalMps = Math.max(peakDescentMps, peakClimbMps);
|
||||
const acquisitionM = Math.abs(heights[0] - cruiseHeights[0]);
|
||||
const cruiseRangeM = Math.max(...cruiseHeights) - Math.min(...cruiseHeights);
|
||||
report(cruiseRangeM > 5, 'altitude breathes rather than sitting flat',
|
||||
`${cruiseRangeM.toFixed(1)} m of vertical range in cruise`);
|
||||
report(cruiseRangeM < 120, 'and the cruise altitude never wanders far from its mean',
|
||||
`${cruiseRangeM.toFixed(1)} m of range after a ${acquisitionM.toFixed(0)} m floor acquisition at the start`);
|
||||
// Smoothness is measured on the camera's ABSOLUTE vertical motion, because
|
||||
// that is what a viewer sees. It is tempting to difference AGL instead, to
|
||||
// separate "our shaping" from "the hill" — but the floor is a ~111 m
|
||||
// staircase, so an AGL series steps at every cell boundary even when the eye
|
||||
// is gliding. Differencing it measures the quantization, not the ride
|
||||
// (measured: 31.6 m/s of "AGL rate" while the eye moved at 8.9 m/s).
|
||||
report(peakDescentMps < 10.5, 'the eye is never DROPPED — descent stays inside its cap',
|
||||
`peak descent ${peakDescentMps.toFixed(1)} m/s (cap 10 m/s)`);
|
||||
report(peakClimbMps < 20, 'and climbs stay a swell rather than a lurch',
|
||||
`peak climb ${peakClimbMps.toFixed(1)} m/s against ~40 m/s of ground speed`);
|
||||
|
||||
const pitches = rows.map((r) => r.pitchDeg);
|
||||
const pitchSpread = Math.max(...pitches) - Math.min(...pitches);
|
||||
report(pitchSpread < 2, 'the look-down angle stays locked (no pitch wobble)',
|
||||
`pitch ${Math.min(...pitches).toFixed(1)}°..${Math.max(...pitches).toFixed(1)}°`);
|
||||
|
||||
// ── Run 2: interrupt the dolly MID-BANK ───────────────────────────────
|
||||
// Cutting a level camera proves nothing about levelling, so this waits for
|
||||
// the live camera to actually be rolled before it grabs the controls.
|
||||
await sleep(1500);
|
||||
await installSampler();
|
||||
const second = await run('fly_route', { label: 'cinema evidence', speed: 'normal' });
|
||||
report(second?.ok === true, 'second flight starts for the interrupt case');
|
||||
const liveRollDeg = () => page.evaluate(
|
||||
() => (window.__godsEyeView.viewer.camera.roll * 180) / Math.PI,
|
||||
);
|
||||
let rollBeforeCut = 0;
|
||||
for (let waited = 0; waited < 90000; waited += 400) {
|
||||
rollBeforeCut = wrapDeg(await liveRollDeg());
|
||||
if (Math.abs(rollBeforeCut) >= 3) break;
|
||||
await sleep(400);
|
||||
}
|
||||
report(Math.abs(rollBeforeCut) >= 3, 'the dolly is genuinely banked before the cut',
|
||||
`live camera roll ${rollBeforeCut.toFixed(2)}°`);
|
||||
await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-0-banked.png') });
|
||||
|
||||
const cut = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const canvas = viewer.scene.canvas;
|
||||
// Read the motion slot BEFORE the cut too: under Vite a dynamic import can
|
||||
// hand back a second module instance whose slot is always empty, and an
|
||||
// "empty after" that was already empty before proves nothing.
|
||||
let read = null;
|
||||
let slotBefore = 'module-unavailable';
|
||||
let slotAfter = 'module-unavailable';
|
||||
try {
|
||||
const mod = await import('/src/cameraVerbs.js');
|
||||
read = () => mod.getActiveCameraMotion?.() ?? null;
|
||||
slotBefore = read();
|
||||
} catch { /* dev-only module read */ }
|
||||
const rollBefore = (viewer.camera.roll * 180) / Math.PI;
|
||||
window.__gevFlyTrace.marks.push({ label: 'pointerdown', t: performance.now() });
|
||||
canvas.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
||||
// Same synchronous turn as the pointerdown — no frame has rendered yet.
|
||||
const rollAfter = (viewer.camera.roll * 180) / Math.PI;
|
||||
if (read) slotAfter = read();
|
||||
return { slotBefore, slotAfter, rollBefore, rollAfter };
|
||||
});
|
||||
// The camera-side proof, which needs no module identity at all: a banked
|
||||
// horizon is level again inside the same synchronous turn as the pointerdown.
|
||||
report(
|
||||
Math.abs(wrapDeg(cut.rollBefore)) >= 3 && Math.abs(wrapDeg(cut.rollAfter)) < 0.01,
|
||||
'the release levels the horizon synchronously — no tilt left behind',
|
||||
`roll ${wrapDeg(cut.rollBefore).toFixed(2)}° → ${wrapDeg(cut.rollAfter).toFixed(4)}° in the same turn`,
|
||||
);
|
||||
if (cut.slotBefore && cut.slotBefore !== 'module-unavailable') {
|
||||
report(cut.slotAfter === null, 'a manual pointerdown frees the motion slot synchronously',
|
||||
`active before the cut: ${cut.slotBefore.kind}@${(cut.slotBefore.progress * 100).toFixed(0)}% → after: ${JSON.stringify(cut.slotAfter)}`);
|
||||
} else {
|
||||
note('motion-slot read',
|
||||
'the dev server handed the harness a second module instance (HMR); the camera-side roll and freeze checks carry the proof');
|
||||
}
|
||||
await sleep(2500);
|
||||
const rollAfterSettle = wrapDeg(await liveRollDeg());
|
||||
report(Math.abs(rollAfterSettle) < 0.01, 'and the horizon STAYS level after the cut',
|
||||
`roll ${rollAfterSettle.toFixed(4)}° 2.5 s later`);
|
||||
await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-1-level.png') });
|
||||
const cutTrace = await readTrace();
|
||||
await page.evaluate(() => window.__gevFlyTraceRemove?.());
|
||||
|
||||
const cutAt = cutTrace.marks.at(-1)?.t ?? 0;
|
||||
const before = cutTrace.rows.filter((r) => r.t < cutAt);
|
||||
const after = cutTrace.rows.filter((r) => r.t >= cutAt);
|
||||
const movementBefore = before.length > 2
|
||||
? sampleDistanceM(before.at(-3), before.at(-1)) : Number.NaN;
|
||||
let movementAfter = 0;
|
||||
for (let i = 1; i < after.length; i += 1) {
|
||||
movementAfter = Math.max(movementAfter, sampleDistanceM(after[i - 1], after[i]));
|
||||
}
|
||||
report(movementBefore > 0.5 && movementAfter < 0.5,
|
||||
'the camera freezes on the cut frame — no coast, no snap-back',
|
||||
`moved ${movementBefore.toFixed(2)} m/frame before, max ${movementAfter.toFixed(3)} m/frame after (${after.length} frames)`);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUT_DIR, 'trace.json'),
|
||||
JSON.stringify({
|
||||
url: APP_URL,
|
||||
flight,
|
||||
shots: shots.map((s) => path.basename(s.file)),
|
||||
samples: rows.length,
|
||||
peakRollDeg: peakRoll,
|
||||
peakRollRateDegS: peakRollRate,
|
||||
rollTurnAgreement: agreement,
|
||||
peakSpeedMps: peakV,
|
||||
peakAccelMps2: peakAccel,
|
||||
peakVerticalMps,
|
||||
peakDescentMps,
|
||||
peakClimbMps,
|
||||
easeInMps: firstSecond,
|
||||
easeInRampMs: riseMs,
|
||||
easeOutRampMs: decayMs,
|
||||
heightRangeM: [Math.min(...heights), Math.max(...heights)],
|
||||
cruiseHeightRangeM: cruiseRangeM,
|
||||
floorAcquisitionM: acquisitionM,
|
||||
pitchRangeDeg: [Math.min(...pitches), Math.max(...pitches)],
|
||||
finalRollDeg: rolls.at(-1),
|
||||
terrainDelayMs: TERRAIN_DELAY_MS,
|
||||
interrupt: {
|
||||
movementBefore,
|
||||
movementAfter,
|
||||
framesAfter: after.length,
|
||||
rollBeforeCutDeg: wrapDeg(cut.rollBefore),
|
||||
rollAfterCutDeg: wrapDeg(cut.rollAfter),
|
||||
rollAfterSettleDeg: rollAfterSettle,
|
||||
},
|
||||
series: rows.map((r) => ({
|
||||
t: Number((r.t - rows[0].t).toFixed(0)),
|
||||
roll: Number(wrapDeg(r.rollDeg).toFixed(3)),
|
||||
heading: Number(r.headingDeg.toFixed(2)),
|
||||
pitch: Number(r.pitchDeg.toFixed(2)),
|
||||
height: Number(r.height.toFixed(1)),
|
||||
})),
|
||||
}, null, 2),
|
||||
);
|
||||
// Contact sheet: the sequence in one image, each tile stamped with the
|
||||
// elapsed time and the roll the camera was actually holding at that moment.
|
||||
const nearestRow = (at) => rows.reduce(
|
||||
(best, row) => (Math.abs(row.t - at) < Math.abs(best.t - at) ? row : best), rows[0],
|
||||
);
|
||||
const cols = 5;
|
||||
const tileW = 384;
|
||||
const tileH = Math.round((950 / 1500) * tileW);
|
||||
const labelH = 26;
|
||||
const gap = 6;
|
||||
const sheetRows = Math.ceil(shots.length / cols);
|
||||
const sheetW = (cols * tileW) + ((cols + 1) * gap);
|
||||
const sheetH = (sheetRows * (tileH + labelH)) + ((sheetRows + 1) * gap);
|
||||
const composites = [];
|
||||
const labels = [];
|
||||
for (let i = 0; i < shots.length; i += 1) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const left = gap + (col * (tileW + gap));
|
||||
const top = gap + (row * (tileH + labelH + gap));
|
||||
composites.push({
|
||||
input: await sharp(shots[i].file).resize(tileW, tileH, { fit: 'fill' }).toBuffer(),
|
||||
left,
|
||||
top,
|
||||
});
|
||||
const near = nearestRow(shots[i].at);
|
||||
const elapsed = ((near.t - rows[0].t) / 1000).toFixed(0);
|
||||
const roll = wrapDeg(near.rollDeg);
|
||||
const sign = roll >= 0 ? '+' : '−';
|
||||
labels.push(`<text x="${left + 6}" y="${top + tileH + 18}" font-family="monospace" font-size="15" fill="#8fe9ff">`
|
||||
+ `${String(i).padStart(2, '0')} t=${elapsed}s roll ${sign}${Math.abs(roll).toFixed(1)}° alt ${near.height.toFixed(0)}m</text>`);
|
||||
}
|
||||
composites.push({
|
||||
input: Buffer.from(`<svg width="${sheetW}" height="${sheetH}">${labels.join('')}</svg>`),
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const sheetPath = path.join(OUT_DIR, 'sequence-contact-sheet.jpg');
|
||||
await sharp({ create: { width: sheetW, height: sheetH, channels: 3, background: '#05080d' } })
|
||||
.composite(composites)
|
||||
.jpeg({ quality: 86 })
|
||||
.toFile(sheetPath);
|
||||
console.log(` contact sheet → ${sheetPath}`);
|
||||
|
||||
if (MIRROR_DIR) {
|
||||
// Mirror as JPEG: the PNG sequence is ~80 MB, which is a poor thing to
|
||||
// hand a human who just wants to flip through the shot.
|
||||
for (const entry of fs.readdirSync(OUT_DIR)) {
|
||||
const from = path.join(OUT_DIR, entry);
|
||||
if (entry.endsWith('.png')) {
|
||||
await sharp(from).jpeg({ quality: 80 })
|
||||
.toFile(path.join(MIRROR_DIR, entry.replace(/\.png$/, '.jpg')));
|
||||
} else {
|
||||
fs.copyFileSync(from, path.join(MIRROR_DIR, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report(false, 'harness completed', String(error?.message || error).slice(0, 200));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.ok === false).length;
|
||||
const passed = results.filter((r) => r.ok === true).length;
|
||||
console.log(`\n ${passed} passed, ${failed} failed → ${OUT_DIR}`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Are the fly_route pins actually load-bearing?
|
||||
*
|
||||
* A pin that is red only when you delete the whole feature proves very little.
|
||||
* This reverts each fix INDIVIDUALLY — the smallest edit that reintroduces the
|
||||
* original defect — and requires src/routeCinematics.test.mjs to go red for it.
|
||||
* Every entry names the defect it restores, so the count is reproducible rather
|
||||
* than asserted in a commit message.
|
||||
*
|
||||
* node scripts/qa-flyroute-mutations.mjs
|
||||
*
|
||||
* Restores the file on exit, including on failure.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = path.join(ROOT, 'src', 'cameraVerbs.js');
|
||||
const TESTS = 'src/routeCinematics.test.mjs';
|
||||
|
||||
/** @type {Array<{defect: string, from: string, to: string}>} */
|
||||
const MUTATIONS = [
|
||||
{
|
||||
defect: 'interrupt leaves the horizon tilted (round-1 behaviour)',
|
||||
from: 'const leveled = wasRoute && !tracking ? levelCameraRoll() : false;',
|
||||
to: 'const leveled = false;',
|
||||
},
|
||||
{
|
||||
defect: 'completion freezes a bank into the final frame',
|
||||
from: 'const appliedBankDeg = state.bankDeg * profile.speed;',
|
||||
to: 'const appliedBankDeg = state.bankDeg;',
|
||||
},
|
||||
{
|
||||
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
|
||||
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
||||
},
|
||||
{
|
||||
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
||||
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
|
||||
to: ' // branch removed',
|
||||
},
|
||||
{
|
||||
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
||||
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
|
||||
to: ' state.floorM = carto.height;',
|
||||
},
|
||||
{
|
||||
defect: 'the flight never warms its own corridor',
|
||||
from: ' warmRouteCorridor(state, 0, ROUTE_WARM_START_M);',
|
||||
to: ' // warm removed',
|
||||
},
|
||||
{
|
||||
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
||||
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'the launch-altitude hold is dropped and the eye descends blind',
|
||||
from: ' if (!state.floorKnown && Number.isFinite(state.safeHoldHeightM)) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'releasing the hold onto late data drops the eye without a limit',
|
||||
from: ' if (Number.isFinite(state.appliedHeightM)) {',
|
||||
to: ' if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'a late mid-flight floor is taken whole and snaps the eye',
|
||||
from: ' state.floorM = routeFloorHoldM(state.floorM, sampledFloor, step);\n state.floorKnown = true;',
|
||||
to: ' state.floorM = sampledFloor;\n state.floorKnown = true;',
|
||||
},
|
||||
{
|
||||
defect: 'the lookahead floor read is dropped (clearance only underfoot)',
|
||||
from: ' const warm = [here, ahead].filter((value) => Number.isFinite(value));',
|
||||
to: ' const warm = [here].filter((value) => Number.isFinite(value));',
|
||||
},
|
||||
{
|
||||
defect: 'the shaping floor adopts rises instantly and pops at cell edges',
|
||||
from: ' if (!Number.isFinite(previousM)) return sampledM;',
|
||||
to: ' if (!Number.isFinite(previousM) || sampledM >= previousM) return sampledM;',
|
||||
},
|
||||
{
|
||||
defect: 'the mesh probe fires every arming frame instead of once per route',
|
||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
||||
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
||||
},
|
||||
{
|
||||
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
||||
from: ' if (cold.length && !state.meshProbeCoveredCold) return false;',
|
||||
to: ' // partial-corridor guard removed',
|
||||
},
|
||||
{
|
||||
defect: 'the probe re-reads cells the cache already answered',
|
||||
from: ' const probe = state.probeFn(cold) || {};',
|
||||
to: ' const probe = state.probeFn(cells) || {};',
|
||||
},
|
||||
{
|
||||
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
|
||||
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
||||
},
|
||||
{
|
||||
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
||||
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
||||
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
||||
},
|
||||
{
|
||||
defect: 'the OS reduced-motion preference is never consulted',
|
||||
from: ' reducedMotion: prefersReducedMotion(),',
|
||||
to: ' reducedMotion: false,',
|
||||
},
|
||||
];
|
||||
|
||||
const original = fs.readFileSync(SOURCE, 'utf8');
|
||||
let caught = 0;
|
||||
const missed = [];
|
||||
|
||||
process.on('exit', () => fs.writeFileSync(SOURCE, original));
|
||||
|
||||
console.log(`\nfly_route pin strength — ${MUTATIONS.length} individual reverts\n`);
|
||||
for (const { defect, from, to } of MUTATIONS) {
|
||||
if (!original.includes(from)) {
|
||||
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
|
||||
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(SOURCE, original.replace(from, to));
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
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('; ');
|
||||
}
|
||||
if (red) {
|
||||
caught += 1;
|
||||
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
|
||||
} else {
|
||||
missed.push(defect);
|
||||
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(SOURCE, original);
|
||||
|
||||
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
|
||||
if (missed.length) {
|
||||
console.log('\n uncovered:');
|
||||
for (const item of missed) console.log(` - ${item}`);
|
||||
}
|
||||
process.exitCode = missed.length ? 1 : 0;
|
||||
@@ -0,0 +1,721 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Moving visual evidence for focus de-emphasis and aircraft recession.
|
||||
*
|
||||
* The operator owns the live dev server; this script never starts one:
|
||||
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
* --screenshots-dir qa-shots/focus-evidence \
|
||||
* --json qa-shots/focus-evidence/report.json
|
||||
* node scripts/qa-focus-evidence.mjs --headful --smoke
|
||||
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
||||
* --params '{"focus":{"dimFloor":0.35},"horizon":{"scaleFloor":0.5}}'
|
||||
* node scripts/qa-focus-evidence.mjs --headful --basemap osm
|
||||
*
|
||||
* The harness owns the Cesium frame clock during capture: every frame applies
|
||||
* synthetic updates, advances explicit virtual time, calls scene.render(),
|
||||
* then screenshots. Headless mode keeps SwiftShader for CI-relative evidence;
|
||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, fallback = null) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
const hasFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
||||
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
const SMOKE = hasFlag('--smoke');
|
||||
const MAP_STACK_IDS = Object.freeze(['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
|
||||
const BASEMAP = getOpt('--basemap', 'photoreal');
|
||||
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||||
const FRAME_COUNT = SMOKE ? 6 : 30;
|
||||
const FRAME_MS = 100;
|
||||
const TILE_SETTLE_TIMEOUT_MS = 45_000;
|
||||
const TARGET_ID = 'f0c001';
|
||||
const _firstCaptureScenarios = new Set();
|
||||
if (!MAP_STACK_IDS.includes(BASEMAP)) {
|
||||
throw new Error(`Invalid --basemap ${BASEMAP}; expected one of ${MAP_STACK_IDS.join(', ')}`);
|
||||
}
|
||||
const TARGET = Object.freeze({
|
||||
id: TARGET_ID,
|
||||
callsign: 'FOCUS1',
|
||||
longitude: -97.7431,
|
||||
latitude: 30.2672,
|
||||
altitudeM: 3_000,
|
||||
klass: 'airliner',
|
||||
});
|
||||
// Owner's 13:58 horizon composition: a low, east-facing regional view whose
|
||||
// distant field sits close to the geometric limb instead of a whole-globe view.
|
||||
const HORIZON_CAMERA = Object.freeze([
|
||||
-100, 30, 1_000_000, Math.PI / 2, -0.52,
|
||||
]);
|
||||
|
||||
function parseParams() {
|
||||
const raw = getOpt('--params');
|
||||
if (!raw) return { focus: {}, horizon: {} };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid --params JSON: ${error.message}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('--params must be a JSON object');
|
||||
}
|
||||
if (parsed.focus || parsed.horizon) {
|
||||
if ((parsed.focus && (typeof parsed.focus !== 'object' || Array.isArray(parsed.focus)))
|
||||
|| (parsed.horizon && (typeof parsed.horizon !== 'object' || Array.isArray(parsed.horizon)))) {
|
||||
throw new Error('--params focus and horizon values must be JSON objects');
|
||||
}
|
||||
return { focus: parsed.focus || {}, horizon: parsed.horizon || {} };
|
||||
}
|
||||
const focusKeys = new Set([
|
||||
'paddingPx', 'dimFloor', 'nearerBehavior', 'hysteresisPx',
|
||||
'distanceHysteresisRatio', 'attackMs', 'releaseMs', 'writeEpsilon',
|
||||
]);
|
||||
const horizonKeys = new Set([
|
||||
'startLimbRatio', 'scaleFloor', 'alphaFloor', 'combinedAlphaFloor',
|
||||
'globeViewBlendStartM', 'globeViewBlendEndM', 'globeViewHeightM',
|
||||
'earthRadiusM', 'writeEpsilon',
|
||||
]);
|
||||
const focus = {};
|
||||
const horizon = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (focusKeys.has(key)) focus[key] = value;
|
||||
if (horizonKeys.has(key)) horizon[key] = value;
|
||||
}
|
||||
return { focus, horizon };
|
||||
}
|
||||
|
||||
function add(a, b) {
|
||||
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
}
|
||||
|
||||
function scale(v, amount) {
|
||||
return [v[0] * amount, v[1] * amount, v[2] * amount];
|
||||
}
|
||||
|
||||
function screenPlanePosition(basis, xPx, yPx, fartherM = 0) {
|
||||
return add(
|
||||
add(
|
||||
add(basis.target, scale(basis.right, xPx * basis.metresPerPixel)),
|
||||
scale(basis.up, yPx * basis.metresPerPixel),
|
||||
),
|
||||
scale(basis.away, fartherM),
|
||||
);
|
||||
}
|
||||
|
||||
function shotPath(scenario, frame) {
|
||||
return path.join(SCREENSHOTS_DIR, `${scenario}-${frame}.png`);
|
||||
}
|
||||
|
||||
async function readTileReadiness(page) {
|
||||
return page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const controller = gev.mapStackController;
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
return {
|
||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||
tileSettleApplicable: applicable,
|
||||
activeStack,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function awaitTilesSettled(page, scenario) {
|
||||
const result = await page.evaluate(async (timeoutMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const controller = gev.mapStackController;
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
// The map-stack controller owns the authoritative Google tileset handle;
|
||||
// the bootstrap field is retained only as a compatibility fallback.
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!applicable) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: false,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
if (tileset.tilesLoaded === true) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
let allTilesLoadedObserved = false;
|
||||
const removeListener = tileset.allTilesLoaded?.addEventListener?.(() => {
|
||||
allTilesLoadedObserved = true;
|
||||
});
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return {
|
||||
tilesSettled: false,
|
||||
tileSettleApplicable: true,
|
||||
activeStack,
|
||||
timedOut: true,
|
||||
};
|
||||
} finally {
|
||||
if (typeof removeListener === 'function') removeListener();
|
||||
}
|
||||
}, TILE_SETTLE_TIMEOUT_MS);
|
||||
const status = result.tilesSettled ? 'settled' : `NOT settled after ${TILE_SETTLE_TIMEOUT_MS / 1000}s`;
|
||||
console.log(` tiles (${scenario}): ${status}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function capture(page, scenario, frame) {
|
||||
if (!_firstCaptureScenarios.has(scenario)) {
|
||||
_firstCaptureScenarios.add(scenario);
|
||||
await awaitTilesSettled(page, scenario);
|
||||
}
|
||||
const tileReadiness = await readTileReadiness(page);
|
||||
const out = shotPath(scenario, frame);
|
||||
// Chromium's Page.captureScreenshot can wedge while a manually driven
|
||||
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
||||
// that same frame without handing frame ownership back to the browser.
|
||||
const dataUrl = await page.evaluate(() => (
|
||||
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
||||
));
|
||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||
return { file: out, ...tileReadiness };
|
||||
}
|
||||
|
||||
async function captureSequenceFrame(page, scenario, frameIndex, update) {
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: update`);
|
||||
await update();
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: render`);
|
||||
await advanceEvidenceFrame(page, FRAME_MS);
|
||||
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: screenshot`);
|
||||
return capture(page, scenario, `frame-${String(frameIndex).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
async function takeFrameClock(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
return seam.takeFrameClock();
|
||||
});
|
||||
if (!result?.ok) throw new Error('Unable to take ownership of the evidence frame clock');
|
||||
}
|
||||
|
||||
async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
||||
return page.evaluate((stepMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
const nowMs = seam.advanceFrameClock(stepMs);
|
||||
const JulianDate = viewer.clock.currentTime.constructor;
|
||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||
viewer.scene.render(viewer.clock.currentTime);
|
||||
// Cross a task boundary so Chrome can commit the explicitly rendered
|
||||
// surface before capture. Cesium's drained default loop stays disabled.
|
||||
return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0));
|
||||
}, deltaMs);
|
||||
}
|
||||
|
||||
async function advanceEvidenceDuration(page, durationMs) {
|
||||
// Cross one 80 ms consumer quantum to publish newly changed desired state,
|
||||
// then jump to the requested endpoint. A zero-delta render is insufficient:
|
||||
// the production focus passes correctly retain their cadence gate.
|
||||
const totalMs = Math.max(0, durationMs);
|
||||
const publishMs = Math.min(80, totalMs);
|
||||
await advanceEvidenceFrame(page, publishMs);
|
||||
if (totalMs > publishMs) await advanceEvidenceFrame(page, totalMs - publishMs);
|
||||
}
|
||||
|
||||
async function releaseFrameClock(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('flights').module.__focusEvidence.releaseFrameClock();
|
||||
});
|
||||
}
|
||||
|
||||
async function installSyntheticFetches(page) {
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const json = (body) => Promise.resolve(new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (url.includes('/api/opensky-track')) return json({ path: [] });
|
||||
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
|
||||
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
|
||||
if (url.includes('/api/adsbdb/')) return json({ found: false });
|
||||
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
|
||||
return realFetch(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000, polling: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
async function setBasemap(page, stackId) {
|
||||
const result = await page.evaluate(async (id) => (
|
||||
window.__godsEyeView.styleManager.setMapStack(id)
|
||||
), stackId);
|
||||
if (!result?.ok) {
|
||||
throw new Error(`Unable to activate --basemap ${stackId}: ${result?.error || 'unknown error'}`);
|
||||
}
|
||||
return result.activeStack;
|
||||
}
|
||||
|
||||
async function enableLayer(page, layerId) {
|
||||
await page.evaluate(async (id) => {
|
||||
await window.__godsEyeView.dataManager.setEnabled(id, true);
|
||||
}, layerId);
|
||||
}
|
||||
|
||||
async function requireEvidenceSeams(page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
return {
|
||||
flights: Boolean(manager.layers.get('flights')?.module?.__focusEvidence),
|
||||
vessels: Boolean(manager.layers.get('ais-live-vessels')?.module?.__focusEvidence),
|
||||
};
|
||||
});
|
||||
if (!result.flights || !result.vessels) {
|
||||
throw new Error('Focus evidence seams are unavailable. Use the Vite dev server (not a production preview/build).');
|
||||
}
|
||||
}
|
||||
|
||||
async function setCamera(page, values) {
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * Math.PI / 180,
|
||||
latitude: lat * Math.PI / 180,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
}, values);
|
||||
await advanceEvidenceDuration(page, 300);
|
||||
}
|
||||
|
||||
async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
||||
const result = await page.evaluate(({ targetRecord, extras }) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const injected = layer.__focusEvidence.setAircraft([targetRecord, ...extras]);
|
||||
const tracked = layer.trackById(targetRecord.id);
|
||||
return { injected, tracked };
|
||||
}, { targetRecord: target, extras: extraAircraft });
|
||||
if (!result.injected?.ok || !result.tracked) {
|
||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await advanceEvidenceDuration(page, 400);
|
||||
}
|
||||
|
||||
async function readTrackedBasis(page) {
|
||||
return page.evaluate((targetId) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
||||
const camera = gev.viewer.camera;
|
||||
if (!target) throw new Error('Tracked target position unavailable');
|
||||
const toTarget = {
|
||||
x: target.x - camera.positionWC.x,
|
||||
y: target.y - camera.positionWC.y,
|
||||
z: target.z - camera.positionWC.z,
|
||||
};
|
||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||
return {
|
||||
target: [target.x, target.y, target.z],
|
||||
right: [camera.rightWC.x, camera.rightWC.y, camera.rightWC.z],
|
||||
up: [camera.upWC.x, camera.upWC.y, camera.upWC.z],
|
||||
away: [toTarget.x / distance, toTarget.y / distance, toTarget.z / distance],
|
||||
metresPerPixel,
|
||||
targetDistanceM: distance,
|
||||
};
|
||||
}, TARGET_ID);
|
||||
}
|
||||
|
||||
async function flightSnapshot(page) {
|
||||
return page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.snapshot()
|
||||
));
|
||||
}
|
||||
|
||||
async function setTuning(page, params) {
|
||||
return page.evaluate((next) => (
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.setTuning(next)
|
||||
), params);
|
||||
}
|
||||
|
||||
async function runControlledCrossing(page, effectiveParams) {
|
||||
const scenario = 's1-controlled-crossing';
|
||||
if (SMOKE) console.log(' setup: inject + track');
|
||||
await injectAndTrackTarget(page, [{ ...TARGET, id: 'f0c002', callsign: 'CROSS2' }]);
|
||||
if (SMOKE) console.log(' setup: read tracked basis');
|
||||
const basis = await readTrackedBasis(page);
|
||||
// Stay beyond the default 8% range-side hysteresis band so S1 exercises
|
||||
// the farther-contact dim path instead of hovering in its neutral band.
|
||||
const crossingDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
||||
const start = screenPlanePosition(basis, -180, 0, crossingDepthM);
|
||||
await page.evaluate((position) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
||||
}, start);
|
||||
// The placeholder begins at the target so tracking can establish its camera
|
||||
// first. Restore fully off-target before frame 0; otherwise the sequence
|
||||
// would start with inherited dimming rather than showing a true attack.
|
||||
if (SMOKE) console.log(' setup: settle off-target contact');
|
||||
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
||||
const frames = [];
|
||||
for (let i = 0; i < FRAME_COUNT; i += 1) {
|
||||
const xPx = -180 + (360 * i) / (FRAME_COUNT - 1);
|
||||
const cartesian = screenPlanePosition(basis, xPx, 0, crossingDepthM);
|
||||
const captured = await captureSequenceFrame(page, scenario, i, () => page.evaluate((position) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
||||
}, cartesian));
|
||||
const snapshot = await flightSnapshot(page);
|
||||
frames.push({ index: i, xPx, ...captured, contacts: snapshot });
|
||||
}
|
||||
return { id: scenario, frameCount: frames.length, basis, crossingDepthM, frames };
|
||||
}
|
||||
|
||||
async function vesselRowsAroundTarget(page) {
|
||||
return page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
||||
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const rows = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
const eastM = Math.cos(angle) * (15 + (i % 3) * 12);
|
||||
const northM = Math.sin(angle) * (15 + (i % 3) * 12);
|
||||
// Cesium.Transforms is not global; a small local lat/lon offset is
|
||||
// sufficient at harbor scale and keeps the injected vessel at sea level.
|
||||
const carto = Cartographic.fromCartesian(target, ellipsoid);
|
||||
const lat = carto.latitude * 180 / Math.PI + northM / 111_320;
|
||||
const lon = carto.longitude * 180 / Math.PI
|
||||
+ eastM / (111_320 * Math.cos(carto.latitude));
|
||||
rows.push({
|
||||
mmsi: String(990000000 + i),
|
||||
name: `HARBOR ${String(i + 1).padStart(2, '0')}`,
|
||||
lat,
|
||||
lon,
|
||||
speed: 4 + (i % 5),
|
||||
course: (i * 31) % 360,
|
||||
type: i % 3 === 0 ? 'Cargo' : 'Tug',
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
|
||||
async function runHarborClutter(page, effectiveParams) {
|
||||
const scenario = 's2-harbor-clutter';
|
||||
const lowTarget = { ...TARGET, altitudeM: 50 };
|
||||
// Harbor contacts straddle the tracked low-altitude subject's range. This
|
||||
// scenario deliberately exercises the documented tunable all-overlap policy
|
||||
// instead of being neutralized by the production `allow` range band.
|
||||
const clutterFocus = { ...effectiveParams.focus, nearerBehavior: 'dim' };
|
||||
await injectAndTrackTarget(page, [], lowTarget);
|
||||
const rows = await vesselRowsAroundTarget(page);
|
||||
await page.evaluate((vessels) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence;
|
||||
seam.setVessels(vessels);
|
||||
}, rows);
|
||||
|
||||
await setTuning(page, { focus: { ...clutterFocus, dimFloor: 1 } });
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const beforeCapture = await capture(page, scenario, 'before');
|
||||
const before = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
||||
));
|
||||
|
||||
await setTuning(page, { ...effectiveParams, focus: clutterFocus });
|
||||
await advanceEvidenceDuration(page, Math.max(500, effectiveParams.focus.attackMs + 200));
|
||||
const afterCapture = await capture(page, scenario, 'after');
|
||||
const after = await page.evaluate(() => (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
||||
));
|
||||
await setTuning(page, effectiveParams);
|
||||
return {
|
||||
id: scenario,
|
||||
focusParams: clutterFocus,
|
||||
frames: [
|
||||
{ phase: 'before', ...beforeCapture, contacts: before },
|
||||
{ phase: 'after', ...afterCapture, contacts: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runAirportTraffic(page, effectiveParams) {
|
||||
const scenario = 's3-airport-traffic';
|
||||
const placeholders = Array.from({ length: 40 }, (_, index) => ({
|
||||
...TARGET,
|
||||
id: `f3${String(index).padStart(4, '0')}`,
|
||||
callsign: `APT${String(index).padStart(2, '0')}`,
|
||||
klass: index % 7 === 0 ? 'widebody' : index % 5 === 0 ? 'fastjet' : 'airliner',
|
||||
}));
|
||||
await injectAndTrackTarget(page, placeholders);
|
||||
const basis = await readTrackedBasis(page);
|
||||
const trafficDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
||||
const startingMoves = placeholders.map((record, index) => {
|
||||
const angle = (index / placeholders.length) * Math.PI * 2;
|
||||
return {
|
||||
id: record.id,
|
||||
cartesian: screenPlanePosition(
|
||||
basis,
|
||||
Math.cos(angle) * 220,
|
||||
Math.sin(angle) * 220 * 0.55,
|
||||
trafficDepthM + index * 3,
|
||||
),
|
||||
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
||||
};
|
||||
});
|
||||
await page.evaluate((positions) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft(positions);
|
||||
}, startingMoves);
|
||||
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
||||
const frames = [];
|
||||
for (let frame = 0; frame < FRAME_COUNT; frame += 1) {
|
||||
const progress = frame / (FRAME_COUNT - 1);
|
||||
const radiusPx = 220 - 205 * progress;
|
||||
const moves = placeholders.map((record, index) => {
|
||||
const angle = (index / placeholders.length) * Math.PI * 2 + progress * 0.35;
|
||||
return {
|
||||
id: record.id,
|
||||
cartesian: screenPlanePosition(
|
||||
basis,
|
||||
Math.cos(angle) * radiusPx,
|
||||
Math.sin(angle) * radiusPx * 0.55,
|
||||
trafficDepthM + index * 3,
|
||||
),
|
||||
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
||||
};
|
||||
});
|
||||
const captured = await captureSequenceFrame(page, scenario, frame, () => page.evaluate((positions) => {
|
||||
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
seam.moveAircraft(positions);
|
||||
}, moves));
|
||||
frames.push({ index: frame, radiusPx, ...captured, contacts: await flightSnapshot(page) });
|
||||
}
|
||||
return { id: scenario, frameCount: frames.length, basis, frames };
|
||||
}
|
||||
|
||||
async function runHorizonBand(page, effectiveParams) {
|
||||
const scenario = 's4-horizon-band';
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.stopTracking();
|
||||
});
|
||||
const contacts = [];
|
||||
for (let row = 0; row < 6; row += 1) {
|
||||
for (let column = 0; column < 10; column += 1) {
|
||||
contacts.push({
|
||||
id: `f4${String(row * 10 + column).padStart(4, '0')}`,
|
||||
callsign: `LIMB${String(row * 10 + column).padStart(2, '0')}`,
|
||||
longitude: -70.5 + column * 0.42,
|
||||
latitude: 27.5 + row * 0.95,
|
||||
altitudeM: 10_000 + (column % 3) * 800,
|
||||
klass: column % 4 === 0 ? 'widebody' : 'airliner',
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate((records) => {
|
||||
window.__godsEyeView.dataManager.layers.get('flights')
|
||||
.module.__focusEvidence.setAircraft(records);
|
||||
}, contacts);
|
||||
await setCamera(page, HORIZON_CAMERA);
|
||||
|
||||
await setTuning(page, {
|
||||
horizon: { ...effectiveParams.horizon, scaleFloor: 1, alphaFloor: 1 },
|
||||
});
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const beforeCapture = await capture(page, scenario, 'before');
|
||||
const before = await flightSnapshot(page);
|
||||
|
||||
await setTuning(page, effectiveParams);
|
||||
await advanceEvidenceDuration(page, 450);
|
||||
const afterCapture = await capture(page, scenario, 'after');
|
||||
const after = await flightSnapshot(page);
|
||||
return {
|
||||
id: scenario,
|
||||
camera: HORIZON_CAMERA,
|
||||
frames: [
|
||||
{ phase: 'before', ...beforeCapture, contacts: before },
|
||||
{ phase: 'after', ...afterCapture, contacts: after },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function browserContext(page, browser) {
|
||||
const gl = await page.evaluate(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
const debug = context?.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
dpr: window.devicePixelRatio,
|
||||
vendor: debug
|
||||
? context.getParameter(debug.UNMASKED_VENDOR_WEBGL)
|
||||
: context?.getParameter(context.VENDOR) || 'unavailable',
|
||||
renderer: debug
|
||||
? context.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
||||
: context?.getParameter(context.RENDERER) || 'unavailable',
|
||||
};
|
||||
});
|
||||
return {
|
||||
browser: await browser.version(),
|
||||
userAgent: gl.userAgent,
|
||||
webglVendor: gl.vendor,
|
||||
webglRenderer: gl.renderer,
|
||||
viewport: { ...VIEWPORT, dpr: gl.dpr },
|
||||
machine: {
|
||||
hostname: os.hostname(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
},
|
||||
caveat: HEADFUL
|
||||
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
||||
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requestedParams = parseParams();
|
||||
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Live dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
||||
}
|
||||
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(JSON_PATH), { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(VIEWPORT);
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
url: APP_URL,
|
||||
screenshotsDir: SCREENSHOTS_DIR,
|
||||
requestedParams,
|
||||
requestedBasemap: BASEMAP,
|
||||
activeBasemap: null,
|
||||
smoke: SMOKE,
|
||||
effectiveParams: null,
|
||||
context: null,
|
||||
scenarios: [],
|
||||
consoleMessages,
|
||||
};
|
||||
try {
|
||||
await installSyntheticFetches(page);
|
||||
await waitForApp(page);
|
||||
report.activeBasemap = await setBasemap(page, BASEMAP);
|
||||
await enableLayer(page, 'flights');
|
||||
await enableLayer(page, 'ais-live-vessels');
|
||||
await requireEvidenceSeams(page);
|
||||
await takeFrameClock(page);
|
||||
report.context = await browserContext(page, browser);
|
||||
report.effectiveParams = await setTuning(page, requestedParams);
|
||||
|
||||
console.log(`Focus evidence → ${APP_URL}`);
|
||||
console.log(` screenshots: ${SCREENSHOTS_DIR}`);
|
||||
console.log(` renderer : ${report.context.webglRenderer}`);
|
||||
console.log(` basemap : ${report.activeBasemap}`);
|
||||
console.log(` params : ${JSON.stringify(report.effectiveParams)}`);
|
||||
console.log(` mode : ${SMOKE ? 'smoke (S1, 6 frames)' : 'full (S1-S4)'}`);
|
||||
console.log(` caveat : ${report.context.caveat}`);
|
||||
|
||||
const scenarios = [
|
||||
['S1 controlled crossing', () => runControlledCrossing(page, report.effectiveParams)],
|
||||
['S2 harbor clutter', () => runHarborClutter(page, report.effectiveParams)],
|
||||
['S3 airport traffic', () => runAirportTraffic(page, report.effectiveParams)],
|
||||
['S4 horizon band', () => runHorizonBand(page, report.effectiveParams)],
|
||||
];
|
||||
for (const [label, run] of (SMOKE ? scenarios.slice(0, 1) : scenarios)) {
|
||||
console.log(` capture : ${label}`);
|
||||
await page.bringToFront();
|
||||
report.scenarios.push(await run());
|
||||
}
|
||||
} finally {
|
||||
if (!page.isClosed()) await releaseFrameClock(page).catch(() => {});
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(JSON_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(` report : ${JSON_PATH}`);
|
||||
if (consoleMessages.some((message) => message.type === 'error' || message.type === 'pageerror')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* qa-height-datum.mjs — height/vertical-datum fix numeric proof harness
|
||||
* (docs/plans/2026-07-05-entity-height-datum-fix.md Task 8).
|
||||
*
|
||||
* Scaffold reused verbatim from qa-cctv-v2.mjs: the puppeteer launcher
|
||||
* (Chrome executable discovery, headless flags), `QA_BASE_URL` env,
|
||||
* `record()`/tally pattern, and the Google-Maps-key-injection dev-server
|
||||
* recipe documented in its header comment. See that file for the reasoning
|
||||
* behind the launch flags and the SwiftShader caveats they share.
|
||||
*
|
||||
* Unlike qa-cctv-v2 (frustum GEOMETRY / raycast-count invariants), this
|
||||
* harness asserts NUMBERS: ground heights, altitudes, and terrain-provider
|
||||
* identity. Every assertion is GL-independent (no pixel/screenshot
|
||||
* comparisons) — it reads plain numbers off live Cesium entities and the
|
||||
* app's own public module APIs, exactly like qa-cctv-v2's SERIALIZE_GEOM_SRC
|
||||
* pattern.
|
||||
*
|
||||
* Three assertion groups (brief order):
|
||||
*
|
||||
* 1. CCTV ground correctness — for 5 London + 5 Austin + 3 SF cameras,
|
||||
* assert each record's placed mount ground reads finitely, then compare
|
||||
* its separately exposed `groundPriorM` against the
|
||||
* authoritative Re:Earth ellipsoidal value fetched through the app's
|
||||
* OWN `/api/terrain/heights` proxy (not a second upstream call — the
|
||||
* proxy IS the oracle here, matching Task 2/3's contract). Keeping the
|
||||
* prior separate is essential because Google-3D can legitimately refine
|
||||
* the rendered ground to the photogrammetric mesh while the immutable
|
||||
* Re:Earth prior remains the fallback/datum reference. The prior check is
|
||||
* `|groundPriorM - reearthEllipsoid| < 6m`. Google-3D after
|
||||
* the one-shot snap: London/Austin active-camera ground must fall in
|
||||
* the brief's bands (tileset-vs-DEM legitimately differs by building
|
||||
* height, so bands not exact-match).
|
||||
*
|
||||
* 2. Aircraft — sample >=10 live aircraft via the flights layer's public
|
||||
* API. `renderAltitudeM` isn't exposed as a named field on any public
|
||||
* method (only `flights.js`/`militaryFlights.js` module-private
|
||||
* `_flightData` carries `geoAltitudeM`/`renderAltitudeM` directly), so
|
||||
* this harness reconstructs both sides purely from public methods:
|
||||
* - render height: `getAllPositions()[i].altitudeM`, which is
|
||||
* `Cartographic.fromCartesian(billboard.position).height` — i.e.
|
||||
* exactly the height passed to `Cartesian3.fromDegrees(lon, lat,
|
||||
* renderAltitudeM)` at the placement site (flights.js ~:2100).
|
||||
* - aviation/baro height: `getNearby()`/`findByQuery()`'s
|
||||
* `altitudeM`, which is `info.altitude` (the UNTOUCHED sticky
|
||||
* barometric field — flights.js ~:2111) — this is the "geoAltM ??
|
||||
* baroAltM" input side, not the render output.
|
||||
* This is the brief's documented fallback ("if geo/baro fields aren't
|
||||
* exposed on the public state, assert the weaker but still-meaningful
|
||||
* under-terrain + finite/plausible checks") PLUS a same-process,
|
||||
* Node-side independent recomputation of `baroM + geoidHeight(lat,lon)`
|
||||
* via a direct import of `src/data/geoid.js` (a pure module, imported
|
||||
* the same way qa-cctv-v2.mjs imports `computeFrustumGeometry` from
|
||||
* cctv.js) — giving a real numeric cross-check without reaching into
|
||||
* the layer's private closure state.
|
||||
*
|
||||
* Under-terrain detector fingerprint (tightened): a real datum-miss
|
||||
* renders an aircraft at the ~0m ellipsoidal SENTINEL (the pre-existing
|
||||
* "no data yet" default) while its actual terrain ground is nowhere
|
||||
* near 0 — that combination is unambiguous. A non-zero under-terrain
|
||||
* reading is NOT hard-failed: a legitimately climbing/descending
|
||||
* aircraft can transiently render close to real ground near an airport
|
||||
* (e.g. a climb-out a few dozen metres above a field that itself sits
|
||||
* well above sea level), which is correct behavior, not a bug. Those
|
||||
* ambiguous cases are recorded INCONCLUSIVE with the raw numbers
|
||||
* rather than FAILed.
|
||||
*
|
||||
* 3. Regime C re-resolve — switch the map stack to keyless OSM in-page via
|
||||
* `mapStackController.setStack('osm')`, assert
|
||||
* `viewer.terrainProvider` is NOT an `EllipsoidTerrainProvider`
|
||||
* (constructor-name check, matching qa-cctv-v2's "borrow statics off a
|
||||
* live instance's constructor" pattern — no `window.Cesium` global
|
||||
* exists), and that CCTV grounds re-resolve within one event cycle
|
||||
* (`gev:map-stack-changed` → cctv.js's regime re-arm — see its Task 5
|
||||
* handler) by serializing a camera's ground height before/after the
|
||||
* switch settles and asserting it moved OFF any old fabricated-prior
|
||||
* value.
|
||||
*
|
||||
* Upstream-dependent assertions (live Re:Earth `/api/terrain/heights`, live
|
||||
* OpenSky aircraft) are marked INCONCLUSIVE — not FAIL — when the upstream
|
||||
* is unreachable this run, mirroring qa-cctv-v2's tiles-timeout handling.
|
||||
* GL-dependent tile settling (the Google-3D one-shot ground snap actually
|
||||
* completing under headless SwiftShader) is likewise inconclusive-on-timeout.
|
||||
*
|
||||
* Run: QA_BASE_URL=http://localhost:4300 node scripts/qa-height-datum.mjs
|
||||
*
|
||||
* Exit 0 = no hard failures. Non-zero = at least one hard FAIL (or harness error).
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const APP_URL = getOpt('--url', BASE_URL);
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'height-datum-qa');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
||||
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
||||
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
||||
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
||||
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
||||
// pinned browser beats the newest one for regression harnesses.
|
||||
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
function findChromeExecutable() {
|
||||
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Waits for the scene's 3D tileset to report tilesLoaded===true. Copied from
|
||||
* qa-cctv-v2.mjs (see its header comment for why this must be re-awaited
|
||||
* fresh before each action that depends on a REAL ground sample, not
|
||||
* reused from an earlier wait).
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {number} [timeoutMs=15000]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
return page.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const prims = scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a camera's placed mount-point cartographic height off its
|
||||
* `cctv-<id>-ray-tl` polyline entity (first position = the mount, per
|
||||
* cctv.js `buildCoverageEntities`/`applyFrustumGeometry`), returns
|
||||
* `{ mountAltM, groundM }` where `groundM = mountAltM - mountHeightM` — the
|
||||
* SAME arithmetic cctv.js itself uses in reverse
|
||||
* (`mountAlt = ground + mountHeightM` in `computeFrustumGeometry`).
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} camId
|
||||
* @returns {Promise<{mountAltM:number, mountHeightM:number, groundM:number}|null>}
|
||||
*/
|
||||
async function readCameraGround(page, camId) {
|
||||
return page.evaluate((id) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const ent = viewer.entities.getById(`cctv-${id}-ray-tl`);
|
||||
if (!ent || !ent.polyline) return null;
|
||||
const positions = ent.polyline.positions.getValue(time);
|
||||
if (!Array.isArray(positions) || positions.length < 1) return null;
|
||||
const mount = positions[0];
|
||||
// No window.Cesium global (qa-cctv-v2 precedent) — borrow Cartographic's
|
||||
// static fromCartesian off a live Cartographic instance's constructor.
|
||||
const carto = viewer.scene.globe.ellipsoid.cartesianToCartographic(mount);
|
||||
const mountAltM = carto.height;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const cam = mod.getUIState().cameras.find((c) => c.id === id);
|
||||
const mountHeightM = cam ? cam.mountHeightM : null;
|
||||
return {
|
||||
mountAltM,
|
||||
mountHeightM,
|
||||
groundM: Number.isFinite(mountHeightM) ? mountAltM - mountHeightM : null,
|
||||
};
|
||||
}, camId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the authoritative Re:Earth ellipsoidal ground height for a
|
||||
* (lat, lon) pair through the APP'S OWN `/api/terrain/heights` proxy (the
|
||||
* same oracle terrainHeights.js's `resolveEllipsoidalGround` uses) — this is
|
||||
* the ground-truth reference for assertion group 1, fetched directly (not
|
||||
* through in-page code) so a proxy hiccup can be told apart from an app bug.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
||||
*/
|
||||
async function fetchReearthEllipsoid(lat, lon) {
|
||||
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
const v = Number(body?.results?.[0]?.ellipsoid);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\nHeight-Datum Fix Numeric Proof`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
// Node-side geoid grid, used only for the aircraft baro+N cross-check
|
||||
// (independent of the app's own bundled copy — same package, separate
|
||||
// process, so this is a real recomputation, not reading the app's cache).
|
||||
await ensureGeoidReady();
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const t = msg.text();
|
||||
if (!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(t)) {
|
||||
consoleErrors.push(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
await sleep(4000);
|
||||
|
||||
// =========================================================================
|
||||
// Group 1: CCTV ground correctness
|
||||
// =========================================================================
|
||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const entry = dm.layers.get('cctv');
|
||||
if (!entry.enabled) await dm.toggle('cctv');
|
||||
});
|
||||
|
||||
const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count);
|
||||
console.log(`CCTV catalog loaded: ${camCount} cameras. Waiting for the geometry-load queue to drain...`);
|
||||
// The full-catalog drain is NOT a precondition for group 1's assertions
|
||||
// below: every record renders correctly from `record.groundPrior` the
|
||||
// instant the Re:Earth prior batch lands (Task 5 contract #1/#4 — "the
|
||||
// prior IS the resolution" on globe stacks, and the fallback path in
|
||||
// google-3d before a real sample resolves), which is exactly what makes
|
||||
// the per-camera ground reads meaningful even mid-drain. The queue drain
|
||||
// itself additionally attempts ONE REAL scene.sampleHeight per camera in
|
||||
// google-3d regime (Task 5 contract #3/#5 — the ≤1×N invariant
|
||||
// qa-cctv-v2 also locks), and at this branch's full city-packs catalog
|
||||
// size (800 cameras: 250 Austin + 300 Caltrans + 250 TfL — measured
|
||||
// directly probing this harness's own dev server) that can take many
|
||||
// minutes under headless SwiftShader (empirically ~2-6s/sample once
|
||||
// real tile contention kicks in past the first few hundred). Waiting for
|
||||
// FULL completion here would make routine harness runs impractically
|
||||
// slow for no assertion gain, so this bounds the wait to a fixed,
|
||||
// reasonable ceiling and reports the outcome as environmental/scale
|
||||
// dependent (inconclusive, not a hard fail) rather than failing the
|
||||
// whole run — mirroring qa-cctv-v2's own tiles-timeout handling.
|
||||
const DRAIN_WAIT_CEILING_MS = 120000;
|
||||
const drained = await page.waitForFunction(
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const ui = mod.getUIState();
|
||||
return ui.loading && ui.loading.active === false;
|
||||
},
|
||||
{ timeout: DRAIN_WAIT_CEILING_MS }
|
||||
).then(() => true).catch(() => false);
|
||||
record(`CCTV geometry-load queue drains within ${Math.round(DRAIN_WAIT_CEILING_MS / 1000)}s (N=${camCount})`,
|
||||
drained ? true : null,
|
||||
drained
|
||||
? 'loading.active === false'
|
||||
: `did not fully drain within the ceiling at N=${camCount} — environmental/scale-dependent under headless SwiftShader ` +
|
||||
'(each un-drained camera still renders correctly from its Re:Earth prior; group 1 assertions below read that prior directly, so this does not block them)');
|
||||
|
||||
// Pull the full camera list, then bucket by city so we can pick 5
|
||||
// London + 5 Austin + 3 SF regardless of catalog ordering. TfL tags
|
||||
// `city: 'London'` and Austin's own pack tags `city: 'Austin'` exactly
|
||||
// (vite.config.js); Caltrans tags `city` with an arbitrary upstream
|
||||
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
||||
// Caltrans cameras are identified by proximity to the SF anchor instead
|
||||
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
||||
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
console.log(`Camera catalog: ${allCameras.length} total.`);
|
||||
|
||||
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
||||
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
const sfCams = allCameras
|
||||
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
||||
.slice(0, 3);
|
||||
|
||||
record('found >=5 London (TfL) test cameras', londonCams.length >= 5, `found ${londonCams.length}`);
|
||||
record('found >=5 Austin test cameras', austinCams.length >= 5, `found ${austinCams.length}`);
|
||||
record('found >=3 SF-area (Caltrans) test cameras', sfCams.length >= 3 ? true : null,
|
||||
sfCams.length >= 3
|
||||
? `found ${sfCams.length}`
|
||||
: `found ${sfCams.length} — live Caltrans catalog unavailable/partial this run (upstream INCONCLUSIVE, not an app datum failure)`);
|
||||
|
||||
const cityBuckets = [
|
||||
{ label: 'London', cams: londonCams },
|
||||
{ label: 'Austin', cams: austinCams },
|
||||
{ label: 'SF', cams: sfCams },
|
||||
];
|
||||
|
||||
const groundRows = []; // for the report/console table
|
||||
|
||||
for (const { label, cams } of cityBuckets) {
|
||||
for (const cam of cams) {
|
||||
const state = await page.evaluate((id) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
return mod.getUIState().cameras.find((camera) => camera.id === id) || null;
|
||||
}, cam.id);
|
||||
const geom = await readCameraGround(page, cam.id);
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl entity missing or non-finite height');
|
||||
continue;
|
||||
}
|
||||
record(`${label} ${cam.id}: rendered ground reads finitely`, true,
|
||||
`renderedGround=${geom.groundM.toFixed(1)}m`);
|
||||
const priorM = state?.groundPriorM;
|
||||
if (!Number.isFinite(priorM)) {
|
||||
record(`${label} ${cam.id}: Re:Earth ground prior resolved`, null,
|
||||
`renderedGround=${geom.groundM.toFixed(1)}m; prior unavailable this run`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM: null, reearthM: null });
|
||||
continue;
|
||||
}
|
||||
const reearth = await fetchReearthEllipsoid(cam.lat, cam.lon);
|
||||
if (reearth === null) {
|
||||
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, null,
|
||||
`Re:Earth proxy unreachable this run — prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m (upstream INCONCLUSIVE, not a harness/app failure)`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: null });
|
||||
continue;
|
||||
}
|
||||
const delta = Math.abs(priorM - reearth);
|
||||
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, delta < 6,
|
||||
`prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m reearth=${reearth.toFixed(1)}m Δ=${delta.toFixed(1)}m`);
|
||||
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: reearth });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n Ground-height table (city, camera, rendered ground, stored prior, Re:Earth ellipsoid):');
|
||||
for (const row of groundRows) {
|
||||
console.log(` ${row.label.padEnd(8)} ${row.id.padEnd(24)} rendered=${row.groundM.toFixed(1)}m` +
|
||||
(row.priorM !== null ? ` prior=${row.priorM.toFixed(1)}m` : ' prior=N/A') +
|
||||
(row.reearthM !== null ? ` reearth=${row.reearthM.toFixed(1)}m` : ' reearth=N/A'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Google-3D band check: activate the London and Austin cameras nearest
|
||||
// the viewer (mirrors qa-cctv-v2's focusNearest activation pattern),
|
||||
// wait for the one-shot ground snap to complete (tilesLoaded gate), then
|
||||
// assert the ACTIVE camera's ground falls in the brief's band. Bands
|
||||
// (not exact) because the Google tileset surface legitimately differs
|
||||
// from the Re:Earth DEM by building height once the snap refines it.
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('Checking Google-3D active-camera ground BANDS (post-snap)...');
|
||||
async function checkActiveCameraBand(cityLabel, targetCamId, band) {
|
||||
await page.evaluate((id) => {
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.selectCamera(id, { focus: true, durationSec: 0.1 });
|
||||
}, targetCamId);
|
||||
await sleep(600);
|
||||
const tilesReady = await waitForTilesLoaded(page, 30000);
|
||||
// Give the one-shot completion pass a beat to land even after tiles
|
||||
// report ready (update() ticks on its own interval — see cctv.js).
|
||||
await sleep(1500);
|
||||
const geom = await readCameraGround(page, targetCamId);
|
||||
if (!tilesReady) {
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, null,
|
||||
`tiles never reported loaded within 30s under headless GL — environmental, inconclusive (recordGround=${geom?.groundM?.toFixed?.(1) ?? 'n/a'}m)`);
|
||||
return;
|
||||
}
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, false, 'ground unreadable');
|
||||
return;
|
||||
}
|
||||
const inBand = geom.groundM >= band[0] && geom.groundM <= band[1];
|
||||
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, inBand,
|
||||
`recordGround=${geom.groundM.toFixed(1)}m`);
|
||||
}
|
||||
|
||||
if (londonCams.length) {
|
||||
await checkActiveCameraBand('London', londonCams[0].id, [45, 75]);
|
||||
} else {
|
||||
record('London active camera google-3d ground ∈ [45,75]m', null, 'no London camera available to test');
|
||||
}
|
||||
if (austinCams.length) {
|
||||
await checkActiveCameraBand('Austin', austinCams[0].id, [110, 135]);
|
||||
} else {
|
||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 2: Aircraft render altitude
|
||||
// =========================================================================
|
||||
console.log('Enabling flights layer, waiting for a live aircraft sample...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const entry = dm.layers.get('flights');
|
||||
if (!entry.enabled) await dm.toggle('flights');
|
||||
});
|
||||
|
||||
// OpenSky polls on its own interval; give it real time to land a batch
|
||||
// (the layer polls every ~30s per the documented polling invariant).
|
||||
const gotAircraft = await page.waitForFunction(
|
||||
() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
return mod.getAllPositions(1).length > 0;
|
||||
},
|
||||
{ timeout: 45000 }
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
if (!gotAircraft) {
|
||||
record('>=10 live aircraft sampled from OpenSky', null,
|
||||
'no aircraft appeared within 45s — OpenSky upstream INCONCLUSIVE this run (not an app failure)');
|
||||
} else {
|
||||
// Let a full second poll cycle land (updateInterval: 30000 — see
|
||||
// flights.js) before sampling. A just-appeared ON-GROUND aircraft can
|
||||
// legitimately render at the sticky `alt` default (0m ellipsoidal) for
|
||||
// ONE tick while `_warmGroundedAircraftSurfaceCache`'s batch resolve is
|
||||
// still in flight (fire-and-forget; picked up by cachedEllipsoidalGround
|
||||
// on a LATER poll — see flights.js's own comment on that function). That
|
||||
// window is a documented, time-bounded product behavior, not a
|
||||
// datum-fix regression — sampling immediately after the FIRST aircraft
|
||||
// appears would catch exactly that transient and misreport it as an
|
||||
// under-terrain violation. Waiting past a second poll interval lets the
|
||||
// cache warm before the under-terrain detector runs.
|
||||
console.log('Waiting through a second poll cycle so on-ground aircraft ground-cache warm-up completes...');
|
||||
await sleep(32000);
|
||||
|
||||
// getAllPositions gives render-height (carto.height off the actual
|
||||
// billboard Cartesian3 — i.e. renderAltitudeM as placed by
|
||||
// Cartesian3.fromDegrees(lon, lat, renderAltitudeM)); getNearby gives
|
||||
// the untouched aviation/baro field (info.altitude) per-icao24. Cross
|
||||
// the two public APIs by icao24/id to reconstruct both sides without
|
||||
// reaching into the layer's private _flightData closure.
|
||||
const allPositions = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
return mod.getAllPositions(60);
|
||||
});
|
||||
// getNearby needs an ECEF center + range; use the live camera position
|
||||
// (a real Cartesian3 instance — no window.Cesium global available, so
|
||||
// this borrows a live instance rather than constructing one, matching
|
||||
// qa-cctv-v2's pattern) with an effectively unbounded range so it
|
||||
// returns everything currently shown regardless of viewer position.
|
||||
const nearbyList = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const center = window.__godsEyeView.viewer.camera.position;
|
||||
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
||||
});
|
||||
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
||||
|
||||
const sampleSize = Math.min(allPositions.length, 25);
|
||||
const sample = allPositions.slice(0, sampleSize);
|
||||
record(`>=10 live aircraft sampled from OpenSky (public getAllPositions/getNearby)`,
|
||||
sample.length >= 10 ? true : (sample.length > 0 ? null : false),
|
||||
`sampled ${sample.length} (getAllPositions total=${allPositions.length})`);
|
||||
|
||||
let plausibleCount = 0;
|
||||
let checkedCount = 0;
|
||||
const underTerrainDetails = [];
|
||||
const baroPlusNDeltas = [];
|
||||
for (const ac of sample) {
|
||||
const renderAltM = ac.altitudeM; // carto.height off the live billboard position
|
||||
const baroM = baroById.get(ac.id);
|
||||
|
||||
// Finite + plausible sanity: render altitude must be a real number in
|
||||
// a physically sane band (-500m .. 20000m covers everything from
|
||||
// Death-Valley-adjacent ground traffic to high-altitude cruise).
|
||||
const finitePlausible = Number.isFinite(renderAltM) && renderAltM > -500 && renderAltM < 20000;
|
||||
if (finitePlausible) plausibleCount += 1;
|
||||
|
||||
// Cross-check vs baro+geoidHeight when we have the baro side (weaker
|
||||
// per-aircraft equality check: geoAltitudeM isn't exposed as a named
|
||||
// field on any public flights-layer method — only the module-private
|
||||
// `_flightData` carries geoAltitudeM/renderAltitudeM directly — so
|
||||
// this harness can only recompute the FALLBACK branch's expected
|
||||
// value (baro + geoid N) from the public API and log the delta,
|
||||
// rather than assert it individually, for aircraft where the true
|
||||
// render source was actually geo_altitude, which can legitimately
|
||||
// differ from baro+N by tens of metres).
|
||||
if (Number.isFinite(baroM) && Number.isFinite(renderAltM)) {
|
||||
const n = geoidHeight(ac.latitude, ac.longitude);
|
||||
const expectedBaroPlusN = baroM + n;
|
||||
const deltaVsBaroPlusN = Math.abs(renderAltM - expectedBaroPlusN);
|
||||
// Not asserted individually (geo_altitude legitimately diverges from
|
||||
// baro+N) — logged into the report table so the cross-check is
|
||||
// actually visible, not silently discarded.
|
||||
baroPlusNDeltas.push({ id: ac.id, renderAltM, expectedBaroPlusN, deltaVsBaroPlusN });
|
||||
}
|
||||
checkedCount += 1;
|
||||
}
|
||||
record(`aircraft render altitudes are finite + physically plausible (-500..20000m)`,
|
||||
checkedCount > 0 ? plausibleCount === checkedCount : null,
|
||||
`${plausibleCount}/${checkedCount} plausible`);
|
||||
|
||||
if (baroPlusNDeltas.length) {
|
||||
console.log('\n baro+geoidN cross-check table (id, renderAlt, expected baro+N, |Δ|):');
|
||||
for (const d of baroPlusNDeltas) {
|
||||
console.log(` ${String(d.id).padEnd(10)} renderAlt=${d.renderAltM.toFixed(1)}m` +
|
||||
` expected=${d.expectedBaroPlusN.toFixed(1)}m Δ=${d.deltaVsBaroPlusN.toFixed(1)}m`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Under-terrain detector, tightened to its unambiguous fingerprint.
|
||||
// Ground truth is fetched fresh through the app's own
|
||||
// /api/terrain/heights proxy (same oracle as group 1), so this stays
|
||||
// GL-independent and upstream-verifiable rather than reaching into
|
||||
// private closures.
|
||||
//
|
||||
// The REAL bug (fixed by this task's Bug 1 change: flights.js's
|
||||
// _warmGroundedAircraftSurfaceCache now keys its warm-cache resolve off
|
||||
// the aircraft's RAW POLL lat/lon — the same coords
|
||||
// pickRenderAltitudeM's surfaceM read uses — instead of the
|
||||
// continuously dead-reckoned billboard position) has an unambiguous
|
||||
// numeric fingerprint: an aircraft rendering at the ~0m ellipsoidal
|
||||
// SENTINEL (the pre-existing "no geo/baro data yet" default) while its
|
||||
// terrain ground is nowhere near 0m. HARD-FAIL only on that exact
|
||||
// combination.
|
||||
//
|
||||
// Any OTHER under-terrain reading (non-zero renderAltitudeM below
|
||||
// ground - 50m) is ambiguous — it could legitimately be a climbing/
|
||||
// descending aircraft briefly near real ground elevation close to an
|
||||
// airport (e.g. a departure a few dozen metres up over a field that
|
||||
// itself sits well above sea level) — so those are recorded
|
||||
// INCONCLUSIVE with the raw values, not FAILed.
|
||||
const SENTINEL_EPS_M = 1; // Math.abs(renderAlt) < 1 is the exact 0m default
|
||||
const SENTINEL_GROUND_FLOOR_M = 100; // terrain must be unambiguously non-zero
|
||||
let underTerrainChecked = 0;
|
||||
let sentinelViolations = 0;
|
||||
let ambiguousUnderTerrain = 0;
|
||||
const ambiguousDetails = [];
|
||||
for (const gc of sample) {
|
||||
const ground = await fetchReearthEllipsoid(gc.latitude, gc.longitude);
|
||||
if (ground === null) continue; // upstream unreachable for this point — skip, don't fail
|
||||
underTerrainChecked += 1;
|
||||
if (gc.altitudeM >= ground - 50) continue; // not under terrain at all
|
||||
const isSentinelMiss = Math.abs(gc.altitudeM) < SENTINEL_EPS_M && Math.abs(ground) > SENTINEL_GROUND_FLOOR_M;
|
||||
if (isSentinelMiss) {
|
||||
sentinelViolations += 1;
|
||||
underTerrainDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m (0m SENTINEL fingerprint)`);
|
||||
} else {
|
||||
ambiguousUnderTerrain += 1;
|
||||
ambiguousDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m`);
|
||||
}
|
||||
}
|
||||
record('no aircraft renders at the 0m sentinel while terrain ground is far from 0m (datum-miss fingerprint)',
|
||||
underTerrainChecked > 0 ? sentinelViolations === 0 : null,
|
||||
underTerrainChecked > 0
|
||||
? `${sentinelViolations} violation(s) out of ${underTerrainChecked} checked${underTerrainDetails.length ? ': ' + underTerrainDetails.slice(0, 3).join(' | ') : ''}`
|
||||
: 'Re:Earth proxy unreachable for every sampled point this run — INCONCLUSIVE');
|
||||
if (ambiguousUnderTerrain > 0) {
|
||||
record('other under-terrain readings (non-zero, ambiguous — may be legit climb/descent near an airport)',
|
||||
null,
|
||||
`${ambiguousUnderTerrain} case(s): ${ambiguousDetails.slice(0, 5).join(' | ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 3: Regime C re-resolve
|
||||
// =========================================================================
|
||||
console.log('Switching map stack to keyless OSM (regime C)...');
|
||||
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
||||
// we were in — Google-3D, given the app's default).
|
||||
const regimeCTargetId = londonCams[0]?.id || austinCams[0]?.id || allCameras[0]?.id;
|
||||
const groundBeforeSwitch = regimeCTargetId ? await readCameraGround(page, regimeCTargetId) : null;
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.mapStackController.setStack('osm');
|
||||
});
|
||||
// Real (non-flat) terrain loads ASYNCHRONOUSLY on a globe stack, and by two
|
||||
// different code paths depending on whether a Cesium Ion token is present:
|
||||
// - NO token → keyless Re:Earth `CesiumTerrainProvider` via a DIRECT
|
||||
// assignment (synchronous — ready immediately).
|
||||
// - token → Cesium World Terrain via `scene.setTerrain(...)`, whose
|
||||
// provider resolves in the background: `viewer.terrainProvider`
|
||||
// is transiently `null` until the World Terrain layer.json
|
||||
// loads (can exceed 1.5 s under SwiftShader), THEN becomes a
|
||||
// `CesiumTerrainProvider`.
|
||||
// Either way the END STATE is a real `CesiumTerrainProvider`, never the flat
|
||||
// `EllipsoidTerrainProvider` regime C used to be stuck on. Poll for that end
|
||||
// state instead of a fixed sleep (a fixed sleep races the async World Terrain
|
||||
// load — the old failure mode). Report whatever it settled on.
|
||||
let terrainCtor = null;
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const p = window.__godsEyeView?.viewer?.terrainProvider;
|
||||
const name = p && p.constructor && p.constructor.name;
|
||||
return !!name && name !== 'EllipsoidTerrainProvider';
|
||||
}, { timeout: 12000, polling: 250 });
|
||||
} catch { /* timed out — fall through and report the settled ctor below */ }
|
||||
terrainCtor = await page.evaluate(() => {
|
||||
const provider = window.__godsEyeView.viewer.terrainProvider;
|
||||
return provider?.constructor?.name || null;
|
||||
});
|
||||
record('globe-stack terrain is a real CesiumTerrainProvider, not the flat EllipsoidTerrainProvider (regime B/C)',
|
||||
terrainCtor !== null && terrainCtor !== 'EllipsoidTerrainProvider',
|
||||
`constructor=${terrainCtor}`);
|
||||
|
||||
if (regimeCTargetId) {
|
||||
const groundAfterSwitch = await readCameraGround(page, regimeCTargetId);
|
||||
if (!groundBeforeSwitch || !groundAfterSwitch ||
|
||||
!Number.isFinite(groundBeforeSwitch.groundM) || !Number.isFinite(groundAfterSwitch.groundM)) {
|
||||
record(`CCTV grounds re-resolved for regime C (${regimeCTargetId})`, false, 'ground unreadable before or after switch');
|
||||
} else {
|
||||
// The prior-everywhere invariant (Task 5) means BOTH readings should
|
||||
// already be close to the Re:Earth ellipsoid value in every regime —
|
||||
// the real regression this guards is the OLD fabricated-prior bug
|
||||
// (London ~15m, Austin ~150m fixed catalog numbers regardless of
|
||||
// regime). Assert the post-switch ground is NOT stuck at either
|
||||
// known-bad fabricated constant, and is within a generous tolerance
|
||||
// of the pre-switch value (both should already reflect the same
|
||||
// Re:Earth prior — a big jump would indicate a stale/blank regime
|
||||
// record instead of a re-arm).
|
||||
const isLondonId = londonCams.some((c) => c.id === regimeCTargetId);
|
||||
const staleValue = isLondonId ? 15 : 150;
|
||||
const notStuckAtFabricated = Math.abs(groundAfterSwitch.groundM - staleValue) > 5;
|
||||
const stableAcrossSwitch = Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM) < 20;
|
||||
record(`CCTV ground NOT stuck at the old fabricated prior (${staleValue}m) after regime C switch (${regimeCTargetId})`,
|
||||
notStuckAtFabricated,
|
||||
`before=${groundBeforeSwitch.groundM.toFixed(1)}m after=${groundAfterSwitch.groundM.toFixed(1)}m`);
|
||||
record(`CCTV ground re-resolved within one event cycle, consistent with the prior-everywhere invariant (${regimeCTargetId})`,
|
||||
stableAcrossSwitch,
|
||||
`Δ=${Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM).toFixed(1)}m`);
|
||||
}
|
||||
} else {
|
||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshot for human visual review (not a pass/fail gate).
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(500);
|
||||
const shot = path.join(SHOTS_DIR, 'height-datum-regimeC.png');
|
||||
await page.screenshot({ path: shot });
|
||||
console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`);
|
||||
|
||||
record('no console errors during height-datum exercise', consoleErrors.length === 0,
|
||||
consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean');
|
||||
|
||||
for (const r of results) {
|
||||
if (r.ok === false) exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
const inconclusive = results.filter((r) => r.ok === null).length;
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
process.exit(3);
|
||||
});
|
||||