diff --git a/.env.example b/.env.example index 2a5479514..1a8830ed0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Local development uses one file for browser and server keys. +# Copy to .env (ignored); this example contains names and placeholders only. +# Only explicitly selected browser keys are injected into the client bundle. # 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. @@ -21,6 +24,16 @@ # 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: a SEPARATE Google API key for the SERVER-SIDE Google calls — Places +# (/api/google/nearby-places, /api/google/text-search) and the CCTV Street View +# fallback (#33). It never reaches the browser, so it can be restricted by +# server IP (not HTTP referrer) and scoped to just Places API + Street View +# Static API, letting the client-exposed key above stay narrowly scoped to Map +# Tiles + Geocoding. Unset, these calls fall back to GOOGLE_MAPS_API_KEY, so a +# single shared key keeps working — splitting is opt-in. +# SERVER-SIDE ONLY. +GOOGLE_MAPS_SERVER_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. @@ -134,9 +147,29 @@ AISSTREAM_API_KEY= # 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_AUSTIN_MAX_SOURCES=250 # cap cameras loaded from the Austin catalog +# CCTV_CALTRANS_DISTRICTS=4,7,11,3 # Caltrans districts; empty string disables Caltrans +# CCTV_CALTRANS_MAX_SOURCES=300 # cap cameras loaded from Caltrans +# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams +# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL +# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras +# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511 +# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack +# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog +# CCTV_DRIVEBC_MAX_SOURCES=250 # cap DriveBC (British Columbia) cameras, nearest Vancouver/Victoria first +# CCTV_DRIVEBC_ENABLED=1 # 0 turns off the DriveBC camera pack +# CCTV_TXDOT_ENABLED=1 # set 0 to disable the TxDOT (Texas) pack +# CCTV_TXDOT_DISTRICTS=AUS,SAT # TxDOT district codes (25 exist); empty disables the pack +# CCTV_TXDOT_MAX_SOURCES=500 # cap cameras loaded from the TxDOT catalogs +# CCTV_TALLINN_ENABLED=1 # set 0 to disable the Tallinn (Estonia) intersection cameras +# CCTV_TALLINN_MAX_SOURCES=255 # cap cameras loaded from the Tallinn catalog +# CCTV_TARKTEE_ENABLED=1 # set 0 to disable the Tarktee (Estonia) road-weather cameras +# CCTV_TARKTEE_MAX_SOURCES=179 # cap cameras loaded from Tarktee +# CCTV_WARENDORF_ENABLED=1 # set 0 to disable the Warendorf (Germany) municipal webcams +# CCTV_NSW_ENABLED=1 # set 0 to disable the Live Traffic NSW (Australia) cameras +# CCTV_NSW_MAX_SOURCES=250 # cap cameras loaded from Live Traffic NSW +# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry -# CCTV_FORCE_AUSTIN= # force Austin-only sources +# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured # CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag) # CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..016576bf8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Something is broken, fails to install, or renders wrong. +title: "bug: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Reproduction steps and relevant errors help us investigate. If installation failed before you could run a command, say which command failed and why. Redact API keys, tokens, and personal information; do not paste your .env file. + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected, and what you saw instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Fresh clone, `npm ci`, `npm run dev` + 2. Open http://localhost:4173, click LOCATION, type "London" + 3. See "Search failed" toast + validations: + required: true + - type: dropdown + id: install + attributes: + label: How you installed + options: + - Pinokio one-click + - Terminal (git clone + npm) + - Docker + - Other (say what in the description) + validations: + required: true + - type: input + id: env + attributes: + label: OS, Node version, browser + description: Include the versions you can find. Write "unknown" for anything unavailable. + placeholder: "Windows 11, Node 24.14.0, Chrome 151" + validations: + required: true + - type: input + id: commit + attributes: + label: Version or commit + description: Output of `git rev-parse --short HEAD`, or the release tag. If unavailable, say when you installed or last updated. + placeholder: "aacfa06" + validations: + required: true + - type: textarea + id: doctor + attributes: + label: Output of `npm run doctor` + description: Run this in the app directory. It reports which providers are configured without printing key values. If it cannot run, describe the failure instead. + render: text + validations: + required: true + - type: textarea + id: logs + attributes: + label: Server log and browser console + description: Paste the relevant error lines from the terminal or Pinokio log and browser console. If no log is available, explain what prevented it. Redact credentials and personal information. + render: text + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..1900926c7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/bilawalsidhu/gods-eye-view/security/advisories/new + about: Report anything exploitable privately. Do not open a public issue for it. + - name: Setup and quick start + url: https://github.com/bilawalsidhu/gods-eye-view#readme + about: Install paths (Pinokio, terminal), provider keys, and what runs keyless. + - name: Known issues + url: https://github.com/bilawalsidhu/gods-eye-view/blob/main/docs/KNOWN-ISSUES.md + about: Check here before filing. Your problem may already be tracked with a workaround. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..d381e366e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature request +description: Propose a new layer, data source, tool, or UI capability. +title: "feat: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + The project only ships public data with a license that allows redistribution, and it does not build features for identifying or tracking individuals. Read the "Responsible & Open" section of the README before proposing a data source. + - type: textarea + id: problem + attributes: + label: Problem + description: What can you not do today, and who needs it? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should it do? Keep the first version small. + validations: + required: true + - type: textarea + id: source + attributes: + label: Data source, license, and attribution + description: For anything that shows data, link the feed or API, name its license, and say whether it works without a key. Write "none" for pure UI changes. + validations: + required: true + - type: dropdown + id: contribute + attributes: + label: Will you build it? + options: + - Yes, I plan to open a PR + - No, this is a request for someone else + validations: + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6a322ea9..167ea65a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,12 @@ jobs: - name: Run setup policy checks run: npm run doctor -- --json + - name: Check adopted formatting + run: npm run format:check + + - name: Check package boundaries + run: npm run check:boundaries + - name: Run unit tests run: npm test @@ -70,6 +76,12 @@ jobs: - name: Run the Pinokio install path run: node scripts/pinokio-install.mjs + - name: Check adopted formatting + run: npm run format:check + + - name: Check package boundaries + run: npm run check:boundaries + - 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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..1aaad8a03 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,22 @@ +dist +.gev-cache +.gev-logs +screenshots +qa-shots +output +coverage +3d-models +pinokio/ENVIRONMENT +pinokio/.installed +src/data/local_data/ +*.geojson +*.geojsonl + +# Local configuration, generated assets and browser evidence +.env +.env.* +!.env.example +node_modules +.vite +.vite-* +*.min.js diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..b1e6b2baf --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "semi": true, + "endOfLine": "lf" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa46..16986c795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,358 @@ # Changelog +## ALPR camera locations + +- Label the loaded camera count as nearby, show a purple-dot legend, and add + SHOW NEAREST to frame and select a loaded camera when none are on screen, + using the available 3D-tile or globe terrain height. + +- Keep nearby camera markers and selection stable during rotation, use bounded + ground-centered coverage instead of the horizon rectangle, and reuse in-flight queries. + +- Add optional, source-labeled OpenStreetMap ALPR camera locations, bounded city queries, + cached-response and coverage notices, selection cards, share links, and voice toggles. +- Separate the request adapter, camera model, presentation, and instance lifecycle. + Source cancellation also guards late response bodies and rejects invalid query bounds. + +## Release disabled infrastructure rendering + +- Remove built Data Center, Dam and Submarine Cable entities when their layers + are disabled, avoiding retained visualizer work and entity memory. +- Keep parsed datasets cached for re-enable; rebuild entities without refetching. + +## Camera layer components + +- Separate camera source requests, placement, frames, projection, cards and calibration. +- Own visibility listeners and pending initialization within each layer lifetime. +- Preserve existing camera catalogs, URL families, geometry and playback behavior. + +## Traffic and bikeshare components + +- Separate traffic loading, animation, styling and lifecycle into factory-owned components. +- Give each flow source its own bounded decode cache and cancellation checks. +- Separate bikeshare registry, station requests, rendering, selection and proximity handling. + +## Installation and context components + +- Separate mapped-site requests, records, placement, selection and viewport lifecycle. +- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering. +- Retain source and ground-floor ownership in standalone composition; reject malformed + installation snapshots and ignore failures from cancelled requests. + +## Satellite and mission layer components + +- Separate catalog loading, orbit calculations, display, tracking and interaction + into instance-owned satellite components. +- Separate mission ingestion, paths, placement, cards, roster, replay and camera + operations, retaining existing layer controls and satellite coordination. +- Cancel late mission source work and reject malformed launch snapshots. + +## Fire layer components + +- Split fire source loading, state, rendering, cards, selection and viewport work + into reusable components with application-owned scene services. +- Cancel late refreshes, retain good data after malformed responses, and preserve + selection identity without repeating a user-selection notification on refresh. + +## Earthquake components + +- Separate earthquake snapshot loading, record validation, and display ownership. +- Cancel pending earthquake refreshes on disable or destruction, retaining the + last good snapshot after malformed or failed refreshes. + +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + +Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). ## [Unreleased] +- Separate map source factories from switching and resource ownership; retain current source IDs, attribution and fallbacks. + +- Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. + +- Separate submarine cable sources and rendering components, and export bundled geography lookup modules. + ### Fixed +- Traffic now retries a failed destination after city navigation without a layer + toggle. Camera departure cancels pending work, arrival checks the final view, + and superseded requests cannot keep a newer view loading. + +### Added + +- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area + highway cameras, with server-registered still URLs and attribution. +- CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh). +- Add DriveBC highway cameras for British Columbia to the CCTV layer: the 250 + nearest Vancouver and Victoria by default, with Open Government Licence – + British Columbia attribution. `CCTV_DRIVEBC_MAX_SOURCES` sets the cap and + `CCTV_DRIVEBC_ENABLED=0` turns the pack off. +- Add TxDOT highway cameras for Texas as a keyless CCTV pack: the Austin and + San Antonio districts by default (`CCTV_TXDOT_DISTRICTS` selects any of the + 25), only cameras reporting Device Online, snapshots decoded from TxDOT's + JSON-wrapped JPEG for the official origin only. +- Add Estonia CCTV source packs: Tallinn intersection stills (`ristmikud.tallinn.ee`, + curated catalog) and nationwide Transpordiamet / Tarktee road-weather cameras + (DATEX2 locations + rotating JPEG URLs), with Tallinn city POIs and attribution. +- Add a Warendorf (Germany) source pack: the Stadt Warendorf Marktplatz webcam, with a + curated pose. +- Add Live Traffic NSW (Transport for NSW, CC BY 4.0) as a keyless CCTV pack: 217 + Sydney and regional cameras with compass headings and view descriptions. +- CCTV monitor planes no longer clip into the terrain. The plane is lifted + rigidly by the largest clearance deficit over a 3×3 grid of support points + against the ground under each (the ground at the mount where nothing finer is + known), and the client honours pack ranges instead of inflating them to 220 m. + `src/data/local_data/cctv_ground_heights/` ships precomputed ground heights under + every camera's mount and plane footprint (3,445 of 3,446 cameras), aligned to work + with Google Photorealistic 3D Tiles; cameras with shipped heights are placed + with zero runtime sampling, and the rest resolve the ground under their plane + from the Re:Earth DEM on activation. The footprint lift is capped at 60 m + above the mount-based lift so a tower under a far edge cannot launch the plane. + +- Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo. + Typing fields retain the key; monitoring stops when hidden. + + +- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services. +- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place. +- Cancel pending vessel history during selection and layer teardown. + +- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. Preserve known military identities even when the source has no position for them. + +- Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application. + +- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history. +- Retain absent aircraft during partially admitted snapshots and bound source error messages. + +- Drive share updates, Location feedback and Scene controls through immutable state snapshots and disposable subscriptions. +- Keep stale lookup/load completions from publishing accepted results and retain shot rows during playback progress updates. +- Export the existing Scene director with explicit playback and editing outcomes. + +- Separate UI assembly from standalone engine wiring, with dedicated panel layout, position, notice and recording owners. +- Stop pending UI presentation and drag work during disposal; preserve accessible status text when stopping its decoration. +- Organize component styles behind the same ordered stylesheet entry and include 3D model controls in the current-state snapshot. + +- Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback. +- Preserve shot-label identity on selection so double-click rename can complete. + +- Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations. +- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown. + +- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal. + +- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal. + +- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. + +- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup. + +- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row. + +- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown. + +- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback. + +- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown. + +- Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior. + +- Extract application shortcuts and shader-parameter controls into reusable UI + components, preserving inputs and cleaning up listeners on rebuild/disposal. + +- Extract adaptive panel rail placement and measurement into reusable UI modules, + preserving obstacle clearance, responsive allocation, disclosure and scroll behavior. + +- Extract shared surface keyboard handling for the welcome launcher and Provider + Settings, preserving Tab/Escape behavior and releasing the listener on teardown. + +### Added + +### Security + +- Validate configured Google Places coordinates and text queries before rate + limiting or upstream requests; preserve the keyless capability response. +- Bound CCTV media response headers to 15 seconds and cancel error bodies. + Cap buffered snapshot downloads at 16 MiB while streaming. + + +- Cancel the active location lookup when its controls are disposed. + + +### Fixed + +- Extract panel disclosure and hover/focus controls into a reusable module; + cancel their listeners and pending work during replacement and teardown. + +- Reuse cached military aircraft during adsb.lol rate limits and server errors, + honor bounded retry delays, and preserve cached observation times and stale + indicators. Show installation zoom guidance without a false LOAD FAILED. + +- GBFS rejects upstream redirects, caps streamed responses at 5 MiB, and keeps + its deadline active through body reads. Rejected downloads are cancelled. + + +- Split Overpass/installation search, regional briefing/weather, local voice + handlers and standalone key setup into focused modules. Preserve routes, + source behavior, tool schemas and credential restrictions. + +- Restore data-provider routes under local build preview and return JSON 404s + for unmatched API requests. Credential editing remains development-only. + +- Extract CCTV catalog/media and Radio Browser directory providers into focused + Node modules, preserving their routes and policies and isolating CCTV catalogs + by provider instance and application root. + +- Simplify POWER UP to one Google Maps entry. Keep the optional server key + available through environment configuration without a second setup row or + missing-key reminder. + +- Separate terrain, traffic, FIRMS and GBFS middleware into focused provider + modules, preserving local configuration, routes and cache/error behavior. + +- Split satellite and launch-feed server providers into focused modules with + portable request URL builders, preserving routes and cache/error behavior. + +- Keep landmark names when geocoding returns only address components, preventing + the United States Capitol annotation from moving to a Washington hotel. + Unrelated outlines leave the valid geocoded marker in place. + +- Split aircraft and vessel server providers into focused modules for source + fetching, AIS records/tracks and shared request helpers; preserve existing + routes, local setup, fallback behavior and rendering. + + +### Changed +- Separate explicit browser build settings from standalone environment loading + and local provider middleware. Preserve provider behavior and root named exports. +- Rename standalone browser startup to `src/standalone/` and add a Node-only + `gods-eye-view/build/vite` export with checked package ownership. + + +### Development + +- Extract application lifecycle and viewer exports. Split standalone startup into + scene setup, controls, layer registration, tools and loading UI. Startup failure + and terminal shutdown release acquired resources and cancel delayed work. + +- Adopt Prettier tooling contributed by RohanDaCoder (#227), with an explicit + file scope, pinned formatter and Linux/Windows CI checks. Format the reusable + infrastructure modules and their consumer tests. Package boundary checks keep + those exports separate from app startup and local Node services. + +### Fixed + +- Reduce terrain-height timeouts when Re:Earth slows down. Batches are + sized against measured response latency on both browser and server to reduce + request timeouts, and a partial upstream failure now + keeps the heights that did resolve rather than discarding them. A position + the upstream answers with no height is reported as an absent reading instead + of a failed refresh, so the log distinguishes a slow or broken upstream from + one that simply has no value for a coordinate. + +- Separate optional Google server credentials for Places and Street View from + the browser key, contributed by Tom-Neverwinter (#110). Provider Settings, + Pinokio's app-specific credential handling and setup diagnostics recognize + both keys. The Street View tool prefers the server key across environment + and `.env` sources. Existing single-key and keyless setups remain supported. + +- Complete the first-run, view-target prewarm, cockpit-plates and floor-hold + browser harness renderer portability fixes contributed by Tom-Neverwinter. + macOS retains Metal; other platforms default to SwiftShader. Cockpit renderer + assertions and evidence labels follow the actual selected mode. Floor-hold + explicitly selects its measured 2D billboard mode and keeps its mesh and terrain assertions; software runs are not real-GPU evidence. + First-run QA now checks the existing attribution Escape-close/focus-return + behavior while preserving the launcher-underneath regression checks. + + +- Datacenter and dam factories are available through scoped package exports with + explicit context, overlay and render callbacks. The standalone app uses the + same implementation and bundled datasets. + +- Local GeoJSON layers share concurrent loads, cancel pending fetches on destruction, + discard late results, and remove their entity-context records on teardown. + +- Unchanged local infrastructure overlays no longer sustain idle rendering. + Ground samples wait for visible terrain to settle and cannot place a marker + below its loaded surface; roofs and valid below-sea-level heights are retained. + Already sampled markers also follow higher terrain as close-up tiles refine. + +- Datacenter and dam marker stems use bounded, zoom-dependent active sets with + stable selection during camera motion. Close-up stems scale to the actual + camera distance; source totals and submarine cables remain unchanged. + +- Keyboard focus rings now survive active/selected button styles across the + interface. Visual Styles, Location cities and points of interest, search, + Context/mission actions, Cockpit utilities, and sliders retain a distinct + focus indicator. +- A short Space press activates a focused control only on key release. Holding + Space for 500 ms blurs that control before push-to-talk starts, and release is + then consumed so it cannot also activate the old control. The same hold works + from the map or page background; text-entry controls remain protected. +- The Location disclosure is reachable with Tab and shows keyboard focus; + its city, point-of-interest, and search controls do too. Escape from inside + the tray returns focus to its disclosure and discards any unfinished search; + Escape on the disclosure itself closes the tray and clears that focus. +- Data Layers ON/OFF buttons show a keyboard focus ring independently of + their enabled and feed-status colors. +- Display buttons, layout selectors, mode buttons, and sliders show a visible + keyboard focus ring, including the controls used in Cockpit Display. Enabled + CCTV camera dropdowns also show keyboard focus. +- Context tabs keep a distinct keyboard ring when selected. Their existing + Left/Right arrow navigation continues to switch Contacts and Space Missions, + and both choices remain reachable through ordinary Tab navigation. +- Tabbing through the Space Missions roster now drives the same temporary globe + rotation and mission-marker highlight as pointer hover, without selecting the + mission. Keyboard and pointer previews no longer cancel each other. +- Radio power controls, Search Nearby Sites, and Clear Selected Layers retain + keyboard focus while their async work is busy. They expose that busy state to + assistive technology and ignore repeated activation until the work settles. +- Live Contacts results retain keyboard focus by contact identity when counts, + distance order, or pages refresh. If a focused contact departs or rotates off + the visible page, focus moves to the named explanatory note at the end of the + list and survives later refreshes there, so the next Tab proceeds beyond the + list instead of restarting at Contacts or silently selecting another contact. +- Cockpit Live Signals retains keyboard focus during live updates and contact + reordering, allowing Tab to continue to Display and Radio. If the focused + contact leaves the list, focus moves to the current briefing tab. +- Cockpit-only Display and Radio launchers show complete inset focus rings. +- Escape collapses the nearest expanded panel containing keyboard focus and + returns focus to that panel's disclosure when closing from its contents. + Escape on the disclosure itself closes without leaving the collapsed control + focused. Cockpit Contact and Live Signals panels follow the same nesting rule. +- Cesium's bottom-left Data attribution control and lightbox Close control are + in the Tab order and support Enter and Space. Close, Escape, and backdrop + dismissal restore focus and synchronize the disclosure state. + +- CCTV testing uses the normal launcher for keyless startup, credential loading, + localhost binding, and explicit LAN-exposure warnings while retaining its + smaller source-pack limits. +- CelesTrak, Launch Library, terrain-height, and aircraft-enrichment failures + return generic error messages. Related diagnostics omit raw exception details + and upstream error bodies; response statuses and cache fallback remain intact. + Includes the security fixes contributed by Tom-Neverwinter in PR #171. + +### Fixed + +- Map Source keyboard opening retries focus until the selected tile is visible. + Leaving the disclosure, pointer interaction, or closing the tray cancels the + pending handoff so delayed work cannot pull focus back. + +- Scope, Bloom, Sharpen, location search and generated style sliders expose + explicit accessible names. The first-run checkbox retains its native label. +- FIRMS records a source as successful only after appending its rows, avoiding + contradictory success/failure status if aggregation throws. +- Radio country filtering and voice country requests now resolve common English + names and exonyms that `Intl.DisplayNames`' primary label omits, so requests + like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus + Myanmar/Burma, UAE, Holland, Swaziland, East Timor, Cabo Verde, Vatican). + Ambiguous names such as a bare "Congo" or "Korea" still fail closed. - Mapped-site outages show their scheduled retry countdown and distinguish known Overpass rate limits, timeouts, and query failures. Search feedback no longer claims a refresh succeeded while the layer is unavailable or loading. @@ -28,6 +374,31 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Existing cached refusals are now ignored immediately, including during stale-data fallback. Concurrent identical requests share the same last-good fallback when all mirrors refuse, without duplicating upstream requests. +- A keyless place lookup no longer remembers a network failure as "no such + place". A blip while Photon was answering used to be memoized for the rest of + the session, so the query kept returning not-found from memory on a network + that had since recovered. A miss is now cached only when every source + consulted actually returned a verdict. + +### Added + +- Keyless place search. The LOCATION search box and the `fly_to_location` voice + tool now resolve place names through Photon (komoot, over OpenStreetMap) when + no Google Maps key is configured — previously the lookup threw. Google stays + the primary path and is unchanged when it answers; the fallback also covers a + key whose Geocoding API is not enabled, which Google reports as HTTP 200 with + `REQUEST_DENIED`, so an empty result is the detector rather than an error. +- The same keyless fallback now covers the remaining two place lookups: map + annotations ("annotate the botanical garden") and the Radio layer's + "near \" selection. Radio previously threw without a key, which + surfaced as a failed voice turn rather than as a station it could not place; + annotations silently failed to anchor. Annotation footprints match OSM on the + resolved feature's canonical name, so locality words in the request cannot + pull the outline onto a neighbouring building. + +- Refresh vulnerable transitive dependencies and update browser/image tooling + to Puppeteer 25.10.0 and Sharp 0.35.4. Cesium remains on 1.138.0. + Browser QA awaits the new asynchronous executable-path lookup. ## [0.1.1] — 2026-09-01 — Installation and live-data fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eef422a84..f967507e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,15 @@ Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR. For a deeper catalog of what each harness checks and what it needs to run, see [TESTING.md](TESTING.md). +## Checking a built app locally + +Run `npm run build` followed by `npm run preview`. Preview serves the built +frontend and local data-provider APIs. Keep optional server credentials in the +ignored `.env`; browser keys are embedded during the build, so rebuild after +changing them. Provider Settings and `/api/setup/*` are development-only: edit +configuration through the development app or environment file. Unknown API +paths return JSON 404 responses. Vite preview is for checking a local build; +it is not a production server. ## Good first contributions @@ -51,7 +60,7 @@ 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/.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). +- **🎙️ Extend voice control.** Voice tools are declared server-side (`GEV_REALTIME_TOOLS` in `server/providers/openai/tools.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). @@ -59,7 +68,7 @@ The highest-leverage places to jump in: - **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/.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. +- **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. ## Coding style @@ -69,6 +78,22 @@ The highest-leverage places to jump in: - 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. +## Formatting and reusable components + +Run `npm run format` before submitting changes to adopted modules, then +`npm run format:check` and `npm run check:boundaries`. Formatting uses the +explicit file list in `scripts/format-scope.json`; add newly extracted modules +and tests there in a separate mechanical commit. Keep unadopted files consistent +with their surrounding style. CI checks all adopted files and package boundaries +on Linux and Windows. + +Reusable package exports own their state and receive application operations +through explicit callbacks. They must not import the standalone bootstrap or +local Node services. Update `scripts/package-boundaries.json` and consumer tests +when adding an export or expanding a component. See +[formatting and component boundaries](docs/CODE-BOUNDARIES.md) for the current +ownership and adoption process. + ## Pull requests 1. Branch off `main`. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index bb0626117..c639f1262 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -13,30 +13,40 @@ How to read this: ## 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)" | +| Source | Used for | License / terms | Attribution | +| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping | +| **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) | +| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" + "© OpenStreetMap contributors" | +| **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" | +| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) | +| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) | +| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) | +| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) | +| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" | +| **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" | +| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario | +| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | +| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | +| **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 @@ -45,6 +55,14 @@ How to read this: - **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. +- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. +- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. +- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. +- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed. +- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`. +- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`. +- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. +- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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. @@ -58,13 +76,14 @@ How to read this: 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) | +| 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) | +| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -74,9 +93,35 @@ The submarine-cable GeoJSON is **CC BY-NC-SA 3.0** (Attribution-**NonCommercial* The richer structured dataset is licensed separately/commercially by TeleGeography. +### ALPR camera mapping + +The optional ALPR layer queries OpenStreetMap nodes tagged `man_made=surveillance` +and `surveillance:type=ALPR` (including case-insensitive semicolon multi-values) +through the shared Overpass proxy. Source/tag reference: +[OSM ALPR tagging](https://wiki.openstreetmap.org/wiki/Tag:surveillance:type%3DALPR). +No DeFlock software, branded assets or separate database is bundled. The layer +shows contributor-supplied locations and tags, not plate records, camera video, +current operating status, or exhaustive coverage. All manufacturers share the +ALPR category and marker color. Manufacturer, operator and camera type are shown +only when tagged, not inferred or independently verified. Every selected card +names OpenStreetMap in a smaller, muted source line beneath those details. + +Queries are limited to a city-scale viewport (at most 3° per axis), at most +1,500 returned nodes, with outward-snapped query reuse and explicit stale/limited +coverage notices. A linked © OpenStreetMap credit appears for five +seconds when camera locations first display after enabling, then collapses; +Data attribution retains the full source/license entry, following the +[OSMF interactive-map guidance](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines). +OSM data remains separately attributed under ODbL; querying +at runtime does not replace the license obligations. ODbL permits commercial +use and does not license the application code. If publicly using a derivative +database, follow its share-alike and access requirements; keep independent +datasets separate rather than treating the whole collection as MIT data. +Overpass hosting capacity and usage policies are separate from the data license. + ### 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). +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 @@ -159,4 +204,4 @@ accepts inline JSON as an alternative to a file path. ## 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. +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, Fintraffic, 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. diff --git a/README.md b/README.md index 86f69c5dd..af5121e2c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ # 🌐 God's Eye View +[![CI](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bilawalsidhu/gods-eye-view/actions/workflows/ci.yml) + ### 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. @@ -186,7 +188,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea ![Moving from a full airport overhead down to close taxiway inspection with 3D flight models](docs/media/start-here/airport-ground-traffic-google-3d.gif) -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. +4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. 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. ![Diving into an Austin intersection with a live public camera projected into the 3D scene](docs/media/03-austin-cctv.gif) @@ -273,7 +275,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi | 🛰️ **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** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~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 | 🟢 | +| 📹 **CCTV Mesh** | ~3,400 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW). 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 | 🟡 | diff --git a/SECURITY.md b/SECURITY.md index 64689ca55..20fee1a36 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,13 +13,14 @@ Include repro steps and impact. We'll acknowledge, investigate, and credit you ( ## 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. +The golden rule: **secret-bearing API keys stay on the server side.** The dev/preview server (middleware under `server/providers/`) 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` | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Two deliberately client-side keys — restrict them @@ -28,7 +29,9 @@ These are designed to be used directly in the browser (like a Mapbox public toke 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. +> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side. + +**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. 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. @@ -42,9 +45,21 @@ 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`. +### Configuring separate Google keys locally + +Terminal development uses one ignored repository-root `.env` for both +`GOOGLE_MAPS_API_KEY` (browser) and `GOOGLE_MAPS_SERVER_API_KEY` (server). +The tracked `.env.example` documents both without credentials. Vite injects +only the browser key; sharing an environment file does not expose the server +key. Provider Settings presents only the browser key as Google Maps; configure +the optional server key manually in the environment file. Pinokio uses +its ignored `pinokio/ENVIRONMENT` instead, with app values and blanks taking +precedence over inherited global values. An absent server key retains the +browser-key fallback for existing single-key setups. + ## Server-side proxy hardening -The data proxies in `vite.config.js` are written so the browser cannot turn the server into an open relay: +The data proxies under `server/providers/` 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. diff --git a/TESTING.md b/TESTING.md index d812ea693..ed070a03d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -218,3 +218,22 @@ While recording, call out anything in these areas — this is the feedback I mos - **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). + + +## Browser harness renderers + +The first-run, view-target prewarm, cockpit-plates and floor-hold harnesses +select Metal on macOS and SwiftShader on other platforms. Cockpit-plates also +accepts `--swiftshader` on macOS; floor-hold retains `--angle=`. +Floor-hold explicitly selects 2D aircraft mode because it measures billboard +positions; the tracking suite covers the 3D handoff. +Software runs validate their assertions but do not establish real-GPU visual +correctness. Floor-hold retains both mesh and DEM checks: an unavailable mesh +oracle fails the run even when the DEM check passes. Record the backend with +any screenshots and run GPU visual checks separately when needed. + +FIRMS refactoring can be checked without a configured server key using +`node scripts/qa-firms.mjs --url http://localhost:4173 --fixtures`. This explicit +fixture mode exercises populated display, aggregation, cards, keyless and stale +responses, and selection/camera handoff. It does not establish live-source +acceptance; omit `--fixtures` with a configured FIRMS key for that check. diff --git a/build/vite.js b/build/vite.js new file mode 100644 index 000000000..5d990f3a1 --- /dev/null +++ b/build/vite.js @@ -0,0 +1,35 @@ +import cesium from 'vite-plugin-cesium'; + +/** Build browser assets with explicit inputs; never load environment or providers. */ +export function createBrowserViteConfig({ + plugins = [], + googleApiKey, + cesiumToken, + host = 'localhost', + port = 4173, +} = {}) { + return { + plugins: [cesium(), ...plugins], + server: { + host: host || 'localhost', + port: parseInt(port, 10) || 4173, + allowedHosts: + host === '0.0.0.0' || host === '::' + ? true + : ['localhost', '127.0.0.1', '.local'], + fs: { + deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + }, + // These headers protect the document containing Provider Settings. + headers: { + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "frame-ancestors 'none'", + }, + }, + define: { + 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), + 'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), + }, + build: { chunkSizeWarningLimit: 1500 }, + }; +} diff --git a/config/cctv_sources.tallinn.json b/config/cctv_sources.tallinn.json new file mode 100644 index 000000000..bb5a0983a --- /dev/null +++ b/config/cctv_sources.tallinn.json @@ -0,0 +1,5562 @@ +[ + { + "id": "tln-103", + "name": "Viru väljak (suund Mere pst ja Narva mnt)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.436563, + "lon": 24.752702, + "headingDeg": 60.1, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam103.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam103.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-104", + "name": "Viru väljak (suund Pärnu mnt)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.436449, + "lon": 24.752409, + "headingDeg": 227.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam104.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam104.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-001", + "name": "Estonia pst - Teatri väljak (suund Tammsaare park)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434566, + "lon": 24.75182, + "headingDeg": 285.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam001.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam001.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-002", + "name": "Estonia pst - Teatri väljak (suund Reaalkool)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434411, + "lon": 24.751394, + "headingDeg": 221.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam002.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam002.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-056", + "name": "Pärnu mnt - Draamateater (suund Viru ring)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434771, + "lon": 24.748074, + "headingDeg": 224.6, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam056.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam056.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-057", + "name": "Pärnu mnt - Draamateater (suund G.Otsa)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434787, + "lon": 24.747846, + "headingDeg": 153.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam057.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam057.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-055", + "name": "Pärnu mnt - Draamateater (suund Pärnu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434931, + "lon": 24.748116, + "headingDeg": 187.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam055.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam055.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-252", + "name": "Ahtri tn - Hobujaama tn (suund Mere pst)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439527, + "lon": 24.759203, + "headingDeg": 199.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam252.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam252.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-253", + "name": "Ahtri tn - Laeva tn (suund Mere pst)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.440241, + "lon": 24.758499, + "headingDeg": 199.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam253.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam253.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-254", + "name": "Kai tn - Logi tn (suund Mere pst)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443646, + "lon": 24.758982, + "headingDeg": 201.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam254.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam254.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-255", + "name": "Kai tn - Logi tn (suund Sadama tn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443726, + "lon": 24.758929, + "headingDeg": 112.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam255.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam255.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-256", + "name": "Sadama tn - Logi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.444087, + "lon": 24.758738, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam256.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam256.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-257", + "name": "Logi tn - Rumbi tn ringristmik", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.4447845, + "lon": 24.758468, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam257.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam257.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-258", + "name": "Põhja pst - Kursi tn (suund Kopli)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443755, + "lon": 24.75169, + "headingDeg": 289.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam258.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam258.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-259", + "name": "Mere pst - Sadama tn (suund Kopli)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443099, + "lon": 24.7517, + "headingDeg": 298.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam259.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam259.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-260", + "name": "Mere pst - Sadama tn (suund Ahtri tn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443376, + "lon": 24.75108, + "headingDeg": 19.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam260.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam260.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-141", + "name": "Suur-Rannavärava tn (suund vanalinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443689, + "lon": 24.749489, + "headingDeg": 167.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam141.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam141.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-142", + "name": "Põhja pst - Kalasadama tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.444322, + "lon": 24.747884, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam142.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam142.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-143", + "name": "Mere pst - Sadama tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443925, + "lon": 24.750162, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam143.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam143.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-110", + "name": "Mere pst - Ahtri tn (suund Ahtri)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441066, + "lon": 24.753474, + "headingDeg": 19.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam110.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam110.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-111", + "name": "Mere pst (suund Kopli) - Ahtri tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441159, + "lon": 24.753354, + "headingDeg": 298.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam111.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam111.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-232", + "name": "Niine tn- Põhja pst", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443436, + "lon": 24.743054, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam232.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam232.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-200", + "name": "Suurtüki trammitee (suund Vanalinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.4415901, + "lon": 24.7444712, + "headingDeg": 173.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam200.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam200.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-201", + "name": "Suurtüki trammitee (suund Kopli)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.44255, + "lon": 24.742802, + "headingDeg": 294.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam201.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam201.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-176", + "name": "Reidi tee - Lootsi tn (suund sadam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439741, + "lon": 24.763115, + "headingDeg": 284.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam176.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam176.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-177", + "name": "Reidi tee - Ahtri tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.440038, + "lon": 24.764174, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam177.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam177.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-178", + "name": "Reidi tee - Jõe tn - Ahtri tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.440445, + "lon": 24.765684, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam178.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam178.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-179", + "name": "Reidi tee - Uus-Sadama tn (suund sadam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441438, + "lon": 24.77107, + "headingDeg": 284.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam179.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam179.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-180", + "name": "Reidi tee - Petrooleumi tn (suund sadam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.442505, + "lon": 24.774809, + "headingDeg": 284.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam180.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam180.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-181", + "name": "Reidi tee - Petrooleumi tn (suund Pirita)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.442353, + "lon": 24.775149, + "headingDeg": 39.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam181.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam181.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-182", + "name": "Reidi tee - Pikksilma tn (suund Pirita)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443717, + "lon": 24.781199, + "headingDeg": 39.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam182.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam182.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-183", + "name": "Reidi tee - Pikksilma tn (suund sadam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443454, + "lon": 24.781978, + "headingDeg": 284.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam183.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam183.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-184", + "name": "Reidi tee - Narva mnt (suund sadam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.442546, + "lon": 24.790007, + "headingDeg": 287.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam184.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam184.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-186", + "name": "Narva mnt - Pirita tee (suund Pirita)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.442678, + "lon": 24.793428, + "headingDeg": 49.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam186.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam186.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-185", + "name": "Narva mnt - Pirita tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443259, + "lon": 24.796238, + "headingDeg": 260.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam185.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam185.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-187", + "name": "Pirita tee (suund Pirita)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.44453, + "lon": 24.797405, + "headingDeg": 22.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam187.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam187.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-193", + "name": "Pirita tee - Rummu tee (suund Kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.463067, + "lon": 24.826954, + "headingDeg": 236.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam193.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam193.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-194", + "name": "Pirita tee - Rummu tee (suund Rummu tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.463166, + "lon": 24.8272, + "headingDeg": 145.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam194.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam194.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-195", + "name": "Merivälja tee (suund Merivälja)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.468017, + "lon": 24.833886, + "headingDeg": 2.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam195.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam195.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-196", + "name": "Merivälja tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.468364, + "lon": 24.834059, + "headingDeg": 232.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam196.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam196.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-197", + "name": "Merivälja tee - Kloostrimetsa tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467995, + "lon": 24.834101, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam197.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam197.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-251", + "name": "Narva mnt - Hobujaama tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.437143, + "lon": 24.75758, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam251.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam251.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-250", + "name": "Narva mnt - A.Laikmaa tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.437386, + "lon": 24.757149, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam250.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam250.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-247", + "name": "Estonia pst - A.Laikmaa tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.435646, + "lon": 24.757835, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam247.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam247.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-248", + "name": "Gonsiori tn - A.Laikmaa tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.435603, + "lon": 24.757367, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam248.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam248.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-246", + "name": "Gonsiori tn - Kivisilla tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.435281, + "lon": 24.760618, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam246.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam246.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-245", + "name": "Gonsiori tn (suund Lasnamäe) - Maneeži tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.435224, + "lon": 24.759971, + "headingDeg": 88.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam245.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam245.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-083", + "name": "Gonsiori tn - Pronksi tn (suund Kaubamaja)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434719, + "lon": 24.764614, + "headingDeg": 241.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam083.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam083.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-082", + "name": "Gonsiori tn - Pronksi tn (suund Politsei park)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434834, + "lon": 24.764321, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam082.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam082.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-154", + "name": "Gonsiori tn - Kreutzwald tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434205, + "lon": 24.76877, + "headingDeg": 320.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam154.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam154.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-155", + "name": "Gonsiori tn - Kreutzwald tn (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434263, + "lon": 24.768316, + "headingDeg": 88.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam155.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam155.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-152", + "name": "Gonsiori tn - Vilmsi tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432946, + "lon": 24.778944, + "headingDeg": 320.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam152.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam152.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-151", + "name": "Gonsiori tn - Vilmsi tn (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.433151, + "lon": 24.778499, + "headingDeg": 88.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam151.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam151.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-156", + "name": "Gonsiori tn - Laagna tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432014, + "lon": 24.785148, + "headingDeg": 320.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam156.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam156.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-153", + "name": "Gonsiori tn - Laagna tee (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431972, + "lon": 24.784385, + "headingDeg": 88.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam153.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam153.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-140", + "name": "Pronksi tn - Raua tn (suund Ahtri tn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.435491, + "lon": 24.764554, + "headingDeg": 331.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam140.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam140.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-150", + "name": "Pronksi tn - Raua tn (suund Liivalaia tn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.435766, + "lon": 24.764763, + "headingDeg": 225.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam150.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam150.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-076", + "name": "Narva mnt - Pronksi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.437559, + "lon": 24.76451, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam076.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam076.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-077", + "name": "Narva mnt - Jõe tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.437162, + "lon": 24.764493, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam077.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam077.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-126", + "name": "Narva mnt- Petrooleumi tn - Vilmsi tn (suund Petrooleumi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439333, + "lon": 24.774961, + "headingDeg": 62.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam126.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam126.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-127", + "name": "Narva mnt- Petrooleumi tn - Vilmsi tn (suund Kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439099, + "lon": 24.774931, + "headingDeg": 260.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam127.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam127.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-128", + "name": "Narva mnt- Petrooleumi tn - Vilmsi tn (suund Pirita)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439155, + "lon": 24.774939, + "headingDeg": 49.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam128.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam128.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-004", + "name": "Narva mnt - Poska tn (suund Pirita)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.440424, + "lon": 24.785142, + "headingDeg": 49.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam004.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam004.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-080", + "name": "Liivalaia tn - Rävala pst", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432829, + "lon": 24.763296, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam080.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam080.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-081", + "name": "Tartu mnt - Kreutzwaldi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432612, + "lon": 24.763292, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam081.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam081.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-008", + "name": "Tartu mnt - Pronksi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432644, + "lon": 24.763167, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam008.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam008.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-037", + "name": "Tartu mnt - Odra tn - Türnpuu tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.428311, + "lon": 24.775817, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam037.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam037.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-038", + "name": "Tartu mnt - Odra tn - Türnpuu tn (bussijaam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.428236, + "lon": 24.776052, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam038.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam038.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-039", + "name": "Tartu mnt - Odra tn - Türnpuu tn (suund Tartu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.428578, + "lon": 24.775648, + "headingDeg": 135.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam039.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam039.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-044", + "name": "Odra tn - Lastekodu tn (suund bussijaam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427845, + "lon": 24.774329, + "headingDeg": 152.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam044.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam044.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-045", + "name": "Lastekodu tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427493, + "lon": 24.774769, + "headingDeg": 124.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam045.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam045.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-042", + "name": "Türnpu tn - Kunderi tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.430259, + "lon": 24.777907, + "headingDeg": 297.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam042.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam042.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-043", + "name": "Türnpu tn - Kunderi tn (suund Laagna tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.43025, + "lon": 24.778187, + "headingDeg": 77.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam043.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam043.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-167", + "name": "Laagna - Selveri (suund Laagna tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.430974, + "lon": 24.782075, + "headingDeg": 84.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam167.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam167.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-168", + "name": "Laagna - Selveri (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.430905, + "lon": 24.782174, + "headingDeg": 264.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam168.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam168.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-188", + "name": "Tartu mnt - Lubja tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427399, + "lon": 24.778227, + "headingDeg": 306.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam188.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam188.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-009", + "name": "Tartu mnt (suund Sikupilli) - Peterburi mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.424337, + "lon": 24.786011, + "headingDeg": 98.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam009.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam009.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-084", + "name": "Tartu mnt - Peterburi mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.423987, + "lon": 24.785984, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam084.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam084.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-078", + "name": "Tartu mnt - Järvevana tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.422259, + "lon": 24.788525, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam078.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam078.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-079", + "name": "Tartu mnt - Suur-Sõjamäe tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.422655, + "lon": 24.787219, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam079.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam079.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-005", + "name": "Tartu mnt (suund kesklinn) - Lennujaama tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.420012, + "lon": 24.791687, + "headingDeg": 306.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam005.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam005.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-006", + "name": "Tartu mnt (suund Tartu) - Lennujaama tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.420097, + "lon": 24.791955, + "headingDeg": 135.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam006.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam006.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-136", + "name": "Tartu mnt - Vana-Tartu mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.411638, + "lon": 24.802079, + "headingDeg": 327.1, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam136.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam136.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-137", + "name": "Tartu mnt - Kanali tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.408044, + "lon": 24.806241, + "headingDeg": 321.9, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam137.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam137.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-047", + "name": "Tartu mnt (suund kesklinn)- Mõigu tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.407197, + "lon": 24.807312, + "headingDeg": 317.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam047.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam047.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-048", + "name": "Tartu mnt (suund Tartu)- Mõigu tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.407873, + "lon": 24.808022, + "headingDeg": 135.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam048.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam048.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-112", + "name": "Liivalaia tn (suund Pärnu mnt)- Juhkentali tn - Lembitu tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429447, + "lon": 24.756856, + "headingDeg": 310.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam112.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam112.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-113", + "name": "Liivalaia tn - Juhkentali tn (suund Juhkentali) - Lembitu tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429332, + "lon": 24.757253, + "headingDeg": 115.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam113.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam113.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-114", + "name": "Liivalaia tn - Juhkentali tn - Lembitu tn (suund Lembitu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429408, + "lon": 24.757142, + "headingDeg": 352.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam114.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam114.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-099", + "name": "Juhkentali tn - Filtri tee - Odra tn (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427039, + "lon": 24.771295, + "headingDeg": 72.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam099.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam099.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-100", + "name": "Juhkentali tn - Filtri tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.426993, + "lon": 24.771712, + "headingDeg": 359.1, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam100.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam100.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-234", + "name": "Järvevana tee (raudteeülesõit, suund Mustamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404763, + "lon": 24.738254, + "headingDeg": 61.7, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam234.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam234.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-233", + "name": "Järvevana tee (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404951, + "lon": 24.738513, + "headingDeg": 58.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam233.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam233.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-236", + "name": "Järvevana tee – Veerenni tn (suund Mustamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.411381, + "lon": 24.75603, + "headingDeg": 273.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam236.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam236.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-235", + "name": "Järvevana tee – Veerenni tn (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.41389, + "lon": 24.758873, + "headingDeg": 58.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam235.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam235.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-024", + "name": "Järvevana tee (ennem tunnelit suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.418157, + "lon": 24.765801, + "headingDeg": 61.7, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam024.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam024.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-046", + "name": "Lasnamäele (tunnelis)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.420112, + "lon": 24.768063, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam046.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam046.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-050", + "name": "Mustamäele (enne tunnelit)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.402648, + "lon": 24.726843, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam050.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam050.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-237", + "name": "Järvevana tee (suund Mustamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.422859, + "lon": 24.779258, + "headingDeg": 275.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam237.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam237.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-238", + "name": "Järvevana tee – Filtri tee tunnel (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.422861, + "lon": 24.775729, + "headingDeg": 58.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam238.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam238.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-239", + "name": "Järvevana tee – Filtri tee tunnel (suund Mustamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.418727, + "lon": 24.76628, + "headingDeg": 273.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam239.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam239.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-109", + "name": "Tehnika tn - Veskiposti tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.421036, + "lon": 24.765894, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam109.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam109.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-065", + "name": "Vabaduse väljak", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432928, + "lon": 24.745551, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam065.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam065.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-066", + "name": "Estonia pst - Pärnu mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432902, + "lon": 24.746388, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam066.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam066.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-125", + "name": "Pärnu mnt - Suur-Ameerika tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427286, + "lon": 24.742256, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam125.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam125.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-069", + "name": "Pärnu mnt (suund Pärnu) - Liivalaia tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427382, + "lon": 24.742399, + "headingDeg": 187.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam069.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam069.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-070", + "name": "Pärnu mnt (suund \"Kosmos\") - Liivalaia tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427435, + "lon": 24.742399, + "headingDeg": 198.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam070.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam070.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-229", + "name": "Pärnu mnt (suund \"Kosmos\") - Vineeri tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.422531, + "lon": 24.742465, + "headingDeg": 198.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam229.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam229.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-228", + "name": "Pärnu mnt (suund Pärnu) - Vineeri tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.423128, + "lon": 24.74246, + "headingDeg": 187.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam228.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam228.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-096", + "name": "Pärnu mnt - Tondi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.390586, + "lon": 24.721513, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam096.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam096.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-105", + "name": "Pärnu mnt - Saku tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.410027, + "lon": 24.736825, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam105.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam105.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-106", + "name": "Pärnu mnt - Kohila tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.392114, + "lon": 24.722693, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam106.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam106.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-087", + "name": "Pärnu mnt (suund kesklinn)- Järvevana tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.403498, + "lon": 24.730369, + "headingDeg": 46.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam087.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam087.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-088", + "name": "Pärnu mnt (suund Pärnu)- Järvevana tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.403106, + "lon": 24.729843, + "headingDeg": 187.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam088.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam088.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-089", + "name": "Pärnu mnt - Järvevana tee (suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.401662, + "lon": 24.729479, + "headingDeg": 85.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam089.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam089.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-014", + "name": "Kaarli pst – Toompuiestee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431194, + "lon": 24.738821, + "headingDeg": 50.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam014.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam014.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-015", + "name": "Kaarli pst – Toompea tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431784, + "lon": 24.740162, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam015.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam015.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-003", + "name": "Kaarli pst – Toompea tn (suund Kaarli)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431929, + "lon": 24.740284, + "headingDeg": 249.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam003.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam003.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-018", + "name": "Endla tn – Toompuiestee (suund Kaarli pst)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431278, + "lon": 24.738573, + "headingDeg": 64.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam018.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam018.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-019", + "name": "Endla tn – Toompuiestee (suund Tõnismägi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431002, + "lon": 24.738563, + "headingDeg": 87.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam019.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam019.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-031", + "name": "Luise tn - Tehnika tn (suund Kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.4289581, + "lon": 24.7267407, + "headingDeg": 59.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam031.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam031.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-033", + "name": "Luise tn - Tehnika tn (suund Taksopark)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.4289581, + "lon": 24.7267407, + "headingDeg": 225.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam033.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam033.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-032", + "name": "Endla tn - Tehnika tn (suund Kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.428081, + "lon": 24.725906, + "headingDeg": 59.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam032.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam032.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-034", + "name": "Endla tn - Tehnika tn (suund Balti jaam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.428185, + "lon": 24.72632, + "headingDeg": 25.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam034.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam034.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-210", + "name": "Toompuiestee - Paldiski mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434713, + "lon": 24.732985, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam210.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam210.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-213", + "name": "Paldiski mnt - Tehnika (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434115, + "lon": 24.723775, + "headingDeg": 80.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam213.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam213.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-215", + "name": "Paldiski mnt - Ristiku (suund Ristiku)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.433961, + "lon": 24.722239, + "headingDeg": 358.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam215.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam215.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-214", + "name": "Paldiski mnt - Ristiku (suund Mustamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.433935, + "lon": 24.722092, + "headingDeg": 219.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam214.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam214.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-061", + "name": "Toompuiestee - Tehnika tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.436464, + "lon": 24.732776, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam061.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam061.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-062", + "name": "Toompuiestee - Tehnika tn (suund Baltijaam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.43648, + "lon": 24.732919, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam062.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam062.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-212", + "name": "Toompuiestee - Nunne (suund Balti jaam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.440055, + "lon": 24.739168, + "headingDeg": 356.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam212.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam212.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-211", + "name": "Toompuiestee - Nunne (suund Sadam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439959, + "lon": 24.73877, + "headingDeg": 45.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam211.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam211.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-115", + "name": "Pae tn - Peterburi tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.425941, + "lon": 24.801485, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam115.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam115.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-051", + "name": "Narva mnt (suund kesklinn) - Turba tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.444945, + "lon": 24.814854, + "headingDeg": 260.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam051.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam051.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-117", + "name": "Smuuli tee - Punane tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.433702, + "lon": 24.831138, + "headingDeg": 265.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam117.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam117.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-118", + "name": "Smuuli tee - Punane tn (suund Narva)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.434066, + "lon": 24.831069, + "headingDeg": 90.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam118.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam118.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-091", + "name": "Peterburi mnt (suund kesklinn) - Smuuli tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429014, + "lon": 24.834234, + "headingDeg": 280.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam091.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam091.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-092", + "name": "Peterburi mnt (suund Narva) - Smuuli tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429117, + "lon": 24.835587, + "headingDeg": 90.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam092.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam092.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-090", + "name": "Suur-Sõjamäe tn - Smuuli tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.415712, + "lon": 24.844588, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam090.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam090.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-097", + "name": "Peterburi mnt (suund kesklinn) - Väo tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441, + "lon": 24.89688, + "headingDeg": 280.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam097.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam097.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-098", + "name": "Peterburi mnt (suund Narva) - Rahu tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.440679, + "lon": 24.89654, + "headingDeg": 90.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam098.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam098.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-217", + "name": "Laagna tee - Mustakivi tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441903, + "lon": 24.866537, + "headingDeg": 264.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam217.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam217.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-131", + "name": "Laagna tee - Mustakivi tee (suund Peterburi tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441798, + "lon": 24.86727, + "headingDeg": 250.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam131.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam131.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-216", + "name": "Laagna tee - Mustakivi tee (suund Narva mnt)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.442615, + "lon": 24.86635, + "headingDeg": 264.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam216.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam216.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-230", + "name": "Ussimäe tn (suund Pärnamäe) - Priisle tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.452244, + "lon": 24.891294, + "headingDeg": 29.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam230.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam230.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-231", + "name": "Ussimäe tn (suund Kärberi tn) - Priisle tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.451904, + "lon": 24.892549, + "headingDeg": 164.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam231.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam231.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-249", + "name": "Ussimäe tn - Narva mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.454048, + "lon": 24.893883, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam249.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam249.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-224", + "name": "Laagna tee - Kärberi tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.446959, + "lon": 24.897265, + "headingDeg": 264.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam224.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam224.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-225", + "name": "Laagna tee - Kärberi tn (suund Peterburi tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.446061, + "lon": 24.898016, + "headingDeg": 250.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam225.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam225.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-123", + "name": "Pärnu mnt - Järvevana tee (suund Neste)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.402114, + "lon": 24.730092, + "headingDeg": 77.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam123.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam123.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-164", + "name": "Pärnu mnt - Järvevana tee (suund Neste)2", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.402281, + "lon": 24.729054, + "headingDeg": 77.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam164.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam164.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-095", + "name": "Pärnu mnt - Viljandi mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.393577, + "lon": 24.725086, + "headingDeg": 338.7, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam095.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam095.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-058", + "name": "Viljandi mnt (suund kesklinn) - Valdeku tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.376621, + "lon": 24.746196, + "headingDeg": 0.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam058.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam058.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-059", + "name": "Viljandi mnt - Valdeku tn (suund Rapla)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.376086, + "lon": 24.746952, + "headingDeg": 352.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam059.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam059.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-053", + "name": "Pärnu mnt (suund kesklinn) - Viljandi mnt", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.394192, + "lon": 24.72507, + "headingDeg": 338.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam053.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam053.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-052", + "name": "Pärnu mnt - Männiku tee (suund Nõmme)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.394485, + "lon": 24.724622, + "headingDeg": 209.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam052.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam052.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-107", + "name": "Vabaduse pst - Männiku tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.388608, + "lon": 24.719608, + "headingDeg": 36.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam107.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam107.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-108", + "name": "Vabaduse pst - Männiku tee (suund Pärnu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.387006, + "lon": 24.718067, + "headingDeg": 184.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam108.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam108.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-204", + "name": "Männiku tee - Valdeku tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.375607, + "lon": 24.717817, + "headingDeg": 16.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam204.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam204.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-205", + "name": "Männiku tee - Valdeku tn (suund Viljandi mnt)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.375208, + "lon": 24.718735, + "headingDeg": 107.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam205.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam205.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-093", + "name": "Pärnu mnt (suund kesklinn) - Vabaduse pst", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.392581, + "lon": 24.722975, + "headingDeg": 46.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam093.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam093.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-094", + "name": "Pärnu mnt (suund Pärnu) - Vabaduse pst", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.360803, + "lon": 24.629726, + "headingDeg": 187.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam094.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam094.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-073", + "name": "Nõmme keskus (suund Järve)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.388164, + "lon": 24.683717, + "headingDeg": 57.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam073.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam073.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-074", + "name": "Nõmme keskus (suund Hiiu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.387863, + "lon": 24.684014, + "headingDeg": 205.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam074.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam074.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-075", + "name": "Nõmme keskus (suund Mustamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.387825, + "lon": 24.683534, + "headingDeg": 350.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam075.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam075.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-208", + "name": "Pärnu mnt - Vääna tn(suund Jaam)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.383489, + "lon": 24.669711, + "headingDeg": 333.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam208.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam208.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-209", + "name": "Pärnu mnt - Hiiu tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.383774, + "lon": 24.671343, + "headingDeg": 37.6, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam209.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam209.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-134", + "name": "Sõpruse pst-Endla tn-Tulika tn(suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427587, + "lon": 24.720716, + "headingDeg": 46.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam134.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam134.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-132", + "name": "Sõpruse pst-Endla tn-Tulika tn(suund Sõle tn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.42718, + "lon": 24.719952, + "headingDeg": 31.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam132.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam132.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-133", + "name": "Sõpruse pst-Endla tn-Tulika tn(suund Sõruse pst)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427614, + "lon": 24.720183, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam133.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam133.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-007", + "name": "Sõpruse pst-Endla tn-Tulika tn(ülevaade)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.427159, + "lon": 24.720762, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam007.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam007.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-199", + "name": "Tammsaare tee (K-Rauta vasakpööre)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.402253, + "lon": 24.716109, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam199.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam199.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-198", + "name": "Tammsaare tee – Sõjakooli tn (suund Tondi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.402426, + "lon": 24.721012, + "headingDeg": 20.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam198.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam198.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-129", + "name": "Tammsaare tee - Alajaama viadukt (suund Järvevana)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40244, + "lon": 24.725683, + "headingDeg": 57.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam129.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam129.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-130", + "name": "Tammsaare tee - Alajaama viadukt (suund Tondi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40263, + "lon": 24.725773, + "headingDeg": 25.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam130.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam130.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-030", + "name": "Järvevana tee (suund Delta Plaza)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40323, + "lon": 24.731772, + "headingDeg": 237.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam030.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam030.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-026", + "name": "Järvevana tee (raudteeülesõit, suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40425, + "lon": 24.736421, + "headingDeg": 61.7, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam026.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam026.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-025", + "name": "Tammsaare tee - Tondi tn (suund Sõpruse)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.402343, + "lon": 24.710432, + "headingDeg": 259.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam025.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam025.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-027", + "name": "Tammsaare tee- Tondi tn (suund Järvevana)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40232, + "lon": 24.710637, + "headingDeg": 60.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam027.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam027.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-063", + "name": "Paldiski mnt - Tulika tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432784, + "lon": 24.716433, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam063.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam063.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-064", + "name": "Paldiski mnt - Sõle tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.432548, + "lon": 24.716106, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam064.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam064.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-146", + "name": "Sõle tn - Kolde pst(suund Kopli)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439692, + "lon": 24.707628, + "headingDeg": 337.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam146.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam146.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-147", + "name": "Sõle tn - Kolde pst(suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.439517, + "lon": 24.707809, + "headingDeg": 77.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam147.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam147.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-138", + "name": "Sõle tn - Sitsi tn (suund Kopli)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.449008, + "lon": 24.698039, + "headingDeg": 337.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam138.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam138.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-139", + "name": "Sõle tn - Sitsi tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.448887, + "lon": 24.698323, + "headingDeg": 77.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam139.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam139.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-035", + "name": "Paldiski mnt - Endla tn (Mustamäele, Õismäele)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429184, + "lon": 24.705238, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam035.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam035.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-036", + "name": "Paldiski mnt - Endla tn (suund Kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429163, + "lon": 24.704665, + "headingDeg": 74.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam036.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam036.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-071", + "name": "Sõpruse pst - Tammsaare tee (suund Õismäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.405108, + "lon": 24.701013, + "headingDeg": 322.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam071.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam071.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-072", + "name": "Sõpruse pst (suund Kesklinn) - Tammsaare tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404814, + "lon": 24.700778, + "headingDeg": 46.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam072.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam072.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-135", + "name": "Sõpruse pst - Tammsaare tee (suund Järvevana)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404721, + "lon": 24.701844, + "headingDeg": 67.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam135.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam135.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-067", + "name": "Mustamäe tee - Tammsaare tee (suund Õismäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.408213, + "lon": 24.684591, + "headingDeg": 287.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam067.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam067.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-068", + "name": "Mustamäe tee (suund kesklinn) - Tammsaare tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.408467, + "lon": 24.685432, + "headingDeg": 49.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam068.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam068.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-206", + "name": "Sõpruse pst - Vilde tee (suund Mustamäe tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40084, + "lon": 24.694514, + "headingDeg": 27.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam206.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam206.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-207", + "name": "Sõpruse pst - Vilde tee (suund PERH)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.401166, + "lon": 24.694591, + "headingDeg": 87.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam207.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam207.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-085", + "name": "Ehitajate tee (suund Õismäe) - Kadaka tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.411095, + "lon": 24.66473, + "headingDeg": 314.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam085.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam085.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-086", + "name": "Ehitajate tee (suund Mustamäe) - Kadaka tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.41072, + "lon": 24.665023, + "headingDeg": 53.3, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam086.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam086.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-242", + "name": "Tammsaare tee - Ehitajate tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.410186, + "lon": 24.668587, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam242.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam242.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-243", + "name": "Tammsaare tee - Laki tn (suund Tondi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.409677, + "lon": 24.675388, + "headingDeg": 20.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam243.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam243.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-244", + "name": "Laki tn - Karjavälja tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.4105382, + "lon": 24.6751713, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam244.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam244.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-121", + "name": "Kadaka tee-Akadeemia tee(linnast välja)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404375, + "lon": 24.654813, + "headingDeg": 237.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam121.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam121.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-122", + "name": "Kadaka tee-Akadeemia tee(linna)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404005, + "lon": 24.655773, + "headingDeg": 152.9, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam122.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam122.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-029", + "name": "Ehitajate tee - Vilde tee (Õismäele, Szolnokisse)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.404035, + "lon": 24.669087, + "headingDeg": 155.1, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam029.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam029.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-119", + "name": "Paldiski mnt - Mõisa tn(suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.42564, + "lon": 24.65558, + "headingDeg": 74.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam119.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam119.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-120", + "name": "Paldiski mnt - Mõisa tn(suund Õismäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.425738, + "lon": 24.656454, + "headingDeg": 163.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam120.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam120.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-218", + "name": "Paldiski mnt - Rannamõisa tee (suund Ehitajate tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.423104, + "lon": 24.645519, + "headingDeg": 144.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam218.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam218.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-219", + "name": "Paldiski mnt - Rannamõisa tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.423571, + "lon": 24.645852, + "headingDeg": 74.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam219.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam219.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-220", + "name": "Paldiski mnt - Rannamõisa tee (suund Keila)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.423611, + "lon": 24.644619, + "headingDeg": 224.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam220.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam220.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-221", + "name": "Paldiski mnt - K-Rauta ristmik (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.421208, + "lon": 24.642079, + "headingDeg": 74.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam221.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam221.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-222", + "name": "Rannamõisa tee - Lõuka tn (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.426581, + "lon": 24.631192, + "headingDeg": 81.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam222.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam222.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-223", + "name": "Rannamõisa tee - Lõuka tn (suund Tabasalu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.426779, + "lon": 24.631276, + "headingDeg": 267.8, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam223.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam223.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-226", + "name": "Rannamõisa tee - Pikaliiva tn (suund Tabasalu)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429054, + "lon": 24.619573, + "headingDeg": 269.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam226.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam226.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-227", + "name": "Rannamõisa tee - Pikaliiva tn (suund Õismägi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.429005, + "lon": 24.619071, + "headingDeg": 299.8, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam227.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam227.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-028", + "name": "Paldiski mnt - Õismäe tee(Harku järve JKÜ)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.413327, + "lon": 24.634074, + "headingDeg": 31.4, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam028.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam028.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-010", + "name": "Paldiski mnt - Järveotsa tee (suund Keila)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.4101, + "lon": 24.631905, + "headingDeg": 226.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam010.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam010.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-011", + "name": "Paldiski mnt - Järveotsa tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40966, + "lon": 24.631066, + "headingDeg": 66.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam011.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam011.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-012", + "name": "Paldiski mnt - Järvekalda tee (suund Keila)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.407169, + "lon": 24.619328, + "headingDeg": 224.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam012.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam012.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-013", + "name": "Paldiski mnt - Järvekalda tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.407085, + "lon": 24.618982, + "headingDeg": 74.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam013.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam013.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-162", + "name": "Paldiski mnt - Tähetorni tee (suund kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.395395, + "lon": 24.594379, + "headingDeg": 74.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam162.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam162.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-163", + "name": "Paldiski mnt - Tähetorni tee (suund Keila)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.394981, + "lon": 24.593069, + "headingDeg": 224.9, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam163.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam163.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-202", + "name": "Akadeemia tee - Järveotsa tee (suund Järveotsa tee)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.407704, + "lon": 24.648984, + "headingDeg": 35.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam202.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam202.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-203", + "name": "Akadeemia tee - Järveotsa tee (suund Pöörise tn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.407846, + "lon": 24.6476, + "headingDeg": 76.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam203.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam203.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-148", + "name": "Kopli tn(suund Kesklinn) - Erika tn(suund Kesklinn)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.448425, + "lon": 24.712092, + "headingDeg": 117.1, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam148.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam148.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-149", + "name": "Kopli tn(suund Kopli) - Ristiku tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.448183, + "lon": 24.711782, + "headingDeg": 113.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam149.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam149.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-144", + "name": "Sitsi tn- Paljasaare tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.453739, + "lon": 24.703756, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam144.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam144.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-145", + "name": "Kopli tn - Sitsi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.453345, + "lon": 24.70339, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam145.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam145.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-060", + "name": "Narva mnt (suund linnast välja) - Vormsi tn", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.455037, + "lon": 24.870274, + "headingDeg": 80.2, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam060.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam060.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-040", + "name": "Narva mnt (suund linna) - Kose tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.454762, + "lon": 24.871014, + "headingDeg": 257.4, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam040.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam040.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-165", + "name": "Vana-Narva mnt (linnast välja) - Pärnamäe tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.460155, + "lon": 24.904597, + "headingDeg": 264.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam165.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam165.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-166", + "name": "Vana-Narva mnt (linna) - Pärnamäe tee", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.460544, + "lon": 24.905172, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam166.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam166.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-169", + "name": "Pärnamäe tee - Muuga tee (suund Muuga)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467009, + "lon": 24.904098, + "headingDeg": 137.6, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam169.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam169.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-170", + "name": "Pärnamäe tee - Muuga tee (suund Narva mnt)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467406, + "lon": 24.904724, + "headingDeg": 229.7, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam170.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam170.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-173", + "name": "Pärnamäe tee - Randvere tee(suund Lasnamäe)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.490974, + "lon": 24.88026, + "headingDeg": 199.5, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam173.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam173.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-174", + "name": "Pärnamäe tee - Randvere tee(suund Viimsi)", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.490747, + "lon": 24.879395, + "headingDeg": 267.0, + "headingConfidence": "high", + "pitchDeg": -24, + "fovDeg": 56, + "rangeM": 210, + "mountHeightM": 10, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam174.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam174.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-101", + "name": "Kalev P&R (sissepääs), Pärnu mnt 150", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.409043, + "lon": 24.735154, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam101.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam101.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-016", + "name": "Kalev P&R (vaade 1), Pärnu mnt 150", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.409303, + "lon": 24.7352, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam016.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam016.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-017", + "name": "Kalev P&R (vaade 2), Pärnu mnt 150", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.40902, + "lon": 24.734724, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam017.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam017.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-102", + "name": "Kalev P&R (vaade 3), Pärnu mnt 150", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.409423, + "lon": 24.735403, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam102.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam102.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-175", + "name": "Tondi P&R (sissepääs), Pärnu mnt 142a", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.41052, + "lon": 24.734112, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam175.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam175.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-161", + "name": "Tondi P&R, Pärnu mnt 142a", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.410559, + "lon": 24.73414, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam161.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam161.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-190", + "name": "Pirita 1 P&R (sissepääs), Jõesuu tee 5", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467211, + "lon": 24.832099, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam190.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam190.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-021", + "name": "Pirita 1 P&R, Jõesuu tee 5", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467026, + "lon": 24.832559, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam021.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam021.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-189", + "name": "Pirita 2 P&R (sissepääs), Jõesuu tee 5", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467141, + "lon": 24.832829, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam189.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam189.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-020", + "name": "Pirita 2 P&R, Jõesuu tee 5", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.467406, + "lon": 24.832463, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam020.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam020.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-192", + "name": "Väike-Õismäe P&R (sissepääs 1), Paldiski mnt 221", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.413013, + "lon": 24.635229, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam192.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam192.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-022", + "name": "Väike-Õismäe P&R (sissepääs 2), Paldiski mnt 221", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.413128, + "lon": 24.634471, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam022.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam022.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-191", + "name": "Väike-Õismäe P&R, Paldiski mnt 221", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.412643, + "lon": 24.635013, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam191.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam191.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-023", + "name": "Oru P&R, Narva mnt 120C", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.443192, + "lon": 24.804078, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam023.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam023.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-124", + "name": "Viru tn langetatav pollar", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.436614, + "lon": 24.751546, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam124.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam124.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-240", + "name": "Vana-Kalamaja pollar", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.441202, + "lon": 24.738547, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam240.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam240.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-157", + "name": "Gonsiori P&R (sissepääs), Gonsiori T7 / Laagna tee T12", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431982, + "lon": 24.782368, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam157.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam157.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-158", + "name": "Gonsiori P&R (vaade 1), Gonsiori T7 / Laagna tee T12", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431451, + "lon": 24.782516, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam158.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam158.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-159", + "name": "Gonsiori P&R (vaade 2), Gonsiori T7 / Laagna tee T12", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431663, + "lon": 24.782473, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam159.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam159.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-160", + "name": "Gonsiori P&R (vaade 3), Gonsiori T7 / Laagna tee T12", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.431561, + "lon": 24.782502, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam160.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam160.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)" + }, + { + "id": "tln-171", + "name": "Laagri P&R (sissepääs), Pärnu mnt. 550", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.358221, + "lon": 24.627948, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam171.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam171.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + }, + { + "id": "tln-172", + "name": "Laagri P&R, Pärnu mnt. 550", + "city": "Tallinn", + "cityId": "tallinn", + "provider": "City of Tallinn", + "lat": 59.357012, + "lon": 24.627735, + "headingDeg": 0.0, + "headingConfidence": "low", + "pitchDeg": -18, + "fovDeg": 44, + "rangeM": 145, + "mountHeightM": 8, + "groundElevationM": 15, + "feedType": "image", + "url": "https://ristmikud.tallinn.ee/last/cam172.jpg", + "snapshotUrl": "https://ristmikud.tallinn.ee/last/cam172.jpg", + "sourceKind": "tallinn-ristmikud", + "license": "Public City of Tallinn traffic camera frame (ristmikud.tallinn.ee)", + "poseSource": "curated" + } +] diff --git a/config/cctv_sources.warendorf.json b/config/cctv_sources.warendorf.json new file mode 100644 index 000000000..394636d96 --- /dev/null +++ b/config/cctv_sources.warendorf.json @@ -0,0 +1,23 @@ +[ + { + "id": "warendorf-marktplatz-rathaus", + "name": "Marktplatz / Historisches Rathaus", + "city": "Warendorf", + "provider": "Stadt Warendorf", + "sourceKind": "municipal-webcam", + "feedType": "image", + "url": "http://webcam.warendorf.de/image/jpeg.cgi", + "snapshotUrl": "http://webcam.warendorf.de/image/jpeg.cgi", + "lat": 51.9526613, + "lon": 7.9908867, + "headingDeg": 221, + "headingConfidence": "high", + "pitchDeg": -23, + "fovDeg": 84, + "rangeM": 260, + "mountHeightM": 14, + "groundElevationM": 55, + "poseSource": "curated", + "license": "Public municipal webcam data — Stadt Warendorf (courtesy). Mount and heading derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)." + } +] diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md new file mode 100644 index 000000000..f65695631 --- /dev/null +++ b/docs/APPLICATION.md @@ -0,0 +1,102 @@ +# Application construction + +`gods-eye-view/application` exports `createApplication`. Importing it does not +create a viewer, discover configuration, start requests, or attach browser +listeners. Construction is also inactive until the caller invokes `start()`. + +The caller supplies four constructors, in this order: + +| Constructor | Receives | Standalone implementation | +| ---------------- | ---------------------------------------------- | --------------------------------------------- | +| `createScene` | `signal`, `defer` | Viewer, attribution and initial map | +| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation | +| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration | +| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners | + +Each constructor returns its component object, or a promise for that object. +Those objects are passed unchanged to later constructors. Configuration and +service instances belong in the caller's closures; the application does not +interpret provider names, environment variables, endpoints, or module paths. +There is no module discovery or automatic import mechanism. + +```js +import { createApplication } from 'gods-eye-view/application'; +import { createApplicationViewer } from 'gods-eye-view/application/viewer'; + +const app = createApplication({ + createScene({ defer }) { + const viewer = createApplicationViewer({ container, creditContainer }); + defer(() => viewer.destroy()); + return { viewer }; + }, + createControls, + createData, + createTools, +}); +const unsubscribe = app.subscribe(({ status, phase }) => { + // Update the caller's startup presentation. +}); +await app.start(); +// When the application is no longer needed: +unsubscribe(); +await app.destroy(); +``` + +The example's containers and three remaining constructors are supplied by the +consumer. The viewer helper preserves the standalone viewer's render settings; +it neither selects map sources nor reads keys. Supply a visible credit container +and retain all attribution required by the chosen sources. Cesium is external to +this export: the consuming build must provide the same instance used elsewhere +in its application. + +## Lifecycle and ownership + +Register `defer(cleanup)` immediately after acquiring each resource, before any +`await`. Cleanup registration remains open until that constructor settles. Each +callback may return a promise. Within a phase, callbacks run in reverse order. +Across phases, teardown runs **tools, controls, data, scene**: controls must cancel +restoration while their data manager and viewer are still alive. + +`start()` returns the same promise on repeated calls. `destroy()` is terminal and +also returns the same promise on repeated calls. It aborts the shared signal +immediately, waits for an in-flight constructor to settle, and runs all registered +cleanup callbacks. Constructors must forward the signal to cancellable operations +and check it after awaits. If a provider cannot cancel construction, register +cleanup for its late result before returning. Destruction waits for that result; +it does not pretend a pending resource has already been released. + +A constructor failure triggers the same cleanup before rejecting startup. +Cleanup failures do not stop the remaining callbacks; they are reported through +an `AggregateError`. Constructors own cleanup for resources created internally, +including anything allocated before their own construction throws. + +`getState()` returns a frozen `{ status, phase }` snapshot. Status is `created`, +`starting`, `ready`, `destroying`, `destroyed`, or `failed`. `phase` identifies the +constructor during startup. `subscribe(listener)` immediately reports the current +snapshot and returns an unsubscribe function. Install observers before `start()` +to receive every startup phase. Observer failures cannot interrupt startup or +teardown. `getComponents()` returns a frozen, shallow snapshot of the currently +constructed component objects; the component instances themselves remain mutable. + +`ready` means all constructors have returned. Background feed activity and the +standalone share-restoration result keep their existing separate contracts. + +## Standalone wiring + +`src/main.js` reads the existing browser configuration and starts +`src/standalone/application.js`. That module selects the four implementations +in its directory. Scene setup, controls, layer registration, tools and loading +chrome have separate owners. The existing `window.__godsEyeView` debugging shape +is preserved while the app is running. + +The standalone controls and layer modules still contain page-scoped state. +Only one standalone application may be constructed per page. Shutdown releases +its runtime resources and debugging handles; reload the page to start again. +It is not an embeddable, removable HTML shell or a multiple-viewer implementation. +Consumers of the small application export supply their own component ownership; +the lifecycle controller itself has no shared instance state. + +Later component extractions can replace a constructor's internals without +changing the startup contract or moving standalone imports into the package. +Run the unit suite, package boundary gate, build, tracking and first-run browser +checks when changing this wiring. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md new file mode 100644 index 000000000..30a9f11c6 --- /dev/null +++ b/docs/CODE-BOUNDARIES.md @@ -0,0 +1,387 @@ +# Formatting and component boundaries + +Run `npm run format` to format the files in `scripts/format-scope.json` and +`npm run format:check` to check that same list without writing. CI checks the +entire adopted list on Linux and Windows. Prettier is pinned in the development +dependencies; use the installed version so local and CI output agree. The shared +configuration specifies two spaces, single quotes, semicolons and LF endings. + +Add new reusable modules and their tests to the list as they are extracted. +Keep mechanical formatting in its own commit after behavior is stable. Existing +source-text regression assertions still apply; investigate failures and preserve +their behavioral coverage when a move or line wrap changes a tested shape. +Files outside the list retain their surrounding style until deliberately adopted. +Generated output, local configuration, browser evidence and bundled datasets are +excluded. The formatter validates every entry before writing any file. + +## Current component ownership + +| Surface | Owns | Receives from its caller | +| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | +| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | +| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | +| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements | +| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services | +| `src/main.js` and `src/standalone/` | Standalone browser startup | Local configuration | + +The application and infrastructure exports are browser source modules. Use their documented +exports instead of importing standalone startup or reaching into internal files. +The application owns the viewer, context store, overlay host and render scheduler; +layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md). + +`npm run check:boundaries` builds every declared package export, with app Vite +configuration disabled. `scripts/package-boundaries.json` lists each export's +component, owned modules and external runtime dependencies. A new export must be +classified. Imports outside the declared modules fail, including unused and +literal dynamic imports. Cesium stays external so the consuming application +supplies the same compatible instance as its viewer. Existing consumer tests +also check import-time inactivity and asset URLs under a non-root base. + +These checks cover the declared exports, not every import in the application. +They check build-time imports, not arbitrary runtime-generated module URLs. +Keep runtime module discovery out of these exports. When extracting another +component, add its ownership and consumer tests together. Node services must use +separate entry points and their own checks when they become reusable; importing +them into a browser component is not supported. + +`gods-eye-view/application` owns construction order, startup state, cancellation +and disposal of caller-supplied components. Its only owned module is +`src/app/application.js`. `gods-eye-view/application/viewer` separately owns the +standard Cesium viewer configuration in `src/app/viewer.js`; Cesium stays external. +Neither export imports standalone UI, layers, tools or configuration. See +[application construction](APPLICATION.md) for the contracts and current limits. + +UI panels and individual source adapters remain future extractions. They should +become smaller modules with explicit lifecycle owners as their callers migrate. + +## Build and standalone server configuration + +`gods-eye-view/build/vite` is a separate Node-only export. `build/vite.js` +creates standard Cesium/Vite browser settings from explicit inputs. It imports +only the declared `vite-plugin-cesium` build dependency, discovers no environment, +and constructs no provider middleware. Call it from a Vite configuration: + +```js +import { createBrowserViteConfig } from 'gods-eye-view/build/vite'; + +export default createBrowserViteConfig({ + plugins: [], + googleApiKey: undefined, + cesiumToken: undefined, +}); +``` + +Consumers supply compatible Vite and vite-plugin-cesium development dependencies. +The package's `node` export condition has no browser fallback. The boundary gate +builds this group for Node, with the declared build dependency external; its +owned module list is checked just like browser groups. Browser groups cannot +use build-only dependency exceptions. + +`server/standalone/vite.config.js` loads the root environment and passes selected +browser keys, host/port and the ordered local provider plugins to this helper. +`server/providers/local.js` composes provider factories and re-exports existing +helpers for compatibility. Provider Settings lives in `server/standalone/key-setup.js` +and writes to the same root `.env` or Pinokio store as before. `vite.config.js` +preserves the default configuration and named provider exports for tools/tests. + +## Aircraft and vessel providers + +`server/providers/live.js` exports the existing Node middleware factories and +request helpers. `aircraft/` owns OpenSky state/fallback, military positions, +enrichment and track endpoints in separate files. `vessels/ais-live.js` owns +websocket setup and route responses; `vessels/ais-store.js` owns record ingestion, +static metadata and recent-track storage. Neither area imports globe rendering. +`common/http.js` owns capped reads/coalescing; `common/query.js` owns query values. + +These plugins retain their existing process-scoped caches and server lifetime. +Importing the entry does not start acquisition. The AIS plugin disposes its +socket/watchdog on server close and re-reads configuration after restart. +The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft +records without importing Node middleware or a renderer. Browser layer/controller +separation is outside this server extraction. + +## Place-search and routing providers + +`gods-eye-view/server/providers/places` exports the Google nearby-place and +text-search plugin, OSRM route registration, and their shared Node helpers. +The existing Overpass plugin still mounts `/api/route` in its original order. +Google credentials are resolved on each request; the default uses the existing +server-key precedence, and callers may supply `resolveApiKey`. + +`gods-eye-view/sources/places` exports portable response projections for Google +place results and OSRM route results/profile aliases. These functions own no +credentials, requests, caches, environment loading, or rendering. Callers retain +input validation and upstream-response acceptance. The boundary check builds +this entry independently and rejects Node imports. + +The browser's direct geocoding, annotation rendering and full Overpass query +service remain in their existing modules. + +## Satellite and launch providers + +`gods-eye-view/server/providers/space` is a Node-only entry for the CelesTrak +and Launch Library 2 middleware. Separate files under `server/providers/space/` +own each feed's acquisition, memory/disk cache and error handling. The existing +local composition mounts them in the same order. Importing the entry performs +no acquisition; factory calls create independent cache state. + +`gods-eye-view/sources/space` owns only fixed upstream URL construction: the +CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed. +Callers supply the group or end date and own validation, credentials, transport, +response limits and cache policy. The boundary gate checks this portable entry +separately from the Node providers. Satellite rendering and launch replay remain +in their existing browser modules. + +## Terrain, traffic, fires and bike-share providers + +`gods-eye-view/server/providers/terrain`, `/traffic`, `/firms` and `/gbfs` +are separate Node-only entries. Each owns its existing middleware and +process-scoped cache or request handling. Standalone composition mounts them in +the original order; their imports do not start acquisition. + +`gods-eye-view/sources/terrain` exports existing point-key, retry and cache +reconstruction mechanics with injectable acquisition dependencies. +`gods-eye-view/sources/traffic` exports tile math and budget calculations. +`gods-eye-view/sources/gbfs` exports host/path acceptance and cache-header rules. +These entries import no Node middleware, application configuration or rendering. +Callers retain their request admission and transport policy. FIRMS CSV parsing +remains an owned dependency of the FIRMS Node provider. The boundary gate +checks each entry independently. + +Browser terrain sampling, traffic matching/drawing, fire overlays and bike-share +layer lifecycle remain in their current modules. This extraction changes no +source defaults, credentials, quotas, data interpretation or visual behavior. + +## Local search, regional context, voice and setup + +Node-only exports `server/providers/overpass`, `server/providers/military-installations`, +`server/providers/regional` and `server/providers/openai` own request handling +without importing the standalone configuration or browser rendering. Overpass +separates query admission, geometry simplification, cache and transport; military +search shares the bounded transport. Regional place/news/weather acquisition is +separate from briefing and weather-effect response caches. Voice handlers share +existing rate limits and request reading; schemas and instructions are separate. + +`server/standalone/key-setup` is explicitly standalone Node functionality. +Its factory and the local voice factory accept `sourceRoot` for application-owned +configuration/log files; defaults resolve the repository root. Local voice also +accepts an optional `annotationGuidance` paragraph. Neither factory starts +acquisition on import. Setup retains its pre-environment-load provenance capture +and development-only registration. Package checks enumerate every owned module +and reject browser imports of these Node entries. + +## Browser place search + +`gods-eye-view/search` exports an explicit geocoding service and Google/Photon +adapters. The entry owns normalization, bounded caches, deadlines and fallback +sequencing. It imports no application state, environment configuration, rendering +or Node server code. Google transport is supplied by its caller. + +`src/standalone/placeSearch.js` constructs the configured Google request and +keyless Photon fallback. The application passes this service to location +controls, annotation resolution and voice/radio actions. Those consumers retain +framing, landmark recovery, footprint matching and playback decisions. Existing +reverse geocoding and nearby/text-search routes remain separate. + +## Panel controls + +`gods-eye-view/ui/panels` owns collapse-button binding, nearest-panel Escape +handling, hover delays and delayed content-focus handoff. It accepts existing +DOM elements and callbacks; importing it creates no browser state. `destroy()` +removes owned listeners and cancels pending work without changing saved state +or moving focus. Call it before removing or replacing the controls. + +`src/ui.js` retains panel layout, persistence, share restoration and application +reactions to state changes. Map Source selection and Location draft cleanup are +provided through callbacks. The component imports no globe, data, application +or server modules. Package checks and scoped formatting cover this entry. + +## Surface keyboard handling + +`gods-eye-view/ui/surfaces` exports `createSurfaceKeyboard` from +`src/ui/surfaceKeyboard.js`. It receives a root DOM node, an optional document, +an `isActive` predicate, an `onEscape` action and an optional return-focus fallback. +Construction is inert. `activate()` remembers the opener and installs one capture +listener; repeated activation is harmless. `deactivate({ restoreFocus: true })` +removes that listener and restores the opener when connected, otherwise invoking +the supplied fallback. Omit return focus when yielding to another surface. +`destroy()` permanently releases ownership without moving focus. + +The welcome launcher and Provider Settings retain content, visibility, initial +focus, animation and screen-specific policy. Tab boundaries are read from the +current visible/enabled controls for each key; ordinary movement within those +boundaries remains native. The component honors already-handled keys and has no +app, server, storage or network dependencies. Its package boundary is checked +independently from the standalone screens that consume it. + +## Panel rail layout + +`gods-eye-view/ui/layout` exports synchronous `layoutLeftPanelRail` and +`layoutRightPanelRail` passes, `measurePanelNaturalHeight`, and the existing pure +corridor/allocation helpers. Separate modules own left placement, right placement, +DOM height measurement and rail geometry. They import no application, renderer, +server, storage or network modules; package checks build this entry independently. + +Callers supply rail/obstacle DOM nodes, the viewport, HUD state, the preferred +panel, disclosure/retry callbacks and the left measurement cache. Right layout +reads the caller's Display scroll value at measurement time and restores it +within the resulting scroll range. The left pass notifies its caller after +alignment so the right pass can follow. Neither pass installs listeners, timers +or observers; construction/import does no work. Scheduling, preference writes, +share restoration and movement of controls between containers remain caller-owned. +Existing helper imports from `cockpitMath.js` and `rightRailPolicy.js` remain +compatible through re-exports. + +## Visual input + +`gods-eye-view/ui/input` exports `bindApplicationShortcuts` and +`createStyleParameters`. The shortcut binder owns one bubbling keydown listener +and receives the document, editing target and explicit action callbacks. +Parameter controls own only the supplied container's generated rows/listeners; +uniform metadata and read/write/change operations come from the caller. +Clearing permits reuse; destruction is final. Neither module imports the app, +renderer, persistence or services. The facade retains panel visibility, share +restore claims and render scheduling. Both controls are destroyed before the +facade's asynchronous teardown can yield. + +## Display controls + +`ui/display` owns Display button, selector and slider subscriptions. It receives +DOM elements and explicit actions, imports no application or effect singleton, +and releases every listener on destruction. Settings and rendering remain with +the caller. + +## Visual effects + +`ui/effects` exports the effects controller and existing preset definitions. +It owns shader stages and their clock, with explicit render ownership callbacks. +Construction installs no stages or frame callbacks. Stop animation before +releasing UI consumers, then destroy to remove owned stages and restore the +borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers +without loading the renderer. UI presentation and product-action coordination +remain in their callers. + +## Map Source controls + +`ui/maps` owns source-chip presentation, selection feedback and its subscription +lifetime. It receives the existing controller and explicit state/action callbacks; +it imports no renderer or application. Source construction and availability policy +remain with the map controller. Rebuilding controls removes their previous chip +listeners, and destruction suppresses late completions without owning or destroying +the supplied controller. + +## Layer panel + +`ui/layers` exports the Layers panel and clear-control binding. Callers supply +snapshots, row descriptors, subscriptions and actions; the component imports no +layer implementation or application bootstrap. Layer transactions remain with +the caller. Hidden-page refresh scheduling remains an explicit callback. + +`ui/layers/feedback` exposes the existing pure loading/notice reducers separately +from DOM controls. Scheduling and presentation stay with their callers. + +## Location controls + +`ui/location` exports Location controls, the cancellable lookup controller and +the existing location-status formatter. Callers supply city data, search and +navigation operations. The component owns DOM listeners and pending expansion; +it imports no geocoder, camera engine, layer or application bootstrap. Existing +camera authority and search providers remain supplied by the application. + +### Radio controls + +`ui/radio` owns Radio input, disclosures, tuner state and presentation. It +receives the existing Radio port and explicit layer/layout actions, without +importing the renderer or station providers. Pure tuner calculations retain +compatibility exports from the data layer. Disposal revokes DOM listeners and +subscriptions before ending the active tuning interaction. + +### Camera panel controls + +`ui/cctv` composes camera controls, frame loading, calibration editing and status +presentation. It receives DOM elements, the existing camera port and explicit +application actions; it imports no provider, layer or camera engine. Selection, +placement, navigation and storage policy remain outside the component family. + +### Context coordination + +`ui/context` owns mode controls, transactions, session restoration and manager +subscriptions. Composition supplies the manager, installations search and +explicit visual/panel actions. `ui/context/policy` exposes the existing pure +mode and restoration rules. No source transport or renderer is imported by +these components; initial state and action results retain their existing shape. + +## Cockpit controls + +`ui/cockpit` supplies the Cockpit controller and Display portal. Camera updates, +instruments, Context readouts, briefings, signals, layout and input have separate +modules. Composition supplies the existing aircraft/awareness operations, terrain +cache and sampling operations, continuous-render owner and regional briefing +service. Pure math, utility layout and vision helpers have explicit exports. +Disposal releases subscriptions and pending work; portal moves preserve the +original Display groups, independent scroll positions and current focus owner. + +## Scene controls + +`ui/scenes` owns Scene prompts, panel input, project/shot rows, playback button and runtime +presentation. It receives project reads and explicit actions, with no imports of +the director, source modules, camera engine or storage. Replacement and disposal +release listeners; pending action feedback is limited to its current owner. + +## UI assembly and styles + +`ui/shell` assembles controls from supplied existing layer, navigation, terrain, +rendering, HUD and share operations. `src/standalone/ui.js` provides the running +application's instances; `src/ui.js` remains the compatibility entry. The shell +imports no standalone bootstrap or concrete live layer implementation. +Panel layout, position/drag, notices, recording and deferred UI work have separate +owners with synchronous cleanup. Existing scene, share and HUD engines retain +their entry points. `ui/styles` loads the ordered stylesheet entry; component +files retain the original cascade, including responsive and dock refinements. + +## UI state and Scene actions + +`StyleManager.subscribeShareState(listener)` supplies the current shareable +visual preferences and subsequent settings changes. The built-in share manager +consumes the same updates. `subscribeLocationSearch(listener)` follows the +current lookup owner across control replacement. `LocationSearch.subscribe` +provides the corresponding per-owner contract. Changes identify `started`, +`found`, `missing`, `failed`, `settled`, and the shell's `reset`; request IDs +belong to their lookup owner. Only current requests publish accepted results. + +`gods-eye-view/scenes` exports `SceneDirector`. Its `subscribe(listener)` supplies +small playback snapshots plus editing outcomes. Scene controls consume these +updates to render the affected presentation; progress does not copy the project +or rebuild shot rows. Project import/export outcomes include the project; +shot editing outcomes include the affected shot and its index before deletion. +Camera, layer sequencing, storage and run-file download retain their existing +owners. Cesium remains an external dependency supplied by the application. + +Each listener receives `{ state, change, revision, initial }` and subscriptions +return an unsubscribe function. Initial state is emitted by default; pass +`{ emitCurrent: false }` to receive only changes. Snapshots and outcomes are +immutable plain data. Reentrant publications retain delivery order; removing a +listener or destroying its owner prevents further queued delivery. These APIs +perform no network requests and discover no additional modules. + +## Maps + +`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`, +`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the +standard sources and their setup/fallback policy. The standalone facade wires +these to the application's render governor. + +A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`. +Each source has a user-facing `descriptor`, availability/reason, and either an +`imagery({ signal })` factory or a supplied `tileset`/`createTileset({ signal })`. +Imagery sources can share a terrain definition with a stable `id` and +`create({ signal })` returning `{ provider }` or `{ terrain }`. Cache IDs must +identify the same source for the lifetime of that registry. Sources may supply +trusted credit markup and construction/tile-error fallback policy. Provider +configuration stays in factories; descriptor values are the presentation API. + +Factories should honor cancellation where their SDK supports it. The controller +also checks scene ownership after asynchronous work, so late results cannot +replace a newer selection. Supplied tilesets are caller-owned; factory-created +tilesets and provider caches are controller-owned. Construct a fresh controller +for a new viewer or configuration lifetime. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861f4..3e4455ab3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,7 +1,669 @@ # God's Eye View Current State +The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations, +not camera footage or plate records. City-scale queries use the existing Overpass +provider, with capped results, retry, cached-data and incomplete-coverage notices. +Its `./layers/alpr` entry exposes a per-instance layer factory and bounded source +adapter. Sources return normalized records, stale/coverage flags and their own +attribution; rendering does not parse Overpass payloads. The standalone +registration supplies selection, picking, terrain and +render services. Selected cards and Data attribution identify OpenStreetMap. +Layer state and the existing voice layer tools include `alpr-cameras`. +The row count explicitly says **nearby**: loaded records can be outside the +screen. A purple-dot legend identifies the fixed 8-pixel markers (12 pixels when +selected). **SHOW NEAREST** frames and selects the nearest loaded camera; it is +disabled with no loaded cameras or while following a tracked entity. This action +chooses from the current source snapshot and uses rendered surface height from +3D tiles or globe terrain when available. +Coverage follows a bounded neighborhood around the screen-center ground point, +with a radius of twice the camera-to-ground range (at least 1 km), rather than +extending to the horizon. Sky, dateline-crossing and wider-than-city views do not +query. Movement within an accepted or pending query reuses it. Markers retain +their Cesium identity and ground clamping while their geometry is unchanged; +refreshes preserve selection without replaying a click event. + +Data Centers, Dams and Submarine Cables release their built Cesium data sources +and record references when disabled. Parsed datasets remain cached for the layer +lifetime, so re-enable rebuilds entities without downloading or parsing again; +this can take longer than simply revealing hidden entities. Destruction clears +the parsed cache as well. A disable during loading cannot leave a completed +build hidden in the scene. + +CCTV exposes a factory through `./layers/cctv`. Catalog and health requests, +frame/media URLs, camera records, ground placement, geometry queues, playback, +projection, cards, calibration and interaction have separate components. The +standalone entry supplies application-owned scene, ground and activation services. +Each layer owns its state and visibility listener; destruction cancels source +reads and pending initialization. Malformed health responses retain prior health. +Existing catalog fallback, camera poses, frame pacing and coverage controls remain. + +Traffic and bikeshare expose factories through `./layers/traffic` and +`./layers/bikeshare`. Traffic separates road requests, ingestion, animation, +flow matching, styling, viewport lifecycle and development timing. Each source +owns its decoded flow cache; cancelled bodies cannot refill it. Bikeshare +separates its city registry, station source, parsing, rendering, selection and +proximity lifecycle. Existing standalone sources, live/simulated labels, road +budgets, station availability and polling behavior are retained. + +Installations and proximity context now expose `./layers/installations` and +`./layers/awareness` factories. Each owns its records, selection, presentation, +navigation and lifecycle. The standalone entries supply existing scene operations +and the aircraft/vessel/installation instances used for proximity queries. +Installation requests use a bounded source adapter; explicit nearby-place search +remains separate from ordinary mapped-site loading. Invalid snapshots retain the +previous display, and cancelled requests cannot publish a later failure state. +Viewport limits, saturation retry, placement, query caps and controls are retained. + +Satellite and mission layers expose separate factories through `./layers/satellites` +and `./layers/launches`. Each owns its catalog, render state, tracking, interaction +and teardown. Source adapters retain the existing CelesTrak and Launch Library +endpoints; standalone entries provide scene services and the satellite dependency. +The mission factory separates paths, replay, camera, panel, overlays and placement. +Pending mission requests abort on disable/destruction and malformed launch +snapshots preserve the last accepted display. Catalog groups, propagation cadence, +tracking intent, replay timing, controls and source attribution remain unchanged. + +The fire layer now exposes `./layers/firms`: an instance factory with separate +snapshot requests, records, rendering, cards, selection, viewport scheduling and +terrain-anchor batching. The standalone entry supplies the existing FIRMS source +and scene services. Disable and teardown cancel pending refreshes; malformed +snapshots preserve the last good display. Refresh restoration keeps selection +identity without announcing a new user selection. Source/label, LOD thresholds, +card limits, fire identities, altitude placement and analyst records are retained. + +Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns +its entities and request lifecycle; the application supplies the overlay host +and snapshot source. The standalone adapter keeps the existing USGS daily feed, +M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or +destroying the layer cancels pending work and ignores late results. + +## Vessel components and sources + +`src/data/aisLiveVessels.js` assembles `createVesselLayer` from the +`./layers/vessels` package entry. The factory owns feed lifecycle, keyed records, +rendering, selection, trails and cards in separate components. The standalone +entry supplies its AISStream source, scene services and existing row limits. + +Incomplete observations retain missing keyed vessels for at most five minutes +since their last accepted receipt, within the renderer's row budget (plus the +existing selected-contact pin). Complete observations keep the existing removal +policy. Empty or failed observations preserve warm data with the existing feed +health warning and first-connect grace period. Source timestamps remain source +timestamps; receipt does not turn an unknown epoch into a fresh update. + +A refreshed record keeps its identity while updating its history reference. +Selection changes, disable and destruction cancel pending trail requests; late +responses cannot refill a cleared or replacement trail. Heading and course, +sea-surface placement, click ownership and card selection policy are unchanged. + + +## Military-flight components and aircraft mechanics + +`gods-eye-view/layers/military` exports `createMilitaryFlightLayer`. It uses the +same normalized observation contract as civil flights, with separate military +classification, styling, model and tracking policy. Each instance owns its +contacts, history, scratch objects, model loads and cancellation lifetime. +Applications supply the existing scene services and resolve model asset URLs; +`src/data/militaryFlights.js` keeps the standalone layer API and adsb.lol source. +A source may retain a bounded stale-status reason; the standalone cached-feed +behavior remains unchanged. + +`gods-eye-view/aircraft` exports the existing shared classification, icon, +metadata, motion, altitude, model-anchor, proximity and selection calculations. +It also exports `createMilitaryRegistry`, an explicitly constructed owner for +known military identities and active-layer transitions. Its optional background +poll consumes the optional `getIdentities` capability, falling back to normalized +positioned records when a source has no identity-only capability. The adsb.lol +adapter preserves known identities without requiring positions; those entries +still cannot enter the renderer. Source replacement +and disposal abort pending work and clear retained identities; construction +starts no network request. Both standalone aircraft layers use one registry. + +## Civil-flight components + +`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance +owns its contacts, histories, model collections, scratch objects and lifecycle. +State, ingestion, enrichment, motion/floor interpolation, rendering, tracking and +queries live in separate files under `src/layers/flights`. The standalone +`src/data/flights.js` assembles the existing OpenSky source and scene services. + +Applications supply the existing floor/snap, geoid, picking, sprite, camera, +trail, focus, readout, context, aircraft presentation and render services. +The factory never constructs a second application registry. Configure a source +before initialization; replacing it while initialized is rejected. Model loads +use the supplied asset resolver, including the preload path. Enrichment receives +an abort signal and cannot update a later lifecycle after destruction. Existing +camera, terrain floor, trail, selection and measured model-size policies remain. + +## Browser live-source observations + +Flights, Military Flights and AIS Vessels obtain snapshots and optional history +through `gods-eye-view/sources/live`. The standalone adapters use the existing +same-origin routes. Aircraft observations distinguish barometric metres from +WGS84 ellipsoid metres and retain source position/contact epochs; vessel records +retain separate heading/course and sea-surface datum. History is a best-effort +addition to the locally accumulated trail, never a promise of complete coverage. + +Snapshot coverage, completeness and freshness are separate fields. A partially +admitted aircraft snapshot retains absent contacts for up to five minutes before +the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty +snapshot retains the previous display. Empty vessel refreshes retain the existing +first-connect grace and warm-data behavior. Known source failures have bounded +messages; arbitrary HTTP response bodies are not surfaced as diagnostics. +Sources receive cancellation signals and check them after body parsing. The +layer's existing lifecycle and selection guards continue to reject late work. + + +## State and action outcomes + +Share preferences, place lookups and Scene playback expose immutable snapshots +and disposable subscriptions. Share settings drive URL updates; lookup outcomes +drive Location labels and busy/error feedback. Superseded or disposed lookups +cannot publish accepted destinations. Each completion carries its own request +identity so an older completion cannot clear the current search indicator. + +Scene controls consume playback state and editing outcomes from the director. +Progress updates carry a small playback snapshot and preserve shot-row identity; +editing outcomes include a copy of the affected scene or shot. Subscriptions +start with current state, isolate listener failures and stop on disposal. +`gods-eye-view/scenes` exports the same director used by the standalone app. + +## UI shell and component ownership + +The standalone entry composes the UI with the application's existing layer, +terrain, navigation and rendering operations. The shell receives those instances +and assembles the controls. Panel layout scheduling, position preferences and +active drags, loading notices, recording presentation and DOM lookup have focused +owners. Disposal revokes queued presentation and listeners before asynchronous +Context restoration, cancels an unfinished drag without saving it, restores the +recording HUD and releases status decoration without replacing accessible text. +The ordinary control snapshot includes the current 3D model toggle and mode. + +`style.css` imports component styles in their original cascade order. Scene, +share, HUD and layer engines retain their existing behavior and entry points. + +## Scene control ownership + +Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation +and keyboard cancellation. The director supplies project reads and explicit +editing/playback actions while retaining persistence, camera and layer sequencing. +Shot selection updates the highlight without replacing the row, preserving +native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; +late file and failed-action completions cannot update removed presentation. + +## Cockpit component ownership + +Cockpit presentation is separated from its camera/controller behavior. Existing +layer, terrain and rendering operations are supplied by composition, retaining +the same tracked identity, ground acquisition, motion correction and cadence. +The Display portal owns group anchors, focus/scroll restoration and listeners. +Superseded portal frames cannot repaint old state or steal focus after disposal; +retained Cockpit actions cannot restart a disposed controller. Input, subscriptions +and queued panel work stop before asynchronous layer restoration; final camera +and portal cleanup follows that restoration. + + +## Context coordination + +Context controls own Contacts/Space Missions state, entry and exit transactions, +layer snapshots and restoration. The application supplies the existing manager, +installations search and camera/panel actions. Tab listeners and pending +presentation work stop during disposal; layer restoration retains its existing +compensation and latest-intent rules. Clear Selected Layers shares this owner, +so an older restore cannot replay over a newer Clear action. + + +## Camera panel ownership + +CCTV controls receive the existing camera port and explicit application actions. +Frame loading, calibration editing and status display have separate modules; +providers, camera placement and navigation policy retain their existing owners. +Changing cameras invalidates old image callbacks and cancels an unfinished +calibration edit. Failed refreshes preserve settled pixels for the same camera, +while a newly selected camera never shows the prior camera's image. Disposal +releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. + + +## UI disposal + +UI disposal releases the CCTV subscription, command-dock observer, legacy drag +resize observer and window resize listener. These remain independent of the +Location component and are included in whole-UI browser teardown acceptance. + +## Radio controls + +Radio panel, compact controls and tuner presentation have a dedicated owner. +It receives playback, layer actions and layout callbacks; station ingestion, +marker placement, audio playback and camera policy stay with their existing +owners. Tuner calculations have a pure entry, with existing layer exports +preserved. Destruction removes listeners and state subscriptions before ending +tuning; a delayed Enable result cannot reveal or refocus removed controls. + +## DriveBC CCTV source pack + +The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the +Austin, Caltrans and TfL packs. The server reads the keyless camera list that +DriveBC.ca serves at `https://www.drivebc.ca/api/webcams/` and keeps cameras that +are switched on and published. Frame URLs are built from each numeric camera id +under `https://www.drivebc.ca/images/`; they are never taken from the payload. +DriveBC's eight compass orientations become high-confidence heading priors, and +its elevation (metres above sea level) seeds the ground height. + +By default the 250 cameras nearest downtown Vancouver and Victoria load. +`CCTV_DRIVEBC_MAX_SOURCES` sets the pack cap (8–1200) and +`CCTV_DRIVEBC_ENABLED=0` turns the pack off. The default catalog cap rises from +900 to 1,050, the sum of the default pack caps, so no default camera is dropped. +The Open Government Licence – British Columbia attribution is registered in the +Data attribution popover. + +## Location control ownership + +City/POI rows, search/reset bindings, location readouts and the orbit indicator +have a dedicated component with explicit navigation actions. A separate lookup +controller cancels superseded searches and checks camera authority before flight +and result presentation. Existing search providers and camera handoff policy are +preserved. Replacing or closing a POI row cancels its pending expansion frame; +destruction releases listeners, pending searches and the orbit indicator. + + +## Layer panel ownership + +Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners +are owned by a renderer-free panel component. The layer manager supplies current +snapshots, lifecycle actions and row descriptors. Remount and teardown remove +listeners and row subscriptions; obsolete completions do not repaint old rows. +The clear control presents busy state while its existing action owns the transaction. + + +## Map Source control ownership + +Map Source controls own chip listeners, source-state subscriptions and selection +feedback. Loading remains with the supplied map controller. The active chip +follows the source actually displayed, including fallback; obsolete completions +cannot overwrite a newer selection. Refresh and destruction revoke old listeners +and destruction suppresses late UI updates. Available choices and setup behavior +remain unchanged. + +## Visual effects ownership + +VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom +and sharpen. Display supplies actions and renders settings; effect execution has +no DOM dependency. Existing presets and the 500 ms transition remain unchanged. +Stopping the controller revokes animation before asynchronous UI teardown; final +destruction removes its stages and restores the borrowed bloom configuration. + +## Display control ownership + +Display button, selector and slider listeners have a single destroyable owner. +The application supplies actions and retains effect settings, restore claims and +rendering. Native keyboard editing, model modes and current defaults are preserved. +Destroying the UI removes these listeners before asynchronous teardown. + +## Application shortcuts and shader parameter controls + +`ui/input` supplies the bubbling application shortcut listener and generated +shader-parameter rows. Number/style keys, H/O/V/F/D/C actions, native form-control +typing and Escape behavior retain their existing mappings. Capture-phase +surfaces continue to arbitrate their own keyboard events first. + +The UI facade retains shader values, share-restore authority, render requests, +search dismissal, visibility and Cockpit portal policy. Parameter rows preserve +labels, bounds, steps and precision. Rebuilding rows removes their previous +listeners; disposal removes shortcuts and parameter listeners synchronously +before asynchronous application teardown. + +## Adaptive panel rail layout + +`ui/layout` supplies the left/right rail layout passes, natural-height +measurement and pure corridor/allocation helpers. The UI facade passes live DOM +nodes, obstacle nodes, HUD presentation, preferred panels and callbacks. It +retains observers, frame scheduling, saved/share preferences and Cockpit portals. + +The left rail keeps its measured collapsed heights and obstacle-safe corridor; +the right rail follows its top baseline and preserves Display's scroll owner. +Automatic collapse remains presentation only, prioritizes the latest explicit +panel, and honors keyboard focus on the right. Mobile layout still releases +desktop allocation styles. Stable Display allocation avoids unnecessary style +writes. This extraction does not change panel positions or layout defaults. + + +## Surface keyboard lifecycle + +`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return +focus shared by the first-run launcher and Provider Settings. Each caller +activates it while open and deactivates it on dismissal; destruction releases +keyboard ownership without moving focus. Reopening captures the current opener. +The launcher retains its hit-test/exclusive-surface arbitration and dismissal +preferences. Provider Settings retains its existing visibility and save policy. +Initial focus, transitions and DOM content remain with each screen. This +component does not add modal semantics or make the map inert. + + +## Panel disclosure lifecycle + +Panel collapse buttons, nested Escape handling and dock hover/focus timing now +use `ui/panels`. The component receives existing DOM elements and callbacks for +state changes and content focus. Panel layout, saved state, share restoration, +Location draft cleanup and Map Source selection remain with their existing +callers. Listener and timer cleanup is synchronous when controls are replaced +or disposed, preventing old hover or focus work from changing a later view. + + +## Military feed cooldown and loading guidance + +The adsb.lol military proxy reuses its last response during upstream 429/5xx +failures and observes Retry-After, bounded to 5–120 seconds (defaults: 30 seconds +for rate limits, 15 seconds for server errors). A failure with no cached data +still reports an error. Cached responses carry their age; the browser preserves +observation timestamps and marks fallback data stale instead of inventing fresh +positions or reporting a failed load. Fresh responses clear that stale state. + +Mapped-installation zoom guidance appears in the layer row without counting as +a global loading failure. Guidance statuses do not suppress independent refresh +errors. + + +## Places and CCTV request bounds + +With a Google key configured, nearby and text search reject missing, blank, +non-numeric and out-of-range coordinates before the opt-in limiter and upstream +request. Text search also requires a nonblank query. Keyless requests retain +their `configured: false` response. + +CCTV media waits at most 15 seconds for upstream response headers and returns +504 on timeout. Its timer stops when headers arrive, so live bodies can continue +streaming; body idle deadlines are separate from this header deadline. Error +responses are cancelled. Buffered snapshots have a 16 MiB streaming cap; an +oversized image remains an upstream miss and uses the normal fallback chain. +The existing declared media size ceiling remains 64 MiB. + + +## GBFS upstream bounds + +GBFS refuses upstream redirects and enforces its 5 MiB response cap while +streaming. The 12-second deadline includes reading the body, and rejected or +stalled downloads are cancelled. Development and preview use the same handler. + + +## Remaining local service modules + +Overpass query validation, geometry simplification, disk caching and upstream +transport now have separate modules; military-installation search reuses that +transport. Regional briefing combines separate place, news and weather sources, +while weather effects uses only the weather source. Existing process-scoped +caches, rate limits, stale fallbacks and route ordering are preserved. + +Local voice has separate HUD-summary, debug-log and Realtime-token handlers, +with tool definitions and instructions in dedicated files. The factory accepts +an optional `annotationGuidance` paragraph; its default instructions and all 28 +tool definitions remain unchanged. `sourceRoot` resolves debug logs against the +application directory. Standalone key setup accepts the same directory option +for its environment store and preserves boot provenance, loopback/origin guards, +atomic credential writes and development-only registration. + +Node-only package entries expose Overpass, military installations, regional +services, local voice and standalone key setup. Importing them starts no network +acquisition. Browser layer lifecycle, rendering and voice execution stay in their +existing modules. + +## Local build preview + +After `npm run build`, `npm run preview` serves the built app with the same data +provider routes as development, including aircraft, satellites, terrain, traffic, +FIRMS, GBFS, Overpass and CCTV/media. Unmatched `/api` requests return a JSON 404 +in both modes instead of the application HTML. Browser routes retain SPA fallback. +Credential editing (`/api/setup/*` and Provider Settings) is development-only; +preview returns JSON 404 for those endpoints. Server credentials come from the +local environment; browser keys are captured at build time. Rebuild after changing +a browser key. Preview is for local build verification, not a production server. + + +## CCTV and radio provider modules + +CCTV catalog acquisition, source normalization and frame/media delivery now live +in separate modules. Each CCTV factory owns its catalog and health state; its +`sourceRoot` option resolves relative source files against the application root. +Radio Browser station normalization, restricted outbound transport and directory +caching are separate modules. Node-only package entries expose both provider +factories. Routes, payloads, fallback behavior and existing dev/preview hook +registration are preserved; browser rendering is unchanged. + +## Terrain, traffic, fire and bike-share provider modules + +Local composition now imports separate Node modules for Re:Earth heights, +TomTom flow tiles, NASA FIRMS detections and GBFS station feeds. Existing routes, +plugin order, server-key selection, validation, disk caches, budgets, retries +and stale/error responses remain unchanged. Each has a Node-only package entry +under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile +math and GBFS source rules are available under `gods-eye-view/sources/`. +The browser layers and their rendering remain in their existing modules. + +## Landmark annotation identity + +When a landmark geocode contains only address components, annotations retain +its requested name for outline matching. A city or neighborhood address no +longer replaces the landmark's identity. Without a canonical feature name, +outline candidates must contain the geocoded anchor or closely match the +requested name; otherwise the annotation stays at its geocoded point. +Genuine feature-name components and existing administrative/monument matching +retain their established behavior. + +## Satellite and launch provider modules + +`server/providers/space/` owns the CelesTrak TLE and Launch Library 2 Node +proxies. Their routes, six-hour/15-minute caches, disk storage, stale fallback, +request coalescing and optional LL2 server token retain existing behavior. +The Node-only `gods-eye-view/server/providers/space` export supplies factories; +`sources/space` supplies fixed upstream URL builders with no I/O or environment +access. Callers retain validation, transport and response policy. + +## Build configuration and local provider boundaries + +`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads +this checkout's environment and constructs the local providers in their existing +order. `server/providers/local.js` is the composition and compatibility entry; +provider families own their middleware and process state in focused modules. +Provider URLs, key selection, cache behavior, setup restrictions and routes are +unchanged. + +`gods-eye-view/build/vite` is a Node-only export for explicit browser build +settings: Cesium assets, caller-supplied plugins, browser key defines, server +binding and document/credential protections. It never reads an environment file +or constructs providers. The standalone caller owns those choices. + +Browser startup now lives in `src/standalone/`. That directory contains only +browser code; Node configuration remains under `server/`. Application lifecycle +and viewer export paths are unchanged. + +## Application startup and shutdown + +The standalone entry now composes scene setup, controls, layer registration and +tools through the reusable application lifecycle. Map defaults, layer order, +share restoration, voice setup and the running debug handle retain their behavior. +The welcome card still waits for restoration and the loading-cover transition. + +Startup failure cleans up acquired resources. Explicit application destruction +aborts construction, cancels pending playback/annotations and delayed welcome UI, +then releases controls, layers and the viewer. Destruction is terminal; the +standalone page must be reloaded to start again. The exported lifecycle and viewer +helpers do not import the standalone entry or discover configuration. See +[application construction](APPLICATION.md). + +## Scoped formatting and package checks + +`npm run format` and `npm run format:check` operate on the explicit adopted-file +list. `npm run check:boundaries` checks the dependency graph of all +current package exports in their declared browser or Node runtime; infrastructure owns its three implementation modules +and takes Cesium from the consumer. CI runs both checks on Linux and Windows. +The standalone app, layer behavior and public export paths remain unchanged. +See [component ownership and adoption](CODE-BOUNDARIES.md). + +## Google browser and server keys + +Local Places nearby/text search and the CCTV Street View fallback prefer +`GOOGLE_MAPS_SERVER_API_KEY`, falling back to `GOOGLE_MAPS_API_KEY` when the +server key is blank or absent. Only the browser key is injected into client +code. POWER UP presents one Google Maps entry for the browser key. The optional +server key is configured manually in the same ignored root `.env`, or Pinokio's +ignored `pinokio/ENVIRONMENT`; it is omitted from Provider Settings and its +missing-key count. Existing server keys and the single-key fallback remain +supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. +The Street View headings tool uses the same server-first selection after +resolving environment overrides per variable; its explicit `--key` wins. + + +## Infrastructure marker visibility + +Datacenter/dam registration uses fresh reusable factories with the application's +existing context, overlay and render functions. The scoped package exports do +not import standalone application globals; see +[the infrastructure interface](INFRASTRUCTURE-LAYERS.md) for lifecycle and asset +requirements. + +Local GeoJSON layers coalesce concurrent enables into one load. Disabling while +loading keeps the result hidden; destruction aborts the fetch and rejects late +parse/add results. Destruction and failed setup remove owned context records, +so replacing a layer cannot retain stale entities or listeners. + +Datacenters and dams retain their full datasets while limiting active marker +stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km, +and 420 below 200 km. Selection favors existing label priority and nearby +features, retains stable choices across small camera movements, and refreshes +during continuous motion. Disable/re-enable resets selection cleanly. +Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. +Unchanged overlay entries do not request another frame; moved positions, +changed membership and re-enabling still publish. Ground sampling waits for +visible globe tiles to settle and uses valid loaded terrain as a floor while +retaining rooftop and below-sea-level elevations. A hidden globe does not +constrain photoreal geometry, and retry work remains bounded. Already sampled +nearby markers follow higher settled terrain on existing bounded walks, without +additional GPU samples or timers, when close-up tile detail refines the floor. +The submarine-cable renderer is unchanged. These limits bound active stem work; +they do not reduce the materialized entity count or establish an FPS gain. + +## Keyboard interaction and focus + +- Enter on the map-source disclosure opens immediately. A short Space press + opens it on key release. Either route focuses the selected source once visible; + a bounded retry handles delayed opening transitions, and closing the tray or + moving focus elsewhere cancels the handoff. +- Keyboard focus rings are a global interface state and stay visible when a + button is active or selected. Visual Styles, Location suboptions, Context and + mission actions, Cockpit utilities, native fields, and sliders use the same + visible-ring contract. +- A short Space press on a focused control keeps its native key-release action. + If it remains held for 500 ms, focus is checked again, the control is blurred, + and push-to-talk starts; releasing a claimed hold cannot activate the old + control. The same hold works on the map and page background. Text-entry + controls remain protected. +- The Location disclosure is reachable with Tab or Shift+Tab and shows a + keyboard focus ring. Its city, point-of-interest, search-toggle, and search + field controls show inset rings, including selected items. Enter toggles its + tray immediately, while Space does so on release; either route makes the revealed controls immediately reachable by Tab; + Escape closes it and clears any unfinished search. Escape from a tray control + returns focus to the disclosure; Escape on the disclosure clears focus after closing. +- Data Layers ON/OFF buttons show a visible keyboard focus ring without + changing their enabled state or feed-status presentation. +- Display controls and shader-parameter sliders show keyboard focus in both + the map panel and Cockpit Display. Arrow keys retain native range adjustment. + The enabled CCTV camera dropdown shows keyboard focus as well. +- Tab reaches both Contacts and Space Missions in sequence in every Context + state. Left/Right arrows also switch them; the focused tab has a distinct ring + even when selected. +- Keyboard focus on a Space Missions roster item uses the same temporary globe + rotation and mission-marker highlight as pointer hover. Focus alone does not + select the mission; Enter or Space performs selection. Keyboard and pointer + preview ownership remain independent when the pointer is parked over the list. +- Radio power controls in the full, compact, and Cockpit surfaces, Search Nearby + Sites, and Clear Selected Layers remain focusable while lifecycle work is in + progress. They announce busy/disabled semantics and ignore repeat activation + until the operation settles, so async work cannot drop the keyboard ring. +- Normal-mode Contacts results preserve the focused action or contact by stable + identity while live counts, distance order, and pages refresh. If that contact + departs or rotates off the visible page, focus moves to a stable continuation + point on the named explanatory note at the end of the list and remains there + through later repaints. The following Tab proceeds beyond the results instead + of restarting at Contacts or silently focusing another contact. +- Cockpit Live Signals updates existing contact buttons without replacing or + disconnecting the focused one. Tab can continue through the briefing footer + to Display and Radio. If the focused contact leaves the list, focus moves + once to the current briefing tab; later refreshes do not reclaim it. +- Cockpit-only Display and Radio launcher icons show a complete inset keyboard + ring in their collapsed and expanded states. +- Escape collapses the nearest expanded panel containing focus before any + containing panel acts. Closing from panel content returns focus to that panel's + disclosure; closing from the disclosure itself clears focus so the collapsed + button does not retain its ring. This includes standard panels, nested + Parameters, Cockpit Contact and Live Signals, and Cockpit Display/Radio utilities. +- The required bottom-left Data attribution control is a named popup button in + the Tab order. Enter opens immediately and Space opens on release, then the + credit lightbox focuses Close; + Close, Escape, or backdrop dismissal restores focus to Data attribution. + +## CCTV launcher and proxy failure responses + +`scripts/dev-cctv.sh` delegates startup to `scripts/dev-fresh.sh`. It retains +its Austin source file, Austin preference, 36-camera Austin limit, and 48-camera +total limit, with environment overrides. Keys are optional; credential loading +and names-only provider provenance follow the normal launcher. The default +binding is localhost. An explicit `HOST=0.0.0.0` uses the same LAN warning as +normal startup. + +The CelesTrak, Launch Library, terrain-height, and ADSBDB middleware return +fixed messages for unexpected failures. Launch Library retains its upstream +HTTP failure status and no-store policy but does not forward the upstream body. +Existing successful, in-flight, missing-data, and stale-cache behavior remains +in place. Failure diagnostics identify the service and, for Launch Library, +HTTP status without printing raw exception text or upstream bodies. + +## Map Source keyboard focus + +Keyboard opening focuses the selected map-source tile, falling back to the first +only when no source is selected. A bounded retry handles delayed tray visibility. +Moving focus away, pointer interaction, closing the tray, or disposing the UI +cancels pending work; a later reopening cannot inherit an old focus request. + +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + +Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. + Updated: August 24, 2026 +## Aircraft and vessel server modules + +Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky +state vectors and regional fallback, adsb.lol military positions, ADSBDB +enrichment and track backfill. Vessel routes and websocket/watchdog setup live +in `server/providers/vessels/ais-live.js`; AIS records and recent tracks live +in `ais-store.js`. Common response caps, request coalescing and query parsing +have their own modules. `server/providers/local.js` composes these with the +remaining providers and retains existing named compatibility exports. + +`gods-eye-view/server/providers/live` is a Node-only entry for the existing +plugins and shared request helpers. Importing it starts no sockets or timers. +The existing aircraft normalizer is separately available through the portable +`gods-eye-view/sources/adsb-lol` export. Provider URLs, local credentials, cache +policy, fallback behavior, response shapes and rendering remain unchanged. + + +## Control names for assistive technology + +Scope, Bloom, Sharpen and location search have explicit accessible names. +Generated style sliders use the same name as their visible parameter label. +The first-run suppression checkbox keeps its native wrapping label, so its +accessible name remains "Don't show this again". Control behavior is unchanged. + +## FIRMS source status + +The FIRMS proxy records source success after appending its rows. If aggregation +throws, that source reports failure without a contradictory success entry. +The existing per-record append continues to support large feeds; sequential +fetching, trailing-24-hour filtering and partial-success caching are unchanged. + ## Installations and map-source guidance - On an uncached Overpass failure, mapped installations keep their existing @@ -748,7 +1410,7 @@ This is the current runtime/source-of-truth snapshot for the project. > corridor. Layer toggles stay live from there, and collapsing returns the > plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden > for the duration of Cockpit, both as a group and as individual controls. -> It uses the `radar` symbol and provides roving keyboard tab navigation. Its action row +> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row > places the single Cockpit entry before Search Nearby Sites. Cockpit removes > the duplicate floating map entry and topline exit; the bottom-center > `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus @@ -934,12 +1596,20 @@ This is the current runtime/source-of-truth snapshot for the project. > settled dim contact always completes its release after tracking ends. > - **Terrain-height resilience:** `/api/terrain/heights` caches canonical > 5-decimal points individually, reconstructs reordered/overlapping batches -> in exact request order, and refreshes only missing or stale points. Network, -> 429, and 5xx failures receive bounded jittered retries with `Retry-After`; -> stale real heights remain usable per point, while an uncached absent height -> still returns 502 rather than becoming a fabricated ground value. Client -> geoid fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on -> the first later successful fetch. +> in exact request order, and refreshes only missing or stale points. Upstream +> calls and browser requests are both chunked at 64 points, sized to fit +> their 30s deadlines across Re:Earth's observed 87-186 ms/point range; a chunk +> that still fails contributes nulls for its own positions instead of +> discarding the chunks that resolved. Network, 429, and 5xx failures receive +> bounded jittered retries with `Retry-After`; stale real heights remain +> usable per point, while an uncached absent height still returns 502 rather +> than becoming a fabricated ground value. A position the upstream answers +> with a null ellipsoid is reported as an absent height rather than a refresh +> failure, and is left uncached so a later poll re-asks it. Client geoid +> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the +> first later successful fetch. Smaller chunks reduce timeout risk; complete +> camera batches still wait for their sequential requests, and unresolved +> placement continues to use the existing prior until real heights arrive. > - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests, > then checks fresh memory, identical in-flight work, and fresh disk entries > before invoking its local 90/min limiter. Cache and single-flight responses @@ -1618,7 +2288,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | -| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | @@ -1684,9 +2354,11 @@ preserves object identity for an idempotent repeat of the same generation, and degrades without replacement if a fresh response presents an older generation. Snapshot records mark community metadata as untrusted, and Radio tool results omit station names so directory text never becomes model instruction context. -One bounded country parser maps recognized ISO codes and English/common names -through proxy metadata and final station selection, while malformed, non-ISO, -control-containing, and oversized inputs fail closed. Literal or resolved +One bounded country parser maps recognized ISO codes and English/common names — +including widely used exonyms the Intl display label omits (Turkey, Holland, +Burma, and similar) — through proxy metadata and final station selection, while +malformed, non-ISO, control-containing, ambiguous, and oversized inputs fail +closed. Literal or resolved non-global IPv4/IPv6 targets are refused. Destroy fully releases the Radio audio session, voice ducking/restoration, request state, filter, selection, volume, accepted snapshot, and feed telemetry before re-initialization; monotonic @@ -1967,7 +2639,16 @@ silently demoting every later lookup for the session. default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of 1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego, Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default - sources — ~800 cameras total, all RAW PRIOR poses, stills-first. + sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes + (cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR + poses, and the layer is stills-first. + sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`, + kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list + covering the whole country, where one *preset* (a station's fixed view) is one camera — 806 + GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors + on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin + across packs, so a lower global cap thins every region instead of starving the last pack. + ~1,100 cameras total, all RAW PRIOR poses, stills-first. - **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field validated 2026-07-21): the COVERAGE toggle is a tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum @@ -2000,7 +2681,8 @@ silently demoting every later lookup for the session. 2026-08-02): the LOD-selected nearby static cameras (20/28/40 by zoom, `cctvLod.js`) get **screen-space thumbnail cards** through the shared world-overlay host showing paced static frames — reselection on `camera.moveEnd` only, at most one frame fetch - per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min). Zero-flicker: + per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min, Fintraffic 10 min + — its stations publish on a 600 s collection interval). Zero-flicker: a card renders nothing until its first frame, a drawn frame persists through failed fetches, and eviction grace (2-pass/5 s) stops budget-edge churn. Camera icons stay visible at every zoom. Eligible candidates are filtered to in-view stills with valid IDs, @@ -2200,6 +2882,29 @@ silently demoting every later lookup for the session. - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. - Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +### Place-search providers + +Location search/fly-to, annotations and Radio location lookup receive one +`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes +Google first when configured and Photon/OpenStreetMap as fallback, including +Google transport failures or declined requests. The portable `./search` export +provides the service and adapters; it reads no environment or application state. +Existing browser/server key setup is unchanged. + +Providers normalize coordinates, canonical name, label, place types and optional +bounds. Camera framing, nearby landmark recovery and footprint selection remain +in their consumers, including the Capitol identity/containment safeguards. +Radio keeps localized country names in labels rather than station filters. +Reverse geocoding and nearby/text-search endpoints retain their existing behavior. + +Only valid answers and definitive misses enter bounded caches; malformed replies, +HTTP refusals and outages remain retryable. Searches share a 12-second total +deadline, with Photon requests capped at six seconds each. Caller/application +cancellation stops retries and late cache writes. Replacing a location search +cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five +candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds +are omitted rather than framing the wrong part of the globe. + ### Map Stack Switcher (June 2026) - `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`. @@ -2298,7 +3003,7 @@ silently demoting every later lookup for the session. eight-second timeout; the timer is cleared on every success or failure path. - OpenSky response cache stores successful upstream responses only; OAuth token refresh calls are coalesced. - A cold OpenSky failure uses the current camera subpoint only to request a cached adsb.lol point fallback capped at 250 nm. A fresh OpenSky response or last-good cache wins; a nominally successful worldwide snapshot more than two minutes old prefers viewport-scoped adsb.lol when available, otherwise the stale source is reported honestly. The fallback is visibly source-labeled and is never presented as a worldwide snapshot. -- GBFS response size is capped; CCTV health map is bounded. +- GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded. - Proxy error payloads are sanitized (no internal error details returned to clients). - `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`. - `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache. @@ -2611,3 +3316,71 @@ Replay transport uses one Play/Pause toggle plus Cancel. During ascent only the ## Maintenance Rule When runtime behavior or architecture changes, update this file in the same change set as code updates. + +## Dependency security baseline + +The lockfile uses DOMPurify 3.4.15, protobufjs 8.8.0, PostCSS 8.5.28, and +nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0; +image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous +executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain +24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree. + +### Traffic city navigation + +Traffic checks the final camera view on arrival and retries a failed road request +while the camera is stationary, backing off from 1.5 to 30 seconds. Failed road +requests report an unavailable source. Leaving the traffic altitude range or +disabling the layer cancels pending work; superseded road and flow requests cannot +release the current request or keep its loading indicator active. + +### Optional frame-rate readout + +Backtick (`) toggles an FPS readout beneath the title logo. It counts actual +Cesium post-render events over one-second windows and does not request extra +frames. Typing fields, modified keys and key repeats do not toggle it. The +readout starts hidden each session and releases its timer and frame listener +when hidden or when the application is disposed. + + +## Radio components + +The radio entry composes one layer from directory ingestion, station queries, +selection, globe rendering and playback modules. Its source supplies directory +metadata and click reporting; scene, ground and overlay services are provided +explicitly. Each constructed layer owns its catalog, audio and lifecycle state. +Existing catalog validation, category filters, tuning and voice playback behavior +remain unchanged. Audio connects directly to the broadcaster after an explicit +play action; the source does not relay or record streams. + +## Bundled geography and submarine cable components + +Submarine cables use separate source, geometry, rendering, interaction and +lifecycle modules. The layer factory accepts cable and landing-point GeoJSON +collections from a source with `fetch(signal)` and a display label. The default +source loads the same bundled TeleGeography files. Disabling still removes all +three Cesium data sources; enabling rebuilds from the accepted parsed cache, +and destroying clears it. Load ownership prevents cancelled work from adding +entities after teardown. + +Natural Earth regions and neighborhood polygon lookup are package exports. +Their existing lazy loaders, retry behavior, bundled datasets and attribution +are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by +the project's MIT license; see `DATA_SOURCES.md`. + +## Map source factories and coordination + +Map selection is composed from separate imagery, terrain and 3D factories. +The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and +OSM, including their setup guidance and existing preset/share IDs. Esri still +falls back to OSM on construction failure or two active tile failures, and +credits follow the source actually displayed. Terrain remains lazy while the +photoreal globe is hidden. + +Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. + +The controller accepts other source registries without adding provider branches. +Each instance caches source construction, ignores superseded scene changes and +releases imagery layers/listeners on replacement. Destroy invalidates pending +work and releases owned resources, including late factory results. Supplied 3D +tilesets remain owned by the caller; tilesets created through the controller's +factory are added to its viewer and removed on destruction. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md new file mode 100644 index 000000000..d29e356f9 --- /dev/null +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -0,0 +1,62 @@ +# Reusing infrastructure layers + +`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`. +It returns fresh datacenter and dam layers in that order, with the existing IDs, +appearance, label budgets and bundled public datasets. Importing the module or +calling the factory does not load data, create a viewer, or start the application. +The asset URLs resolve relative to the module; Vite rewrites them for production +base paths. This browser source export expects a Vite-compatible asset build. + +Pass the application's existing functions: + +```js +import { createInfrastructureLayers } from 'gods-eye-view/infrastructure'; + +const layers = createInfrastructureLayers({ + overlayHost: { setEntries, setVisible, clearSource }, + registerEntityContext, + selectEntityContext, + clearSelectedEntityContextForLayer, + removeEntityContextsForLayer, + governorRequestRender, +}); + +// Register these layers with the existing data-layer manager and viewer. +``` + +| Function | Arguments / responsibility | +| --- | --- | +| `overlayHost.setEntries` | `(sourceId, entries, options)`; publish the existing infrastructure overlay records and paint budget | +| `overlayHost.setVisible` | `(sourceId, visible)`; toggle a source | +| `overlayHost.clearSource` | `(sourceId)`; remove that source's overlay entries | +| `registerEntityContext` | `(entity, metadata)`; register selectable feature metadata, including the owning layer and data source | +| `selectEntityContext` | `(entity)`; select that registered feature | +| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer | +| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup | +| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | + +Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, +`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`. +Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay +host permits one live instance per layer ID; destroy an old instance before +replacing it. Separate applications may use identical IDs with separate hosts. +Callbacks must be ready before enable and remain usable through destruction. + +Disable retains the loaded dataset for reuse and clears selection and listeners. +Concurrent enables share one load. Destroy is permanent, aborts pending fetches, +discards late results, and clears the owned data source, context records, overlays, +input handler and listeners. The layer never creates an application context store, +overlay host, viewer, or render scheduler. The consumer must resolve one compatible +Cesium installation shared with its viewer; it must not bundle separate copies. + +`gods-eye-view/infrastructure/geojson` exports the lower-level +`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay +helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. +The standalone app's `src/data/localGeojson.js` keeps its original single-argument +factory and supplies its existing functions. Other consumers should use the package +exports, which do not import standalone application globals. + +Dataset files and source/license notices remain under `src/data/local_data/`. +Their input counts are 4,351 datacenter features and 704 dam features. Cesium may +expand multipart geometries into multiple entities; entity and stem counts are +different measurements. This change does not refresh or relicense the datasets. diff --git a/index.html b/index.html index e8b0b8ddd..87fb168bc 100644 --- a/index.html +++ b/index.html @@ -440,7 +440,7 @@ mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather softens the black mask edge; label fading is the separate Fade slider. --> - + 11% @@ -460,7 +460,7 @@ Bloom
- + 0%
@@ -470,7 +470,7 @@ Sharpen
- + 49%
@@ -548,14 +548,16 @@
- LOCATION +
📍 Location: --
Landmark: --
-
+
@@ -565,7 +567,7 @@ - +
@@ -727,7 +729,7 @@ CONTACTS - @@ -765,6 +767,7 @@
LOADING 30-DAY MISSION INDEX
+

TAB PREVIEWS · ENTER / SPACE SELECTS

diff --git a/package-lock.json b/package-lock.json index 06b972e7e..c24590e4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,9 @@ "satellite.js": "^6.0.2" }, "devDependencies": { - "puppeteer": "^24.37.5", - "sharp": "^0.34.5", + "prettier": "3.9.6", + "puppeteer": "^25.10.0", + "sharp": "^0.35.4", "vite": "^6.0.0", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" @@ -27,31 +28,6 @@ "node": ">=24.14.0 <25 || >=26 <27" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@cesium/engine": { "version": "22.3.0", "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-22.3.0.tgz", @@ -104,9 +80,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -557,9 +533,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { @@ -567,9 +543,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -580,19 +556,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -603,19 +579,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -630,9 +626,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -647,9 +643,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -664,9 +660,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -681,9 +677,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -698,9 +694,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -715,9 +711,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -732,9 +728,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -749,9 +745,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -766,9 +762,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -783,9 +779,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -796,19 +792,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -819,19 +815,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -842,19 +838,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -865,19 +861,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -888,19 +884,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -911,19 +907,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -934,19 +930,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -957,39 +953,56 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1000,16 +1013,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1020,16 +1033,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1040,7 +1053,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1064,41 +1077,30 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" }, "bin": { - "browsers": "lib/cjs/main-cli.js" + "browsers": "lib/main-cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "node": ">=22.12.0" }, - "engines": { - "node": ">=6.0" + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" }, "peerDependenciesMeta": { - "supports-color": { + "proxy-agent": { + "optional": true + }, + "yauzl": { "optional": true } } @@ -1490,13 +1492,6 @@ "pnpm": ">=8" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@tweenjs/tween.js": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", @@ -1516,17 +1511,6 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.2.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", - "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1534,17 +1518,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@zip.js/zip.js": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.18.tgz", @@ -1559,62 +1532,32 @@ "node": ">=18.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -1637,155 +1580,12 @@ "pnpm": ">=10.10.0" } }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bitmap-sdf": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", "license": "MIT" }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/cesium": { "version": "1.138.0", "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.138.0.tgz", @@ -1805,107 +1605,61 @@ } }, "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, "peerDependencies": { "devtools-protocol": "*" } }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1923,21 +1677,6 @@ "dev": true, "license": "MIT" }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1970,16 +1709,16 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1566079", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", - "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2018,9 +1757,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -2034,36 +1773,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2123,52 +1832,6 @@ "dev": true, "license": "MIT" }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -2176,16 +1839,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2196,72 +1849,6 @@ "node": ">= 0.6" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2331,55 +1918,19 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2414,87 +1965,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2502,33 +1972,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -2539,36 +1982,6 @@ "@types/estree": "*" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -2578,13 +1991,6 @@ "node": ">= 10.16.0" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -2616,12 +2022,18 @@ "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", "license": "Apache-2.0" }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } }, "node_modules/long": { "version": "5.3.2", @@ -2629,16 +2041,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -2687,6 +2089,16 @@ "dev": true, "license": "MIT" }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2695,9 +2107,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -2713,16 +2125,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/nosleep.js": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", @@ -2742,106 +2144,12 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2864,13 +2172,6 @@ "pbf": "bin/pbf" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2892,9 +2193,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -2912,7 +2213,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2920,20 +2221,26 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, "engines": { - "node": ">=0.4.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/protobufjs": { - "version": "8.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.4.tgz", - "integrity": "sha512-/+XMv9JalknuncEJSwsyEVlwcxVLKx2iaoSUXFZA86MJkdqyOdfrlB1sB7S6aKyUk9tl20YY+SgQe5J2sJHTcg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", "license": "BSD-3-Clause", "dependencies": { "long": "^5.3.2" @@ -2948,119 +2255,44 @@ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/puppeteer": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", - "integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", + "integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.5", - "typed-query-selector": "^2.12.0" + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.10.0", + "typed-query-selector": "^2.12.2" }, "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" + "puppeteer": "lib/puppeteer/node/cli.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/puppeteer-core": { - "version": "24.37.5", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", - "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1566079", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.19.0" + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" }, "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=22.12.0" } }, "node_modules/quickselect": { @@ -3088,26 +2320,6 @@ "quickselect": "^3.0.0" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", @@ -3175,9 +2387,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3236,120 +2448,55 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/socks-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { - "supports-color": { + "@types/node": { "optional": true } } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3378,92 +2525,37 @@ "node": ">= 0.8" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" + "node": ">=12" }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "streamx": "^2.12.5" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/tinyglobby": { @@ -3514,20 +2606,12 @@ "license": "0BSD" }, "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", "dev": true, "license": "MIT" }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -3675,41 +2759,52 @@ "license": "Apache-2.0" }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", "dev": true, "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { @@ -3739,43 +2834,41 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "funding": { + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index d9772860d..07297242a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "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.", + "description": "A real-time intelligence console for planet Earth \u2014 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", @@ -33,6 +33,8 @@ ], "scripts": { "doctor": "node scripts/setup-doctor.mjs", + "format": "node scripts/format.mjs --write", + "format:check": "node scripts/format.mjs --check", "dev": "vite", "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", @@ -40,7 +42,8 @@ "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" + "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", + "check:boundaries": "node scripts/check-package-boundaries.mjs" }, "dependencies": { "@mapbox/vector-tile": "^3.0.0", @@ -51,10 +54,119 @@ "satellite.js": "^6.0.2" }, "devDependencies": { - "puppeteer": "^24.37.5", - "sharp": "^0.34.5", + "prettier": "3.9.6", + "puppeteer": "^25.10.0", + "sharp": "^0.35.4", "vite": "^6.0.0", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" + }, + "exports": { + "./infrastructure": "./src/data/infrastructure.js", + "./infrastructure/geojson": "./src/data/localGeojsonCore.js", + "./infrastructure/lod": "./src/data/localGeojsonLod.js", + "./application": "./src/app/application.js", + "./application/viewer": "./src/app/viewer.js", + "./build/vite": { + "node": "./build/vite.js" + }, + "./server/providers/live": { + "node": "./server/providers/live.js" + }, + "./sources/adsb-lol": "./src/data/adsbLolFallback.js", + "./server/providers/places": { + "node": "./server/providers/places.js" + }, + "./sources/places": "./src/data/placeProviderPayloads.js", + "./server/providers/space": { + "node": "./server/providers/space.js" + }, + "./sources/space": "./src/data/spaceProviderRequests.js", + "./server/providers/terrain": { + "node": "./server/providers/terrain.js" + }, + "./sources/terrain": "./src/data/terrainHeightsProxy.js", + "./server/providers/traffic": { + "node": "./server/providers/traffic.js" + }, + "./sources/traffic": "./src/data/tomtomTiles.js", + "./server/providers/firms": { + "node": "./server/providers/firms.js" + }, + "./server/providers/gbfs": { + "node": "./server/providers/gbfs.js" + }, + "./sources/gbfs": "./src/data/gbfsSource.js", + "./server/providers/cctv": { + "node": "./server/providers/cctv.js" + }, + "./server/providers/radio": { + "node": "./server/providers/radio.js" + }, + "./server/providers/overpass": { + "node": "./server/providers/overpass.js" + }, + "./server/providers/military-installations": { + "node": "./server/providers/military-installations.js" + }, + "./server/providers/regional": { + "node": "./server/providers/regional.js" + }, + "./server/providers/openai": { + "node": "./server/providers/openai.js" + }, + "./server/standalone/key-setup": { + "node": "./server/standalone/key-setup.js" + }, + "./search": "./src/search/index.js", + "./ui/panels": "./src/ui/panelDisclosure.js", + "./ui/surfaces": "./src/ui/surfaceKeyboard.js", + "./ui/layout": "./src/ui/panelRails.js", + "./ui/input": "./src/ui/visualInput.js", + "./ui/display": "./src/ui/displayControls.js", + "./ui/effects": "./src/ui/effects.js", + "./ui/effects/bloom": "./src/bloom.js", + "./ui/maps": "./src/ui/mapSource.js", + "./ui/layers": "./src/ui/layers.js", + "./ui/layers/feedback": "./src/loadingFeedback.js", + "./ui/location": "./src/ui/location.js", + "./ui/radio": "./src/ui/radio.js", + "./ui/cctv": "./src/ui/cctv.js", + "./ui/context": "./src/ui/context.js", + "./ui/context/policy": "./src/contextModePolicy.js", + "./ui/cockpit": "./src/ui/cockpit.js", + "./ui/cockpit/math": "./src/cockpitMath.js", + "./ui/cockpit/layout": "./src/cockpitUtilityLayout.js", + "./ui/cockpit/vision": "./src/cockpitVisionPolicy.js", + "./ui/scenes": "./src/ui/scenes.js", + "./ui/shell": "./src/ui/applicationShell.js", + "./ui/styles": "./src/ui/styles.js", + "./scenes": "./src/scenes/director.js", + "./sources/live": "./src/sources/live/index.js", + "./layers/flights": "./src/layers/flights/index.js", + "./layers/military": "./src/layers/military/index.js", + "./aircraft": "./src/layers/aircraft/index.js", + "./layers/vessels": "./src/layers/vessels/index.js", + "./layers/earthquakes": "./src/layers/earthquakes/index.js", + "./layers/firms": "./src/layers/firms/index.js", + "./layers/satellites": "./src/layers/satellites/index.js", + "./layers/launches": "./src/layers/launches/index.js", + "./layers/installations": "./src/layers/installations/index.js", + "./layers/awareness": "./src/layers/awareness/index.js", + "./layers/traffic": "./src/layers/traffic/index.js", + "./layers/bikeshare": "./src/layers/bikeshare/index.js", + "./layers/cctv": "./src/layers/cctv/index.js", + "./layers/alpr": "./src/layers/alpr/index.js", + "./layers/radio": "./src/layers/radio/index.js", + "./layers/submarine-cables": "./src/layers/submarineCables/index.js", + "./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js", + "./geography/natural-earth": "./src/data/naturalEarthRegions.js", + "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js", + "./maps/controller": "./src/maps/controller.js", + "./maps/catalog": "./src/maps/catalog.js", + "./maps/imagery": "./src/maps/imagery.js", + "./maps/terrain": "./src/maps/terrain.js", + "./maps/3d": "./src/maps/google3d.js", + "./maps/defaults": "./src/maps/defaultSources.js" } } diff --git a/pinokio/_ENVIRONMENT b/pinokio/_ENVIRONMENT index 0cb4302bb..243f60615 100644 --- a/pinokio/_ENVIRONMENT +++ b/pinokio/_ENVIRONMENT @@ -9,7 +9,9 @@ # 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. +# Browser: 3D tiles and geocoding; server: Places and Street View. # GOOGLE_MAPS_API_KEY= +# GOOGLE_MAPS_SERVER_API_KEY= # CESIUM_ION_TOKEN= # OPENAI_API_KEY= # AISSTREAM_API_KEY= diff --git a/pinokio/install.js b/pinokio/install.js index a774a72a0..3e9b72d7c 100644 --- a/pinokio/install.js +++ b/pinokio/install.js @@ -16,6 +16,7 @@ module.exports = { // child also reads the raw app ENVIRONMENT file because Pinokio removes // blank fields before constructing this merged template environment. env: { + GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', diff --git a/pinokio/start.js b/pinokio/start.js index 21a2deec7..a3ae847d3 100644 --- a/pinokio/start.js +++ b/pinokio/start.js @@ -8,6 +8,7 @@ module.exports = { env: { HOST: '127.0.0.1', PORT: '{{port}}', + GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', diff --git a/pinokio/update.js b/pinokio/update.js index 89a09316c..7d9a9a457 100644 --- a/pinokio/update.js +++ b/pinokio/update.js @@ -7,6 +7,7 @@ module.exports = { // Update reuses the install doctor. The child re-reads raw app // ENVIRONMENT so blank fields override Pinokio-global values too. env: { + GOOGLE_MAPS_SERVER_API_KEY: '{{env.GOOGLE_MAPS_SERVER_API_KEY || ""}}', GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs new file mode 100644 index 000000000..3655cd998 --- /dev/null +++ b/scripts/check-package-boundaries.mjs @@ -0,0 +1,146 @@ +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build, normalizePath } from 'vite'; + +/** Build each browser or Node export group and reject imports outside its declared ownership. */ +export async function checkPackageBoundaries(root) { + root = await realpath(root); + const readJson = async (name) => + JSON.parse(await readFile(path.join(root, name), 'utf8')); + const pkg = await readJson('package.json'); + const groups = await readJson('scripts/package-boundaries.json'); + const declaredExports = Object.keys(pkg.exports || {}).sort(); + const classifiedExports = Object.values(groups) + .flatMap((group) => group.exports) + .sort(); + if ( + !declaredExports.length || + JSON.stringify(declaredExports) !== JSON.stringify(classifiedExports) + ) { + throw new Error( + 'Every package export must belong to exactly one boundary group', + ); + } + const reports = []; + for (const [name, group] of Object.entries(groups)) { + const node = group.runtime === 'node'; + if (group.runtime && !['browser', 'node'].includes(group.runtime)) { + throw new Error(`Invalid boundary runtime: ${name}`); + } + if ( + !Array.isArray(group.modules) || + !group.modules.length || + !Array.isArray(group.external) + ) { + throw new Error(`Invalid package boundary: ${name}`); + } + const allowed = new Set(); + for (const module of group.modules) { + if ( + typeof module !== 'string' || + path.isAbsolute(module) || + module.includes('\\') || + module.split('/').includes('..') + ) { + throw new Error(`Boundary modules must be repository paths: ${name}`); + } + const resolved = await realpath(path.join(root, module)); + const relative = path.relative(root, resolved); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Boundary module escapes repository: ${name}`); + } + allowed.add(normalizePath(resolved)); + } + for (const external of group.external) { + if ( + !Object.hasOwn(pkg.dependencies || {}, external) && + !Object.hasOwn(pkg.peerDependencies || {}, external) && + !(node && Object.hasOwn(pkg.devDependencies || {}, external)) + ) { + throw new Error( + `Boundary external must be a declared dependency for its runtime: ${external}`, + ); + } + } + const input = group.exports.map((key) => { + const declaration = pkg.exports[key]; + const target = node ? declaration?.node : declaration; + if ( + node && + (typeof declaration !== 'object' || + Object.keys(declaration).join() !== 'node') + ) { + throw new Error(`Node export must have only a node condition: ${key}`); + } + if ( + typeof target !== 'string' || + !target.startsWith('./') || + !allowed.has(normalizePath(path.resolve(root, target))) + ) { + throw new Error(`Export must point to an owned module: ${key}`); + } + return path.resolve(root, target); + }); + const seen = new Set(); + await build({ + root, + configFile: false, + envFile: false, + publicDir: false, + ssr: node ? { noExternal: true } : undefined, + logLevel: 'silent', + plugins: [ + { + name: 'check-package-ownership', + moduleParsed(info) { + if (!allowed.has(info.id)) { + throw new Error( + `Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`, + ); + } + seen.add(info.id); + }, + }, + ], + build: { + ssr: node, + lib: { entry: input, formats: ['es'] }, + write: false, + minify: false, + assetsInlineLimit: 0, + rollupOptions: { + input, + external: group.external, + // Unused imports must still obey ownership; tree shaking is not a boundary. + treeshake: false, + preserveEntrySignatures: 'strict', + }, + }, + }); + reports.push({ name, exports: input.length, modules: seen.size }); + } + return reports; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + const reports = await checkPackageBoundaries( + fileURLToPath(new URL('../', import.meta.url)), + ); + for (const report of reports) + console.log( + `Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/dev-cctv.sh b/scripts/dev-cctv.sh index b69d84a14..a1a248203 100755 --- a/scripts/dev-cctv.sh +++ b/scripts/dev-cctv.sh @@ -2,213 +2,12 @@ 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}" +# A lighter CCTV testing preset; startup, credentials, and LAN warnings belong +# to the normal launcher. Explicit environment overrides remain supported. +export CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}" +export CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}" +export CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}" +export 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 +exec bash "$ROOT_DIR/scripts/dev-fresh.sh" diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index 0b019321f..b03e542b9 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -11,9 +11,11 @@ PORT="${PORT:-4173}" 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. +# TfL London JamCams (~870 live upstream), Ontario 511 (~944 live upstream, +# including Kitchener-area highways), and Fintraffic Finland weathercams +# (~2,260 live presets). Caps keep the densest cores per pack; override +# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='', +# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0 and CCTV_FINTRAFFIC_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. @@ -21,7 +23,24 @@ 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}" +CCTV_ONTARIO_ENABLED="${CCTV_ONTARIO_ENABLED:-1}" +CCTV_ONTARIO_MAX_SOURCES="${CCTV_ONTARIO_MAX_SOURCES:-1000}" +CCTV_FINTRAFFIC_ENABLED="${CCTV_FINTRAFFIC_ENABLED:-1}" +CCTV_FINTRAFFIC_MAX_SOURCES="${CCTV_FINTRAFFIC_MAX_SOURCES:-300}" +CCTV_DRIVEBC_ENABLED="${CCTV_DRIVEBC_ENABLED:-1}" +CCTV_DRIVEBC_MAX_SOURCES="${CCTV_DRIVEBC_MAX_SOURCES:-250}" +CCTV_TXDOT_ENABLED="${CCTV_TXDOT_ENABLED:-1}" +# Use `-` so an explicit empty string (the documented kill switch) is preserved. +CCTV_TXDOT_DISTRICTS="${CCTV_TXDOT_DISTRICTS-AUS,SAT}" +CCTV_TXDOT_MAX_SOURCES="${CCTV_TXDOT_MAX_SOURCES:-500}" +CCTV_TALLINN_ENABLED="${CCTV_TALLINN_ENABLED:-1}" +CCTV_TALLINN_MAX_SOURCES="${CCTV_TALLINN_MAX_SOURCES:-255}" +CCTV_TARKTEE_ENABLED="${CCTV_TARKTEE_ENABLED:-1}" +CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}" +CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}" +CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}" +CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}" +CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}" # Capture which provider credentials genuinely came from the parent shell # before this launcher resolves dotenv and Keychain fallbacks. Only names are @@ -30,6 +49,7 @@ CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}" # 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 "${GOOGLE_MAPS_SERVER_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_SERVER_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) @@ -225,8 +245,8 @@ if [[ ! -f "src/data/cctv.js" ]]; then exit 1 fi -if ! grep -q "dataManager.register(cctvLayer)" src/main.js; then - echo "error: CCTV layer not wired in src/main.js" +if ! grep -q "dataManager.register(cctvLayer)" src/standalone/data.js; then + echo "error: CCTV layer not wired in src/standalone/data.js" exit 1 fi @@ -363,7 +383,23 @@ 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 CCTV_ONTARIO_ENABLED "${CCTV_ONTARIO_ENABLED}" +put_env CCTV_ONTARIO_MAX_SOURCES "${CCTV_ONTARIO_MAX_SOURCES}" put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}" +put_env CCTV_FINTRAFFIC_ENABLED "${CCTV_FINTRAFFIC_ENABLED}" +put_env CCTV_FINTRAFFIC_MAX_SOURCES "${CCTV_FINTRAFFIC_MAX_SOURCES}" +put_env CCTV_DRIVEBC_ENABLED "${CCTV_DRIVEBC_ENABLED}" +put_env CCTV_DRIVEBC_MAX_SOURCES "${CCTV_DRIVEBC_MAX_SOURCES}" +put_env CCTV_TXDOT_ENABLED "${CCTV_TXDOT_ENABLED}" +put_env CCTV_TXDOT_DISTRICTS "${CCTV_TXDOT_DISTRICTS}" +put_env CCTV_TXDOT_MAX_SOURCES "${CCTV_TXDOT_MAX_SOURCES}" +put_env CCTV_TALLINN_ENABLED "${CCTV_TALLINN_ENABLED}" +put_env CCTV_TALLINN_MAX_SOURCES "${CCTV_TALLINN_MAX_SOURCES}" +put_env CCTV_TARKTEE_ENABLED "${CCTV_TARKTEE_ENABLED}" +put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}" +put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}" +put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}" +put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}" 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}" diff --git a/scripts/format-scope.json b/scripts/format-scope.json new file mode 100644 index 000000000..f2acaea16 --- /dev/null +++ b/scripts/format-scope.json @@ -0,0 +1,536 @@ +[ + ".prettierrc.json", + "package.json", + "scripts/format-scope.json", + "scripts/package-boundaries.json", + "scripts/format.mjs", + "scripts/check-package-boundaries.mjs", + "scripts/precompute-cctv-heights.mjs", + "src/tooling/precomputeCctvHeights.test.mjs", + "src/tooling/format.test.mjs", + "src/tooling/packageBoundaries.test.mjs", + "src/data/infrastructure.js", + "src/data/infrastructure.test.mjs", + "src/data/localGeojsonCore.js", + "src/data/localGeojsonLod.js", + "docs/CODE-BOUNDARIES.md", + "src/main.js", + "docs/APPLICATION.md", + "scripts/qa-application.mjs", + "src/app/application.js", + "src/app/application.test.mjs", + "src/app/mapLifecycle.test.mjs", + "src/app/startupCamera.test.mjs", + "src/app/viewer.js", + "src/standalone/application.js", + "src/standalone/controls.js", + "src/standalone/data.js", + "src/standalone/errors.js", + "src/standalone/scene.js", + "src/standalone/startupChrome.js", + "src/standalone/startupChrome.test.mjs", + "src/standalone/tools.js", + "build/vite.js", + "server/standalone/vite.config.js", + "vite.config.js", + "src/tooling/viteBuild.test.mjs", + "server/providers/common/http.js", + "server/providers/common/query.js", + "server/providers/aircraft/opensky.js", + "server/providers/aircraft/adsb-lol.js", + "server/providers/aircraft/enrichment.js", + "server/providers/aircraft/tracks.js", + "server/providers/vessels/ais-store.js", + "server/providers/vessels/ais-live.js", + "server/providers/live.js", + "src/tooling/liveProviders.test.mjs", + "src/data/adsbLolFallback.js", + "src/data/aisStreamAdapter.js", + "src/data/aisWatchdog.js", + "server/providers/common/geo.js", + "server/providers/common/rate-limit.js", + "server/providers/places.js", + "server/providers/places/google-key.js", + "server/providers/places/google.js", + "server/providers/places/routes.js", + "src/data/placeProviderPayloads.js", + "src/tooling/placeProviders.test.mjs", + "server/providers/space.js", + "server/providers/space/celestrak.js", + "server/providers/space/launch-library.js", + "src/data/spaceProviderRequests.js", + "src/tooling/spaceProviders.test.mjs", + "server/providers/terrain.js", + "server/providers/traffic.js", + "server/providers/firms.js", + "server/providers/gbfs.js", + "src/data/gbfsSource.js", + "src/data/terrainHeightsProxy.js", + "src/data/terrainHeightsProxy.test.mjs", + "src/data/tomtomTiles.js", + "src/data/tomtomTiles.test.mjs", + "src/data/firmsCsv.js", + "src/data/firmsCsv.test.mjs", + "src/tooling/environmentProviders.test.mjs", + "server/providers/cctv.js", + "server/providers/cctv/cap.js", + "server/providers/cctv/groundHeights.js", + "server/providers/cctv/catalog.js", + "server/providers/cctv/constants.js", + "server/providers/cctv/media.js", + "server/providers/cctv/normalize.js", + "server/providers/cctv/sources.js", + "server/providers/radio.js", + "server/providers/radio/catalog.js", + "server/providers/radio/constants.js", + "server/providers/radio/stations.js", + "server/providers/radio/transport.js", + "src/tooling/mediaProviders.test.mjs", + "server/standalone/api-not-found.js", + "src/tooling/previewServing.test.mjs", + "server/providers/common/request.js", + "server/providers/common/source-root.js", + "server/providers/local.js", + "server/providers/military-installations.js", + "server/providers/military-installations/cache.js", + "server/providers/military-installations/constants.js", + "server/providers/military-installations/query.js", + "server/providers/openai.js", + "server/providers/openai/constants.js", + "server/providers/openai/debug-log.js", + "server/providers/openai/hud-summary.js", + "server/providers/openai/instructions.js", + "server/providers/openai/rate-limit.js", + "server/providers/openai/realtime.js", + "server/providers/openai/tools.js", + "server/providers/overpass.js", + "server/providers/overpass/cache.js", + "server/providers/overpass/constants.js", + "server/providers/overpass/geometry.js", + "server/providers/overpass/query.js", + "server/providers/overpass/transport.js", + "server/providers/regional.js", + "server/providers/regional/briefing.js", + "server/providers/regional/http.js", + "server/providers/regional/news.js", + "server/providers/regional/place.js", + "server/providers/regional/query.js", + "server/providers/regional/weather-effects.js", + "server/providers/regional/weather.js", + "server/standalone/key-setup.js", + "src/tooling/localServices.test.mjs", + "src/search/index.js", + "src/search/placeSearch.js", + "src/search/google.js", + "src/keylessGeocoder.js", + "src/standalone/placeSearch.js", + "src/ui/panelDisclosure.js", + "src/ui/surfaceKeyboard.js", + "src/ui/surfaceKeyboard.test.mjs", + "src/ui/panelRails.js", + "src/ui/leftPanelRail.js", + "src/ui/rightPanelRail.js", + "src/ui/panelMeasurement.js", + "src/ui/panelRailGeometry.js", + "src/ui/panelRails.test.mjs", + "src/panelStackLayout.js", + "src/ui/applicationShortcuts.js", + "src/ui/styleParameters.js", + "src/ui/visualInput.js", + "src/ui/visualInput.test.mjs", + "scripts/qa-visual-input.mjs", + "src/ui/displayControls.js", + "src/ui/displayControls.test.mjs", + "src/ui/effects.js", + "src/ui/visualEffects.js", + "src/ui/visualPresets.js", + "src/ui/visualEffects.test.mjs", + "src/bloom.js", + "src/styles/retro.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/snow.js", + "scripts/qa-visual-effects.mjs", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/mapSourceControls.test.mjs", + "src/mapStackChips.js", + "scripts/qa-map-source-controls.mjs", + "src/ui/layers.js", + "src/ui/layerPanel.js", + "src/ui/clearLayersControl.js", + "scripts/qa-layer-panel.mjs", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/ui/location.js", + "src/ui/locationControls.js", + "src/ui/locationSearch.js", + "src/locationStatus.js", + "src/ui/locationControls.test.mjs", + "src/ui/locationSearch.test.mjs", + "scripts/qa-location-controls.mjs", + "src/ui/radio.js", + "src/ui/radioControls.js", + "src/ui/radioBindings.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js", + "src/ui/radioControls.test.mjs", + "scripts/qa-ui-disposal.mjs", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvControls.test.mjs", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js", + "scripts/qa-camera-controls.mjs", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextControls.test.mjs", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitController.test.mjs", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitDisplayPortal.test.mjs", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/cockpitMath.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/ui/scenes.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/sceneControls.test.mjs", + "scripts/qa-scene-controls.mjs", + "src/ui.js", + "src/standalone/ui.js", + "src/ui/applicationShell.js", + "src/ui/panelLayoutController.js", + "src/ui/panelPositionControls.js", + "src/ui/recordingControls.js", + "src/ui/shellElements.js", + "src/ui/shellFeedback.js", + "src/ui/uiLifetime.js", + "src/ui/styles.js", + "src/ui/shellLifecycle.test.mjs", + "src/testSupport/readStylesheet.mjs", + "src/splitFlap.js", + "style.css", + "src/ui/styles/overlays.css", + "src/ui/styles/command-dock-trays.css", + "src/ui/styles/location.css", + "src/ui/styles/recording.css", + "src/ui/styles/status.css", + "src/ui/styles/layers.css", + "src/ui/styles/controls.css", + "src/ui/styles/cctv.css", + "src/ui/styles/foundation.css", + "src/ui/styles/first-run.css", + "src/ui/styles/scenes.css", + "src/ui/styles/command-dock.css", + "src/ui/styles/command-dock-sliding.css", + "src/ui/styles/provider-settings.css", + "src/ui/styles/radio.css", + "src/ui/styles/responsive.css", + "src/ui/styles/voice-cost.css", + "src/ui/styles/cockpit.css", + "src/ui/styles/command-dock-compact.css", + "src/app/stateChannel.js", + "src/app/stateChannel.test.mjs", + "src/sources/live/aircraft.js", + "src/sources/live/contract.js", + "src/sources/live/contract.test.mjs", + "src/sources/live/index.js", + "src/sources/live/standalone.js", + "src/sources/live/vessels.js", + "src/data/flights.js", + "src/testSupport/readLayerSource.mjs", + "src/layers/flights/lifecycle.js", + "src/layers/flights/motion.js", + "src/layers/flights/controller.js", + "src/layers/flights/state.js", + "src/layers/flights/policy.js", + "src/layers/flights/tracking.js", + "src/layers/flights/rendering.js", + "src/layers/flights/testing.js", + "src/layers/flights/evidence.js", + "src/layers/flights/ingestion.js", + "src/layers/flights/queries.js", + "src/layers/flights/index.js", + "src/layers/flights/enrichment.js", + "src/layers/flights/ownership.test.mjs", + "src/data/militaryFlights.js", + "src/data/militaryRegistry.js", + "src/layers/military/lifecycle.js", + "src/layers/military/motion.js", + "src/layers/military/controller.js", + "src/layers/military/state.js", + "src/layers/military/policy.js", + "src/layers/military/tracking.js", + "src/layers/military/rendering.js", + "src/layers/military/testing.js", + "src/layers/military/ingestion.js", + "src/layers/military/queries.js", + "src/layers/military/index.js", + "src/layers/aircraft/index.js", + "src/layers/aircraft/classification.js", + "src/layers/military/ownership.test.mjs", + "src/layers/aircraft/classification.test.mjs", + "src/data/aisLiveVessels.js", + "src/layers/vessels/cards.js", + "src/layers/vessels/evidence.js", + "src/layers/vessels/index.js", + "src/layers/vessels/ingestion.js", + "src/layers/vessels/lifecycle.js", + "src/layers/vessels/policy.js", + "src/layers/vessels/queries.js", + "src/layers/vessels/rendering.js", + "src/layers/vessels/selection.js", + "src/layers/vessels/state.js", + "src/layers/vessels/store.js", + "src/layers/vessels/testing.js", + "src/layers/vessels/tracking.js", + "src/layers/vessels/ownership.test.mjs", + "src/data/earthquakes.js", + "src/layers/earthquakes/index.js", + "src/layers/earthquakes/model.js", + "src/layers/earthquakes/source.js", + "src/layers/earthquakes/ownership.test.mjs", + "src/data/firmsHeatmap.js", + "src/data/fireAnchors.js", + "src/layers/firms/anchors.js", + "src/layers/firms/cards.js", + "src/layers/firms/index.js", + "src/layers/firms/ingestion.js", + "src/layers/firms/lifecycle.js", + "src/layers/firms/model.js", + "src/layers/firms/policy.js", + "src/layers/firms/queries.js", + "src/layers/firms/rendering.js", + "src/layers/firms/selection.js", + "src/layers/firms/source.js", + "src/layers/firms/state.js", + "src/layers/firms/viewport.js", + "src/layers/firms/source.test.mjs", + "src/layers/satellites/lifecycle.js", + "src/layers/satellites/interaction.js", + "src/layers/satellites/controls.js", + "src/layers/satellites/source.js", + "src/layers/satellites/labels.js", + "src/layers/satellites/catalog.js", + "src/layers/satellites/state.js", + "src/layers/satellites/policy.js", + "src/layers/satellites/tracking.js", + "src/layers/satellites/rendering.js", + "src/layers/satellites/testing.js", + "src/layers/satellites/ingestion.js", + "src/layers/satellites/index.js", + "src/layers/satellites/orbits.js", + "src/layers/launches/lifecycle.js", + "src/layers/launches/controls.js", + "src/layers/launches/panel.js", + "src/layers/launches/source.js", + "src/layers/launches/policyHelpers.js", + "src/layers/launches/replay.js", + "src/layers/launches/launchPad.js", + "src/layers/launches/state.js", + "src/layers/launches/policy.js", + "src/layers/launches/rendering.js", + "src/layers/launches/testing.js", + "src/layers/launches/ingestion.js", + "src/layers/launches/overlays.js", + "src/layers/launches/orbitRendering.js", + "src/layers/launches/index.js", + "src/layers/launches/paths.js", + "src/layers/launches/model.js", + "src/layers/launches/selection.js", + "src/layers/launches/camera.js", + "src/data/satellites.js", + "src/data/rocketLaunches.js", + "src/layers/satellites/source.test.mjs", + "src/layers/launches/source.test.mjs", + "src/layers/installations/lifecycle.js", + "src/layers/installations/viewport.js", + "src/layers/installations/controls.js", + "src/layers/installations/source.js", + "src/layers/installations/state.js", + "src/layers/installations/policy.js", + "src/layers/installations/rendering.js", + "src/layers/installations/ingestion.js", + "src/layers/installations/index.js", + "src/layers/installations/model.js", + "src/layers/installations/selection.js", + "src/layers/awareness/focus.js", + "src/layers/awareness/lifecycle.js", + "src/layers/awareness/dependencies.js", + "src/layers/awareness/controls.js", + "src/layers/awareness/panel.js", + "src/layers/awareness/state.js", + "src/layers/awareness/policy.js", + "src/layers/awareness/rendering.js", + "src/layers/awareness/testing.js", + "src/layers/awareness/ingestion.js", + "src/layers/awareness/queries.js", + "src/layers/awareness/navigation.js", + "src/layers/awareness/index.js", + "src/layers/awareness/model.js", + "src/layers/awareness/history.js", + "src/layers/awareness/subject.js", + "src/data/militaryInstallations.js", + "src/data/militaryAwareness.js", + "src/layers/installations/source.test.mjs", + "src/layers/traffic/flow.js", + "src/layers/traffic/lifecycle.js", + "src/layers/traffic/viewport.js", + "src/layers/traffic/controls.js", + "src/layers/traffic/source.js", + "src/layers/traffic/state.js", + "src/layers/traffic/policy.js", + "src/layers/traffic/rendering.js", + "src/layers/traffic/ingestion.js", + "src/layers/traffic/flowSource.js", + "src/layers/traffic/animation.js", + "src/layers/traffic/index.js", + "src/layers/traffic/flowDecode.js", + "src/layers/traffic/model.js", + "src/layers/traffic/timing.js", + "src/layers/traffic/style.js", + "src/layers/bikeshare/lifecycle.js", + "src/layers/bikeshare/viewport.js", + "src/layers/bikeshare/registry.js", + "src/layers/bikeshare/controls.js", + "src/layers/bikeshare/source.js", + "src/layers/bikeshare/state.js", + "src/layers/bikeshare/policy.js", + "src/layers/bikeshare/rendering.js", + "src/layers/bikeshare/testing.js", + "src/layers/bikeshare/ingestion.js", + "src/layers/bikeshare/queries.js", + "src/layers/bikeshare/index.js", + "src/layers/bikeshare/model.js", + "src/layers/bikeshare/selection.js", + "src/data/traffic.js", + "src/data/bikeshare.js", + "src/data/flowTiles.js", + "src/layers/traffic/source.test.mjs", + "src/layers/bikeshare/source.test.mjs", + "scripts/traffic-fixtures.mjs", + "src/layers/traffic/navigation.test.mjs", + "scripts/qa-traffic-navigation.mjs", + "src/ui/frameRateMonitor.js", + "src/ui/frameRateMonitor.test.mjs", + "src/layers/cctv/lifecycle.js", + "src/layers/cctv/controls.js", + "src/layers/cctv/geometry.js", + "src/layers/cctv/source.js", + "src/layers/cctv/catalog.js", + "src/layers/cctv/state.js", + "src/layers/cctv/policy.js", + "src/layers/cctv/projection.js", + "src/layers/cctv/cards.js", + "src/layers/cctv/rendering.js", + "src/layers/cctv/testing.js", + "src/layers/cctv/ingestion.js", + "src/layers/cctv/hover.js", + "src/layers/cctv/navigation.js", + "src/layers/cctv/index.js", + "src/layers/cctv/model.js", + "src/layers/cctv/geometryQueue.js", + "src/layers/cctv/selection.js", + "src/layers/cctv/calibration.js", + "src/layers/cctv/frames.js", + "src/layers/cctv/presentation.js", + "src/layers/cctv/ground.js", + "src/layers/cctv/health.js", + "src/data/cctv.js", + "src/data/cctvCatalogCap.test.mjs", + "src/data/cctvDisplayCode.test.mjs", + "src/data/cctvFootprint.js", + "src/data/cctvGroundHeights.test.mjs", + "src/data/cctvFootprint.test.mjs", + "src/data/cctvEstonia.test.mjs", + "src/data/cctvWarendorf.test.mjs", + "src/data/cctvNswSource.test.mjs", + "src/data/cctvTxdotSource.test.mjs", + "src/layers/cctv/source.test.mjs", + "src/data/alprCameras.js", + "src/data/alprCameras.test.mjs", + "src/layers/alpr/index.js", + "src/layers/alpr/model.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/presentation.js", + "src/layers/alpr/source.js", + "src/layers/alpr/source.test.mjs", + "src/layers/radio/catalogModel.js", + "src/layers/radio/categories.js", + "src/layers/radio/clustering.js", + "src/layers/radio/controls.js", + "src/layers/radio/index.js", + "src/layers/radio/ingestion.js", + "src/layers/radio/interaction.js", + "src/layers/radio/labels.js", + "src/layers/radio/lifecycle.js", + "src/layers/radio/model.js", + "src/layers/radio/navigation.js", + "src/layers/radio/playback.js", + "src/layers/radio/policy.js", + "src/layers/radio/presentation.js", + "src/layers/radio/queries.js", + "src/layers/radio/rendering.js", + "src/layers/radio/selection.js", + "src/layers/radio/source.js", + "src/layers/radio/state.js", + "src/layers/radio/tuning.js", + "src/layers/radio/tuningNoise.js", + "src/layers/radio/volume.js", + "src/data/radio.js", + "src/layers/radio/source.test.mjs", + "src/layers/submarineCables/bundledSource.js", + "src/layers/submarineCables/geometry.js", + "src/layers/submarineCables/index.js", + "src/layers/submarineCables/ingestion.js", + "src/layers/submarineCables/interaction.js", + "src/layers/submarineCables/lifecycle.js", + "src/layers/submarineCables/overlay.js", + "src/layers/submarineCables/policy.js", + "src/layers/submarineCables/rendering.js", + "src/layers/submarineCables/state.js", + "src/layers/submarineCables/surface.js", + "src/data/telegeographySubmarineCables.js", + "src/data/telegeographySubmarineCables.test.mjs", + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/layers/submarineCables/bundledSource.test.mjs", + "src/maps/availability.js", + "src/maps/catalog.js", + "src/maps/controller.js", + "src/maps/controller.test.mjs", + "src/maps/credits.js", + "src/maps/defaultSources.js", + "src/maps/google3d.js", + "src/maps/imagery.js", + "src/maps/registry.js", + "src/maps/terrain.js", + "src/mapStackController.js", + "src/mapStartup.js", + "src/mapStackChips.test.mjs", + "src/mapStackUnavailableReason.test.mjs", + "src/mapStartup.test.mjs", + "src/maps/sourceFactories.test.mjs" +] diff --git a/scripts/format.mjs b/scripts/format.mjs new file mode 100644 index 000000000..b74dfbdcd --- /dev/null +++ b/scripts/format.mjs @@ -0,0 +1,101 @@ +import { readFile, writeFile, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as prettier from 'prettier'; + +/** Check or format the explicitly adopted files, without scanning other source. */ +export async function formatAdoptedFiles(root, mode) { + if (!['--check', '--write'].includes(mode)) { + throw new Error('Usage: node scripts/format.mjs --check|--write'); + } + root = await realpath(root); + const scope = JSON.parse( + await readFile(path.join(root, 'scripts/format-scope.json'), 'utf8'), + ); + if ( + !Array.isArray(scope) || + !scope.length || + new Set(scope).size !== scope.length + ) { + throw new Error( + 'Formatting scope must be a nonempty list of unique file paths', + ); + } + + // Validate the whole scope before any write, including resolved symlink paths. + const files = []; + for (const name of scope) { + if ( + typeof name !== 'string' || + !name || + name.includes('\\') || + path.isAbsolute(name) || + name.split('/').includes('..') + ) { + throw new Error( + 'Formatting scope entries must be paths inside the repository', + ); + } + const file = path.join(root, name); + const relative = path.relative(root, await realpath(file)); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + !(await stat(file)).isFile() + ) { + throw new Error( + `Formatting scope must contain repository files: ${name}`, + ); + } + const info = await prettier.getFileInfo(file, { + ignorePath: path.join(root, '.prettierignore'), + }); + if (info.ignored || !info.inferredParser) { + throw new Error( + `Formatting scope contains an ignored or unsupported file: ${name}`, + ); + } + const options = { ...(await prettier.resolveConfig(file)), filepath: file }; + const source = await readFile(file, 'utf8'); + files.push({ + name, + file, + source, + formatted: await prettier.format(source, options), + }); + } + + const changed = files.filter(({ source, formatted }) => source !== formatted); + if (mode === '--write') { + for (const { file, formatted } of changed) await writeFile(file, formatted); + } + return { count: files.length, changed: changed.map(({ name }) => name) }; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + if (process.argv.length !== 3) + throw new Error('Usage: node scripts/format.mjs --check|--write'); + const mode = process.argv[2]; + const result = await formatAdoptedFiles( + fileURLToPath(new URL('../', import.meta.url)), + mode, + ); + if (mode === '--check' && result.changed.length) { + for (const name of result.changed) + console.error(`Needs formatting: ${name}`); + process.exitCode = 1; + } else { + console.log( + `${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`, + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/google-server-key.mjs b/scripts/google-server-key.mjs new file mode 100644 index 000000000..fe5531a7a --- /dev/null +++ b/scripts/google-server-key.mjs @@ -0,0 +1,5 @@ +/** Resolve server credentials after applying environment overrides per key. */ +export function resolveGoogleServerKey(environment = {}, defaults = {}) { + const value = (name) => String(environment[name] ?? defaults[name] ?? '').trim(); + return value('GOOGLE_MAPS_SERVER_API_KEY') || value('GOOGLE_MAPS_API_KEY'); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json new file mode 100644 index 000000000..f704a7a38 --- /dev/null +++ b/scripts/package-boundaries.json @@ -0,0 +1,1004 @@ +{ + "infrastructure": { + "exports": [ + "./infrastructure", + "./infrastructure/geojson", + "./infrastructure/lod" + ], + "modules": [ + "src/data/infrastructure.js", + "src/data/localGeojsonCore.js", + "src/data/localGeojsonLod.js" + ], + "external": ["cesium"] + }, + "application": { + "exports": ["./application"], + "modules": ["src/app/application.js"], + "external": [] + }, + "viewer": { + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] + }, + "vite-build": { + "runtime": "node", + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] + }, + "live-providers": { + "runtime": "node", + "exports": ["./server/providers/live"], + "modules": [ + "server/providers/live.js", + "server/providers/common/http.js", + "server/providers/common/query.js", + "server/providers/aircraft/opensky.js", + "server/providers/aircraft/adsb-lol.js", + "server/providers/aircraft/enrichment.js", + "server/providers/aircraft/tracks.js", + "server/providers/vessels/ais-store.js", + "server/providers/vessels/ais-live.js", + "src/data/adsbLolFallback.js", + "src/data/aisStreamAdapter.js", + "src/data/aisWatchdog.js" + ], + "external": ["ws"] + }, + "adsb-lol-source": { + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], + "external": [] + }, + "place-providers": { + "runtime": "node", + "exports": ["./server/providers/places"], + "modules": [ + "server/providers/places.js", + "server/providers/places/google.js", + "server/providers/places/google-key.js", + "server/providers/places/routes.js", + "server/providers/common/rate-limit.js", + "server/providers/common/geo.js", + "server/providers/common/http.js", + "scripts/google-server-key.mjs", + "src/data/placeProviderPayloads.js" + ], + "external": [] + }, + "place-payloads": { + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], + "external": [] + }, + "space-providers": { + "runtime": "node", + "exports": ["./server/providers/space"], + "modules": [ + "server/providers/space.js", + "server/providers/space/celestrak.js", + "server/providers/space/launch-library.js", + "server/providers/common/http.js", + "src/data/spaceProviderRequests.js" + ], + "external": [] + }, + "space-requests": { + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], + "external": [] + }, + "terrain-provider": { + "runtime": "node", + "exports": ["./server/providers/terrain"], + "modules": [ + "server/providers/terrain.js", + "src/data/terrainHeightsProxy.js" + ], + "external": [] + }, + "terrain-source": { + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], + "external": [] + }, + "traffic-provider": { + "runtime": "node", + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "external": [] + }, + "traffic-source": { + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], + "external": [] + }, + "firms-provider": { + "runtime": "node", + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "external": [] + }, + "gbfs-provider": { + "runtime": "node", + "exports": ["./server/providers/gbfs"], + "modules": [ + "server/providers/gbfs.js", + "server/providers/common/http.js", + "src/data/gbfsSource.js" + ], + "external": [] + }, + "gbfs-source": { + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], + "external": [] + }, + "cctv-provider": { + "runtime": "node", + "exports": ["./server/providers/cctv"], + "modules": [ + "server/providers/cctv.js", + "server/providers/cctv/cap.js", + "server/providers/cctv/groundHeights.js", + "server/providers/cctv/catalog.js", + "server/providers/cctv/constants.js", + "server/providers/cctv/media.js", + "server/providers/cctv/normalize.js", + "server/providers/cctv/sources.js", + "src/data/directionText.js", + "server/providers/common/geo.js", + "server/providers/places/google-key.js", + "scripts/google-server-key.mjs", + "src/data/cctvFootprint.js" + ], + "external": [] + }, + "radio-provider": { + "runtime": "node", + "exports": ["./server/providers/radio"], + "modules": [ + "server/providers/radio.js", + "server/providers/radio/catalog.js", + "server/providers/radio/constants.js", + "server/providers/radio/stations.js", + "server/providers/radio/transport.js", + "src/data/radioCountry.js", + "server/providers/common/http.js" + ], + "external": [] + }, + "overpass-provider": { + "runtime": "node", + "exports": ["./server/providers/overpass"], + "modules": [ + "server/providers/common/geo.js", + "server/providers/common/http.js", + "server/providers/common/rate-limit.js", + "server/providers/common/request.js", + "server/providers/overpass.js", + "server/providers/overpass/cache.js", + "server/providers/overpass/constants.js", + "server/providers/overpass/geometry.js", + "server/providers/overpass/query.js", + "server/providers/overpass/transport.js", + "server/providers/places/routes.js", + "src/data/placeProviderPayloads.js" + ], + "external": [] + }, + "military-installations-provider": { + "runtime": "node", + "exports": ["./server/providers/military-installations"], + "modules": [ + "server/providers/common/http.js", + "server/providers/common/query.js", + "server/providers/common/rate-limit.js", + "server/providers/military-installations.js", + "server/providers/military-installations/cache.js", + "server/providers/military-installations/constants.js", + "server/providers/military-installations/query.js", + "server/providers/overpass/constants.js", + "server/providers/overpass/geometry.js", + "server/providers/overpass/transport.js" + ], + "external": [] + }, + "regional-provider": { + "runtime": "node", + "exports": ["./server/providers/regional"], + "modules": [ + "server/providers/common/http.js", + "server/providers/common/query.js", + "server/providers/common/rate-limit.js", + "server/providers/regional.js", + "server/providers/regional/briefing.js", + "server/providers/regional/http.js", + "server/providers/regional/news.js", + "server/providers/regional/place.js", + "server/providers/regional/query.js", + "server/providers/regional/weather-effects.js", + "server/providers/regional/weather.js", + "src/data/regionalBrief.js" + ], + "external": [] + }, + "openai-provider": { + "runtime": "node", + "exports": ["./server/providers/openai"], + "modules": [ + "server/providers/common/rate-limit.js", + "server/providers/common/request.js", + "server/providers/common/source-root.js", + "server/providers/openai.js", + "server/providers/openai/constants.js", + "server/providers/openai/debug-log.js", + "server/providers/openai/hud-summary.js", + "server/providers/openai/instructions.js", + "server/providers/openai/rate-limit.js", + "server/providers/openai/realtime.js", + "server/providers/openai/tools.js", + "src/hudSummaryResponse.js", + "src/voice/voiceCost.js" + ], + "external": [] + }, + "standalone-key-setup": { + "runtime": "node", + "exports": ["./server/standalone/key-setup"], + "modules": [ + "scripts/pinokio-environment.mjs", + "server/providers/common/source-root.js", + "server/standalone/key-setup.js", + "src/keySetupCore.mjs", + "src/keySetupHardening.mjs" + ], + "external": [] + }, + "place-search": { + "exports": ["./search"], + "modules": [ + "src/search/index.js", + "src/search/placeSearch.js", + "src/search/google.js", + "src/keylessGeocoder.js" + ], + "external": [] + }, + "panel-disclosure": { + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], + "external": [] + }, + "surface-keyboard": { + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], + "external": [] + }, + "panel-rail-layout": { + "exports": ["./ui/layout"], + "modules": [ + "src/ui/panelRails.js", + "src/ui/leftPanelRail.js", + "src/ui/rightPanelRail.js", + "src/ui/panelMeasurement.js", + "src/ui/panelRailGeometry.js", + "src/panelStackLayout.js" + ], + "external": [] + }, + "visual-input": { + "exports": ["./ui/input"], + "modules": [ + "src/ui/visualInput.js", + "src/ui/applicationShortcuts.js", + "src/ui/styleParameters.js" + ], + "external": [] + }, + "display-controls": { + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], + "external": [] + }, + "visual-effects": { + "exports": ["./ui/effects", "./ui/effects/bloom"], + "modules": [ + "src/ui/effects.js", + "src/ui/visualEffects.js", + "src/ui/visualPresets.js", + "src/bloom.js", + "src/styles/retro.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/snow.js" + ], + "external": ["cesium"] + }, + "map-source-controls": { + "exports": ["./ui/maps"], + "modules": [ + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/mapStackChips.js", + "src/keySetupCore.mjs" + ], + "external": [] + }, + "layer-panel": { + "exports": ["./ui/layers", "./ui/layers/feedback"], + "modules": [ + "src/ui/layers.js", + "src/ui/layerPanel.js", + "src/ui/clearLayersControl.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js" + ], + "external": [] + }, + "location-controls": { + "exports": ["./ui/location"], + "modules": [ + "src/ui/location.js", + "src/ui/locationControls.js", + "src/ui/locationSearch.js", + "src/locationStatus.js", + "src/app/stateChannel.js" + ], + "external": [] + }, + "radio-controls": { + "exports": ["./ui/radio"], + "modules": [ + "src/ui/radio.js", + "src/ui/radioControls.js", + "src/ui/radioBindings.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js" + ], + "external": [] + }, + "cctv-controls": { + "exports": ["./ui/cctv"], + "modules": [ + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js" + ], + "external": [] + }, + "context-controls": { + "exports": ["./ui/context", "./ui/context/policy"], + "modules": [ + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/contextModePolicy.js", + "src/rightRailPolicy.js", + "src/ui/panelRailGeometry.js" + ], + "external": [] + }, + "cockpit-controls": { + "exports": [ + "./ui/cockpit", + "./ui/cockpit/math", + "./ui/cockpit/layout", + "./ui/cockpit/vision" + ], + "modules": [ + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/cockpitMath.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/ui/panelRailGeometry.js" + ], + "external": ["cesium"] + }, + "scene-controls": { + "exports": ["./ui/scenes"], + "modules": [ + "src/ui/scenes.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js" + ], + "external": [] + }, + "ui-shell": { + "exports": ["./ui/shell"], + "modules": [ + "src/bloom.js", + "src/cctvFocusPolicy.js", + "src/cctvFocusRequest.js", + "src/cockpitMath.js", + "src/cockpitTracking.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/contactsDetectionPolicy.js", + "src/contextModePolicy.js", + "src/data/detectionPolicy.js", + "src/data/installationFeedback.js", + "src/data/layerState.js", + "src/data/militaryAwarenessEngine.js", + "src/keySetupCore.mjs", + "src/loadingFeedback.js", + "src/locationStatus.js", + "src/mapStackChips.js", + "src/navigationPolicy.js", + "src/panelStackLayout.js", + "src/rightRailPolicy.js", + "src/splitFlap.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/ui/applicationShell.js", + "src/ui/applicationShortcuts.js", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js", + "src/ui/clearLayersControl.js", + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/ui/displayControls.js", + "src/ui/effects.js", + "src/ui/layerPanel.js", + "src/ui/layers.js", + "src/ui/leftPanelRail.js", + "src/ui/location.js", + "src/ui/locationControls.js", + "src/ui/locationSearch.js", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/panelDisclosure.js", + "src/ui/panelLayoutController.js", + "src/ui/panelMeasurement.js", + "src/ui/panelPositionControls.js", + "src/ui/panelRailGeometry.js", + "src/ui/panelRails.js", + "src/ui/radio.js", + "src/ui/radioBindings.js", + "src/ui/radioControls.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js", + "src/ui/recordingControls.js", + "src/ui/rightPanelRail.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/scenes.js", + "src/ui/shellElements.js", + "src/ui/shellFeedback.js", + "src/ui/styleParameters.js", + "src/ui/surfaceKeyboard.js", + "src/ui/visualEffects.js", + "src/ui/visualInput.js", + "src/ui/visualPresets.js", + "src/worldFocus.js", + "src/ui/uiLifetime.js", + "src/app/stateChannel.js", + "src/ui/frameRateMonitor.js" + ], + "external": ["cesium"] + }, + "ui-styles": { + "exports": ["./ui/styles"], + "modules": [ + "src/ui/styles.js", + "style.css", + "src/ui/styles/foundation.css", + "src/ui/styles/controls.css", + "src/ui/styles/location.css", + "src/ui/styles/status.css", + "src/ui/styles/cockpit.css", + "src/ui/styles/overlays.css", + "src/ui/styles/layers.css", + "src/ui/styles/radio.css", + "src/ui/styles/cctv.css", + "src/ui/styles/scenes.css", + "src/ui/styles/recording.css", + "src/ui/styles/responsive.css", + "src/ui/styles/command-dock.css", + "src/ui/styles/command-dock-compact.css", + "src/ui/styles/command-dock-trays.css", + "src/ui/styles/command-dock-sliding.css", + "src/ui/styles/voice-cost.css", + "src/ui/styles/first-run.css", + "src/ui/styles/provider-settings.css" + ], + "external": [] + }, + "scenes": { + "exports": ["./scenes"], + "modules": [ + "src/scenes/director.js", + "src/scenes/recipes.js", + "src/scenes/scenePolicy.js", + "src/bloom.js", + "src/app/stateChannel.js", + "src/ui/scenes.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/contextModePolicy.js" + ], + "external": ["cesium"] + }, + "live-observations": { + "exports": ["./sources/live"], + "modules": [ + "src/sources/live/aircraft.js", + "src/sources/live/contract.js", + "src/sources/live/index.js", + "src/sources/live/standalone.js", + "src/sources/live/vessels.js" + ], + "external": [] + }, + "civil-flights": { + "exports": ["./layers/flights"], + "modules": [ + "src/data/aircraftClass.js", + "src/data/aircraftIcons.js", + "src/data/aircraftMeta.js", + "src/data/aircraftNearbyPolicy.js", + "src/data/cockpitAirLod.js", + "src/data/cockpitContactDot.js", + "src/data/contactMatch.js", + "src/data/iconOrientation.js", + "src/data/layerState.js", + "src/data/modelVisualAnchor.js", + "src/data/motionModel.js", + "src/data/renderAltitude.js", + "src/data/routePlausible.js", + "src/data/trackedModelRegime.js", + "src/data/trackingClickGesture.js", + "src/layers/flights/controller.js", + "src/layers/flights/enrichment.js", + "src/layers/flights/evidence.js", + "src/layers/flights/index.js", + "src/layers/flights/ingestion.js", + "src/layers/flights/lifecycle.js", + "src/layers/flights/motion.js", + "src/layers/flights/policy.js", + "src/layers/flights/queries.js", + "src/layers/flights/rendering.js", + "src/layers/flights/state.js", + "src/layers/flights/testing.js", + "src/layers/flights/tracking.js" + ], + "external": ["cesium"] + }, + "military-flights": { + "exports": ["./layers/military"], + "modules": [ + "src/data/aircraftClass.js", + "src/data/aircraftIcons.js", + "src/data/aircraftMeta.js", + "src/data/aircraftNearbyPolicy.js", + "src/data/cockpitAirLod.js", + "src/data/cockpitContactDot.js", + "src/data/contactMatch.js", + "src/data/iconOrientation.js", + "src/data/layerState.js", + "src/data/modelVisualAnchor.js", + "src/data/motionModel.js", + "src/data/renderAltitude.js", + "src/data/trackedModelRegime.js", + "src/data/trackingClickGesture.js", + "src/layers/military/controller.js", + "src/layers/military/index.js", + "src/layers/military/ingestion.js", + "src/layers/military/lifecycle.js", + "src/layers/military/motion.js", + "src/layers/military/policy.js", + "src/layers/military/queries.js", + "src/layers/military/rendering.js", + "src/layers/military/state.js", + "src/layers/military/testing.js", + "src/layers/military/tracking.js" + ], + "external": ["cesium"] + }, + "aircraft-mechanics": { + "exports": ["./aircraft"], + "modules": [ + "src/data/aircraftClass.js", + "src/data/aircraftIcons.js", + "src/data/aircraftMeta.js", + "src/data/aircraftNearbyPolicy.js", + "src/data/cockpitAirLod.js", + "src/data/cockpitContactDot.js", + "src/data/contactMatch.js", + "src/data/iconOrientation.js", + "src/data/layerState.js", + "src/data/modelVisualAnchor.js", + "src/data/motionModel.js", + "src/data/renderAltitude.js", + "src/data/routePlausible.js", + "src/data/trackedModelRegime.js", + "src/data/trackingClickGesture.js", + "src/layers/aircraft/classification.js", + "src/layers/aircraft/index.js" + ], + "external": ["cesium"] + }, + "vessel-layer": { + "exports": ["./layers/vessels"], + "modules": [ + "src/layers/vessels/cards.js", + "src/layers/vessels/evidence.js", + "src/layers/vessels/index.js", + "src/layers/vessels/ingestion.js", + "src/layers/vessels/lifecycle.js", + "src/layers/vessels/policy.js", + "src/layers/vessels/queries.js", + "src/layers/vessels/rendering.js", + "src/layers/vessels/selection.js", + "src/layers/vessels/state.js", + "src/layers/vessels/store.js", + "src/layers/vessels/testing.js", + "src/layers/vessels/tracking.js", + "src/data/iconOrientation.js", + "src/data/vesselLabels.js" + ], + "external": ["cesium"] + }, + "earthquakes": { + "exports": ["./layers/earthquakes"], + "modules": [ + "src/layers/earthquakes/index.js", + "src/layers/earthquakes/model.js", + "src/layers/earthquakes/source.js" + ], + "external": ["cesium"] + }, + "fire-layer": { + "exports": ["./layers/firms"], + "modules": [ + "src/data/firmsAdapt.js", + "src/data/firmsLabels.js", + "src/data/iconOrientation.js", + "src/layers/firms/anchors.js", + "src/layers/firms/cards.js", + "src/layers/firms/index.js", + "src/layers/firms/ingestion.js", + "src/layers/firms/lifecycle.js", + "src/layers/firms/model.js", + "src/layers/firms/policy.js", + "src/layers/firms/queries.js", + "src/layers/firms/rendering.js", + "src/layers/firms/selection.js", + "src/layers/firms/source.js", + "src/layers/firms/state.js", + "src/layers/firms/viewport.js" + ], + "external": ["cesium"] + }, + "satellites-layer": { + "exports": ["./layers/satellites"], + "modules": [ + "src/data/issPass.js", + "src/data/satelliteClass.js", + "src/layers/satellites/catalog.js", + "src/layers/satellites/controls.js", + "src/layers/satellites/index.js", + "src/layers/satellites/ingestion.js", + "src/layers/satellites/interaction.js", + "src/layers/satellites/labels.js", + "src/layers/satellites/lifecycle.js", + "src/layers/satellites/orbits.js", + "src/layers/satellites/policy.js", + "src/layers/satellites/rendering.js", + "src/layers/satellites/source.js", + "src/layers/satellites/state.js", + "src/layers/satellites/testing.js", + "src/layers/satellites/tracking.js" + ], + "external": ["cesium", "satellite.js"] + }, + "launches-layer": { + "exports": ["./layers/launches"], + "modules": [ + "src/layers/launches/camera.js", + "src/layers/launches/controls.js", + "src/layers/launches/index.js", + "src/layers/launches/ingestion.js", + "src/layers/launches/launchPad.js", + "src/layers/launches/lifecycle.js", + "src/layers/launches/model.js", + "src/layers/launches/orbitRendering.js", + "src/layers/launches/overlays.js", + "src/layers/launches/panel.js", + "src/layers/launches/paths.js", + "src/layers/launches/policy.js", + "src/layers/launches/policyHelpers.js", + "src/layers/launches/rendering.js", + "src/layers/launches/replay.js", + "src/layers/launches/selection.js", + "src/layers/launches/source.js", + "src/layers/launches/state.js", + "src/layers/launches/testing.js" + ], + "external": ["cesium"] + }, + "installations-layer": { + "exports": ["./layers/installations"], + "modules": [ + "src/data/installationFeedback.js", + "src/data/militaryInstallationData.js", + "src/layers/installations/controls.js", + "src/layers/installations/index.js", + "src/layers/installations/ingestion.js", + "src/layers/installations/lifecycle.js", + "src/layers/installations/model.js", + "src/layers/installations/policy.js", + "src/layers/installations/rendering.js", + "src/layers/installations/selection.js", + "src/layers/installations/source.js", + "src/layers/installations/state.js", + "src/layers/installations/viewport.js" + ], + "external": ["cesium"] + }, + "awareness-layer": { + "exports": ["./layers/awareness"], + "modules": [ + "src/cockpitMath.js", + "src/data/iconOrientation.js", + "src/data/militaryAwarenessEngine.js", + "src/layers/awareness/controls.js", + "src/layers/awareness/dependencies.js", + "src/layers/awareness/focus.js", + "src/layers/awareness/history.js", + "src/layers/awareness/index.js", + "src/layers/awareness/ingestion.js", + "src/layers/awareness/lifecycle.js", + "src/layers/awareness/model.js", + "src/layers/awareness/navigation.js", + "src/layers/awareness/panel.js", + "src/layers/awareness/policy.js", + "src/layers/awareness/queries.js", + "src/layers/awareness/rendering.js", + "src/layers/awareness/state.js", + "src/layers/awareness/subject.js", + "src/layers/awareness/testing.js", + "src/ui/panelRailGeometry.js" + ], + "external": ["cesium"] + }, + "traffic-layer": { + "exports": ["./layers/traffic"], + "modules": [ + "src/data/flowMatch.js", + "src/data/tomtomTiles.js", + "src/data/trafficBounds.js", + "src/data/trafficFlowStyle.js", + "src/data/trafficPresetStyle.js", + "src/data/trafficQueue.js", + "src/layers/traffic/animation.js", + "src/layers/traffic/controls.js", + "src/layers/traffic/flow.js", + "src/layers/traffic/flowDecode.js", + "src/layers/traffic/flowSource.js", + "src/layers/traffic/index.js", + "src/layers/traffic/ingestion.js", + "src/layers/traffic/lifecycle.js", + "src/layers/traffic/model.js", + "src/layers/traffic/policy.js", + "src/layers/traffic/rendering.js", + "src/layers/traffic/source.js", + "src/layers/traffic/state.js", + "src/layers/traffic/style.js", + "src/layers/traffic/timing.js", + "src/layers/traffic/viewport.js" + ], + "external": ["cesium", "pbf", "@mapbox/vector-tile"] + }, + "bikeshare-layer": { + "exports": ["./layers/bikeshare"], + "modules": [ + "src/layers/bikeshare/controls.js", + "src/layers/bikeshare/index.js", + "src/layers/bikeshare/ingestion.js", + "src/layers/bikeshare/lifecycle.js", + "src/layers/bikeshare/model.js", + "src/layers/bikeshare/policy.js", + "src/layers/bikeshare/queries.js", + "src/layers/bikeshare/registry.js", + "src/layers/bikeshare/rendering.js", + "src/layers/bikeshare/selection.js", + "src/layers/bikeshare/source.js", + "src/layers/bikeshare/state.js", + "src/layers/bikeshare/testing.js", + "src/layers/bikeshare/viewport.js" + ], + "external": ["cesium"] + }, + "cctv-layer": { + "exports": ["./layers/cctv"], + "modules": [ + "src/data/cctvCards.js", + "src/data/cctvGizmo.js", + "src/data/cctvLod.js", + "src/data/cctvViewshed.js", + "src/data/iconOrientation.js", + "src/data/trackingClickGesture.js", + "src/layers/cctv/calibration.js", + "src/layers/cctv/cards.js", + "src/layers/cctv/catalog.js", + "src/layers/cctv/controls.js", + "src/layers/cctv/frames.js", + "src/layers/cctv/geometry.js", + "src/layers/cctv/geometryQueue.js", + "src/layers/cctv/ground.js", + "src/layers/cctv/health.js", + "src/layers/cctv/hover.js", + "src/layers/cctv/index.js", + "src/layers/cctv/ingestion.js", + "src/layers/cctv/lifecycle.js", + "src/layers/cctv/model.js", + "src/layers/cctv/navigation.js", + "src/layers/cctv/policy.js", + "src/layers/cctv/presentation.js", + "src/layers/cctv/projection.js", + "src/layers/cctv/rendering.js", + "src/layers/cctv/selection.js", + "src/layers/cctv/source.js", + "src/layers/cctv/state.js", + "src/layers/cctv/testing.js", + "src/overlays/worldOverlayTokens.js", + "src/data/cctvFootprint.js" + ], + "external": ["cesium"] + }, + "alpr-cameras": { + "exports": ["./layers/alpr"], + "modules": [ + "src/layers/alpr/index.js", + "src/layers/alpr/model.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/presentation.js", + "src/layers/alpr/source.js" + ], + "external": ["cesium"] + }, + "radio-layer": { + "exports": ["./layers/radio"], + "modules": [ + "src/data/iconOrientation.js", + "src/data/layerState.js", + "src/data/radioCountry.js", + "src/layers/radio/catalogModel.js", + "src/layers/radio/categories.js", + "src/layers/radio/clustering.js", + "src/layers/radio/controls.js", + "src/layers/radio/index.js", + "src/layers/radio/ingestion.js", + "src/layers/radio/interaction.js", + "src/layers/radio/labels.js", + "src/layers/radio/lifecycle.js", + "src/layers/radio/model.js", + "src/layers/radio/navigation.js", + "src/layers/radio/playback.js", + "src/layers/radio/policy.js", + "src/layers/radio/presentation.js", + "src/layers/radio/queries.js", + "src/layers/radio/rendering.js", + "src/layers/radio/selection.js", + "src/layers/radio/source.js", + "src/layers/radio/state.js", + "src/layers/radio/tuning.js", + "src/layers/radio/tuningNoise.js", + "src/layers/radio/volume.js", + "src/ui/radioTunerModel.js" + ], + "external": ["cesium"] + }, + "submarine-cables": { + "exports": ["./layers/submarine-cables"], + "modules": [ + "src/layers/submarineCables/geometry.js", + "src/layers/submarineCables/index.js", + "src/layers/submarineCables/ingestion.js", + "src/layers/submarineCables/interaction.js", + "src/layers/submarineCables/lifecycle.js", + "src/layers/submarineCables/overlay.js", + "src/layers/submarineCables/policy.js", + "src/layers/submarineCables/rendering.js", + "src/layers/submarineCables/state.js", + "src/layers/submarineCables/surface.js" + ], + "external": ["cesium"] + }, + "bundled-cables": { + "exports": ["./sources/bundled-cables"], + "modules": [ + "src/layers/submarineCables/bundledSource.js", + "src/data/local_data/telegeography_submarine_cables/cable-geo.json", + "src/data/local_data/telegeography_submarine_cables/landing-point-geo.json" + ], + "external": [] + }, + "geography": { + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "modules": [ + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/data/retryableLoad.js", + "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/natural_earth/marine.json" + ], + "external": [] + }, + "maps": { + "exports": [ + "./maps/controller", + "./maps/catalog", + "./maps/imagery", + "./maps/terrain", + "./maps/3d", + "./maps/defaults" + ], + "modules": [ + "src/maps/availability.js", + "src/maps/catalog.js", + "src/maps/controller.js", + "src/maps/credits.js", + "src/maps/defaultSources.js", + "src/maps/google3d.js", + "src/maps/imagery.js", + "src/maps/registry.js", + "src/maps/terrain.js", + "src/keySetupCore.mjs" + ], + "external": ["cesium"] + } +} diff --git a/scripts/pinokio-environment.mjs b/scripts/pinokio-environment.mjs index 745e23d9d..b32b7c18b 100644 --- a/scripts/pinokio-environment.mjs +++ b/scripts/pinokio-environment.mjs @@ -8,6 +8,7 @@ const DEFAULT_ENVIRONMENT_FILE = path.join(ROOT, 'pinokio', 'ENVIRONMENT'); export const PINOKIO_CONFIG_FIELDS = Object.freeze([ 'GOOGLE_MAPS_API_KEY', + 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', diff --git a/scripts/precompute-cctv-heights.mjs b/scripts/precompute-cctv-heights.mjs new file mode 100644 index 000000000..24813b5e7 --- /dev/null +++ b/scripts/precompute-cctv-heights.mjs @@ -0,0 +1,487 @@ +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + planeSupportPoints, + poseHash, + SUPPORT_KEYS, +} from '../src/data/cctvFootprint.js'; + +const ROOT = fileURLToPath(new URL('../', import.meta.url)); +const SAMPLE_KEYS = ['mount', ...SUPPORT_KEYS]; +const BATCH_SIZES = [16, 4, 1]; +const DEADLINE_MS = 90_000; + +/** Extract the served nominal pose (the values the catalog ships). */ +export function nominalPose(source) { + const pose = Object.fromEntries( + [ + 'lat', + 'lon', + 'headingDeg', + 'pitchDeg', + 'fovDeg', + 'rangeM', + 'mountHeightM', + ].map((key) => { + if (typeof source[key] !== 'number' || !Number.isFinite(source[key])) { + throw new Error(`Camera ${source.id}: invalid ${key}`); + } + return [key, source[key]]; + }), + ); + if (Math.abs(pose.lat) > 90 || Math.abs(pose.lon) > 180) { + throw new Error(`Camera ${source.id}: invalid coordinates`); + } + // Hashed exactly as served: the server join (groundHeights.js) and the + // client (camera.servedPose) hash the same raw values, so no clamp here. + return pose; +} + +/** Return a roughly 2 km grid key, with longitude width fixed per row. */ +export function cellKey({ lat, lon }) { + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) { + throw new Error('Invalid cell coordinates'); + } + const row = Math.min(8999, Math.floor((lat + 90) / 0.02)); + const rowLat = -90 + (row + 0.5) * 0.02; + const lonWidth = 0.02 / Math.cos((rowLat * Math.PI) / 180); + const col = Math.floor(((lon === 180 ? -180 : lon) + 180) / lonWidth); + return `${row},${col}`; +} + +/** Split an ordered array into bounded batches without mutating it. */ +export function batches(items, size = 16) { + if (!Number.isSafeInteger(size) || size < 1) + throw new Error('Invalid batch size'); + const result = []; + for (let i = 0; i < items.length; i += size) + result.push(items.slice(i, i + size)); + return result; +} + +/** Group cameras by cell and order cells numerically by row, then column. */ +export function planCells(cameras) { + const cells = new Map(); + for (const camera of cameras) { + const key = cellKey(camera.pose); + if (!cells.has(key)) cells.set(key, { key, cameras: [] }); + cells.get(key).cameras.push(camera); + } + return [...cells.values()].sort((a, b) => { + const [ar, ac] = a.key.split(',').map(Number); + const [br, bc] = b.key.split(',').map(Number); + return ar - br || ac - bc; + }); +} + +/** Preserve ellipsoidal metres; missing, non-finite and implausible values are null. */ +export function plausibleHeight(height) { + return Number.isFinite(height) && height >= -150 ? height : null; +} + +/** + * Only a complete, successful entry for exactly the current nominal pose is + * reusable; an entry with missing supports is resampled (valid heights are + * kept by mergeCameraSamples). + */ +export function needsSampling(entry, pose) { + return ( + entry?.status !== 'ok' || + entry.poseHash !== poseHash(pose) || + (Array.isArray(entry.misses) && entry.misses.length > 0) + ); +} + +/** Merge retry samples without losing valid heights; a missing mount stores no heights. */ +export function mergeCameraSamples( + pose, + heights, + previous, + attempts, + sampledAt, +) { + const hash = poseHash(pose); + const prior = previous?.poseHash === hash ? previous : null; + const values = SAMPLE_KEYS.map( + (key, i) => + plausibleHeight(heights[i]) ?? + plausibleHeight( + key === 'mount' ? prior?.mountGroundM : prior?.supports?.[key], + ), + ); + const entry = { + poseHash: hash, + status: values[0] === null ? 'miss' : 'ok', + misses: SAMPLE_KEYS.filter((_, i) => values[i] === null), + sampledAt, + attempts, + }; + if (entry.status === 'ok') { + entry.mountGroundM = values[0]; + entry.supports = Object.fromEntries( + SUPPORT_KEYS.map((key, i) => [key, values[i + 1]]), + ); + } + return entry; +} + +/** Build a sidecar while retaining entries outside this run's selection. */ +export function mergeSidecar(existing, updates, generatedAt) { + const cameras = { ...existing?.cameras, ...updates }; + return { + schemaVersion: 1, + provider: 'google-3d-tiles', + heightReference: 'WGS84-ellipsoid', + generatedAt, + cameras: Object.fromEntries( + Object.keys(cameras) + .sort() + .map((id) => [id, cameras[id]]), + ), + }; +} + +/** Parse pilot filters; the limit applies after kinds, before resume filtering. */ +export function parseArgs(argv) { + const options = { limit: Infinity, kinds: null, dryRun: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--dry-run') options.dryRun = true; + else if (arg === '--limit') { + const value = argv[++i]; + if (!/^\d+$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) { + throw new Error('--limit requires a non-negative integer'); + } + options.limit = Number(value); + } else if (arg === '--kinds') { + const value = argv[++i]; + if ( + !value || + value.startsWith('--') || + value.split(',').some((kind) => !kind.trim()) + ) { + throw new Error('--kinds requires comma-separated sourceKinds'); + } + options.kinds = value.split(',').map((kind) => kind.trim()); + } else throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +/** Use the shared footprint's mount and nine supports in the sampling wire order. */ +export function samplingPoints(pose) { + const footprint = planeSupportPoints(pose); + return SAMPLE_KEYS.map((key) => { + const point = key === 'mount' ? footprint.mount : footprint.supports[key]; + if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) { + throw new Error( + `Shared planeSupportPoints returned invalid coordinates for ${key}`, + ); + } + return { lat: point.lat, lon: point.lon }; + }); +} + +/** Sample one cell serially, retrying failed cameras at most three times (16/4/1). */ +export async function sampleCell( + cameras, + sampler, + now = () => new Date().toISOString(), + existing = null, +) { + // Seed with the prior entries so a resumed camera keeps every valid height + // it already has (mergeCameraSamples only replaces misses). + const entries = Object.create(null); + for (const camera of cameras) { + if (existing?.[camera.id]) entries[camera.id] = existing[camera.id]; + } + let pending = cameras; + for (const [index, size] of BATCH_SIZES.entries()) { + const retry = []; + for (const batch of batches(pending, size)) { + let samples; + try { + samples = await sampler.sample(batch); + } catch (error) { + sampler.warn?.( + `Attempt ${index + 1}, ${batch.length} cameras: ${error.message}`, + ); + // A timed-out evaluate can still be sampling: destroy its page before retrying. + await sampler.recycle(); + samples = batch.map(() => []); + } + const sampledAt = now(); + for (let i = 0; i < batch.length; i += 1) { + const camera = batch[i]; + const entry = mergeCameraSamples( + camera.pose, + samples[i] ?? [], + entries[camera.id], + index + 1, + sampledAt, + ); + entries[camera.id] = entry; + if (entry.misses.length) retry.push(camera); + } + } + pending = retry; + if (!pending.length) break; + } + return entries; +} + +/** Bound a sampling operation; callers must recycle its page after rejection. */ +export async function withDeadline(operation, timeoutMs = DEADLINE_MS) { + let timer; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Height sampling timed out after ${timeoutMs / 1000} s`, + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function openPage(browser, base) { + const page = await browser.newPage(); + try { + await page.goto( + `${base}/#v=2&lat=30&lon=-95&alt=17000000&heading=0&pitch=-90&roll=0`, + ); + await page.waitForFunction(() => !!window.__godsEyeView?.viewer); + return page; + } catch (error) { + await page.close(); + throw error; + } +} + +async function loadSidecar(output) { + let existing; + try { + existing = JSON.parse(await readFile(output, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + if ( + existing?.schemaVersion !== 1 || + existing.provider !== 'google-3d-tiles' || + existing.heightReference !== 'WGS84-ellipsoid' || + !existing.cameras || + typeof existing.cameras !== 'object' || + Array.isArray(existing.cameras) + ) { + throw new Error(`Incompatible sidecar: ${output}`); + } + return existing; +} + +async function writeSidecar(output, sidecar) { + await mkdir(path.dirname(output), { recursive: true }); + const temporary = `${output}.${process.pid}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(sidecar, null, 2)}\n`); + await rename(temporary, output); + } finally { + await rm(temporary, { force: true }); + } +} + +function counts(cameras, entries) { + const ok = cameras.filter( + (camera) => entries[camera.id]?.status === 'ok', + ).length; + return { ok, miss: cameras.length - ok }; +} + +async function main() { + const started = Date.now(); + const options = parseArgs(process.argv.slice(2)); + const base = (process.env.GEV_BASE || 'http://localhost:4173').replace( + /\/+$/, + '', + ); + const output = path.resolve( + ROOT, + process.env.OUT || + 'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json', + ); + let sidecar = await loadSidecar(output); + const response = await fetch(`${base}/api/cctv/sources`, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`); + const catalog = await response.json(); + if (!Array.isArray(catalog.sources)) + throw new Error('Catalog must contain sources[]'); + const ids = new Set(); + const selected = catalog.sources + .filter( + (source) => !options.kinds || options.kinds.includes(source.sourceKind), + ) + .slice(0, options.limit) + .flatMap((source) => { + if (typeof source.id !== 'string' || !source.id || ids.has(source.id)) { + throw new Error('Catalog has an invalid or duplicate camera id'); + } + ids.add(source.id); + let pose; + try { + pose = nominalPose(source); + } catch (error) { + // One camera with a malformed pose must not abort the whole run. + console.warn(`skip ${source.id}: ${error.message}`); + return []; + } + return [ + { + id: source.id, + sourceKind: source.sourceKind || 'unknown', + pose, + }, + ]; + }); + const pending = selected.filter((camera) => + needsSampling(sidecar?.cameras?.[camera.id], camera.pose), + ); + const cells = planCells(pending); + console.log( + `${selected.length} cameras selected, ${selected.length - pending.length} resumed, ${pending.length} to sample in ${cells.length} cells`, + ); + if (options.dryRun) { + for (const cell of cells) { + const sizes = batches(cell.cameras).map( + (batch) => `${batch.length} cameras/${batch.length * 10} points`, + ); + console.log( + `cell ${cell.key}: ${cell.cameras.length} cameras; batches: ${sizes.join(', ')}`, + ); + } + console.log( + 'Retries: failed cameras in batches of 4, then singly; 3 attempts, 90 s per call.', + ); + return; + } + + // Validate shared geometry before starting an expensive browser session. + for (const camera of pending) camera.points = samplingPoints(camera.pose); + let browser; + try { + if (pending.length) { + const { default: puppeteer } = await import('puppeteer'); + browser = await puppeteer.launch({ + headless: false, + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + protocolTimeout: 600000, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1200,800', + ], + }); + let page = await openPage(browser, base); + const sampler = { + warn: (message) => console.error(message), + recycle: async () => { + await page.close(); + page = await openPage(browser, base); + }, + sample: (batch) => + withDeadline( + page.evaluate(async (cameras) => { + const gev = window.__godsEyeView; + const scene = gev.viewer.scene; + const Cartesian3 = gev.viewer.camera.position.constructor; + const Cartographic = + gev.viewer.camera.positionCartographic.constructor; + const lat = + cameras.reduce((sum, camera) => sum + camera.pose.lat, 0) / + cameras.length; + const lon = + cameras.reduce((sum, camera) => sum + camera.pose.lon, 0) / + cameras.length; + gev.viewer.camera.setView({ + destination: Cartesian3.fromDegrees(lon, lat, 3000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + const cartographics = cameras.flatMap((camera) => + camera.points.map((point) => + Cartographic.fromDegrees(point.lon, point.lat), + ), + ); + const sampled = + await scene.sampleHeightMostDetailed(cartographics); + return cameras.map((_, i) => + sampled + .slice(i * 10, (i + 1) * 10) + .map((point) => + Number.isFinite(point.height) ? point.height : null, + ), + ); + }, batch), + ), + }; + for (const cell of cells) { + const cellStarted = Date.now(); + const entries = await sampleCell( + cell.cameras, + sampler, + undefined, + sidecar?.cameras, + ); + sidecar = mergeSidecar(sidecar, entries, new Date().toISOString()); + await writeSidecar(output, sidecar); + const tally = counts(cell.cameras, entries); + console.log( + `cell ${cell.key}: ${cell.cameras.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - cellStarted) / 1000).toFixed(1)} s`, + ); + } + } + } finally { + if (browser) await browser.close(); + } + const entries = sidecar?.cameras || {}; + const tally = counts(selected, entries); + console.log( + `Total: ${selected.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - started) / 1000).toFixed(1)} s`, + ); + for (const kind of [ + ...new Set(selected.map((camera) => camera.sourceKind)), + ].sort()) { + const tally = counts( + selected.filter((camera) => camera.sourceKind === kind), + entries, + ); + console.log(` ${kind}: ${tally.ok} ok/${tally.miss} miss`); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/qa-application.mjs b/scripts/qa-application.mjs new file mode 100644 index 000000000..41b77c8dc --- /dev/null +++ b/scripts/qa-application.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** Prove standalone startup and terminal resource ownership in a real browser. */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; +const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], +}); +try { + const page = await browser.newPage(); + const errors = []; + page.on('console', (message) => { + if ( + message.type() === 'warn' && + /^\[Data\].*(?:destroy|disable) error:/.test(message.text()) + ) { + console.log(message.text()); + } + }); + page.on('pageerror', (error) => errors.push(error.stack || error.message)); + await page.goto(`${url}/?welcome=1`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { + timeout: 60_000, + }); + const before = await page.evaluate(async () => { + const entry = [...document.scripts].find((script) => + /\/src\/main\.js(?:\?|$)/.test(script.src), + ); + const { application } = await import(entry.src); + window.__qaApplication = application; + const app = window.__godsEyeView; + window.__qaComponents = app; + await application.start(); + await app.styleManager.initialRestorePromise; + const enabled = await app.dataManager.setEnabled('local-datacenters', true); + const annotation = await app.annotations.annotate([ + { + type: 'pin', + longitude: -97.74, + latitude: 30.27, + label: 'Lifecycle check', + }, + ]); + return { + status: application.getState().status, + enabled, + layers: app.dataManager.layers.size, + annotationCount: annotation.drawn, + viewerAlive: !app.viewer.isDestroyed(), + credits: Boolean(document.querySelector('#cesium-credits')), + }; + }); + assert.equal(before.status, 'ready'); + assert.equal(before.enabled, true); + assert.ok(before.layers >= 16); + assert.equal(before.annotationCount, 1); + assert.equal(before.viewerAlive, true); + assert.equal(before.credits, true); + console.log( + 'PASS: startup, data registration, annotations and visible attribution', + ); + + const after = await page.evaluate(async () => { + const application = window.__qaApplication; + const app = window.__qaComponents; + const first = application.destroy(); + const samePromise = first === application.destroy(); + await first.catch((error) => { + const describe = (failure) => + failure.errors?.map(describe).join('; ') || failure.message; + throw new Error(describe(error)); + }); + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('resize')); + app.requestRender('after-destroy'); + await new Promise((resolve) => setTimeout(resolve, 1400)); + return { + status: application.getState().status, + samePromise, + viewerDestroyed: app.viewer.isDestroyed(), + layers: app.dataManager.layers.size, + annotations: app.annotations.count(), + governor: app.getRenderGovernorDiagnostics(), + handlesRemoved: + !window.__godsEyeView && + !window.__gevVoiceCommands && + !window.__gevAnnotations, + creditsRemoved: !document.querySelector('#cesium-credits'), + welcomeHidden: !document.querySelector('#first-run-launcher.visible'), + settingsRemoved: !document.querySelector('#key-setup'), + }; + }); + assert.equal(after.status, 'destroyed'); + assert.equal(after.samePromise, true); + assert.equal(after.viewerDestroyed, true); + assert.equal(after.layers, 0); + assert.equal(after.annotations, 0); + assert.equal(after.governor.installed, false); + assert.deepEqual(after.governor.holds, []); + assert.equal(after.handlesRemoved, true); + assert.equal(after.creditsRemoved, true); + assert.equal(after.welcomeHidden, true); + assert.equal(after.settingsRemoved, true); + assert.deepEqual(errors, []); + console.log( + 'PASS: terminal teardown releases runtime owners without late browser errors', + ); +} finally { + await browser.close(); +} diff --git a/scripts/qa-attribution-b12.mjs b/scripts/qa-attribution-b12.mjs index 028878104..8875f1e19 100644 --- a/scripts/qa-attribution-b12.mjs +++ b/scripts/qa-attribution-b12.mjs @@ -34,7 +34,7 @@ mkdirSync(SHOT_DIR, { recursive: true }); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs new file mode 100644 index 000000000..7eb2aeca7 --- /dev/null +++ b/scripts/qa-camera-controls.mjs @@ -0,0 +1,413 @@ +#!/usr/bin/env node +/** Camera-panel browser acceptance with controlled sources, images and terrain. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; +const origin = process.env.QA_BASE_URL || 'http://localhost:4173'; +const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control'; +const sources = [ + { + id: 'qa-camera-a', + name: 'QA Camera A', + lat: 30.2747, + lon: -97.7403, + headingDeg: 30, + }, + { + id: 'qa-camera-b', + name: 'QA Camera B', + lat: 30.2672, + lon: -97.7431, + headingDeg: 200, + }, +].map((camera) => ({ + ...camera, + city: 'Austin', + cityId: 'austin', + provider: 'QA camera fixture', + feedType: 'snapshot', + feedConfigured: true, + pitchDeg: -18, + fovDeg: 60, + rangeM: 300, + mountHeightM: 8, +})); +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +const check = (name, ok, detail) => { + console.log( + `[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`, + ); + if (!ok) failures++; +}; +let delayCameraA = false; +const delayedFrames = new Set(); +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.setRequestInterception(true); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.origin !== new URL(origin).origin) return void request.continue(); + const json = (body) => + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + if (url.pathname === '/api/cctv/sources') return void json({ sources }); + if (url.pathname === '/api/cctv/health') + return void json({ + cameras: sources.map((camera) => ({ + id: camera.id, + status: 'ok', + sourceKind: 'snapshot', + label: 'QA camera fixture', + })), + }); + if (url.pathname.startsWith('/api/cctv/frame/')) { + const cameraA = url.pathname.endsWith('qa-camera-a'); + const respond = () => + request + .respond({ + status: 200, + contentType: 'image/svg+xml', + headers: { 'Cache-Control': 'no-store' }, + body: `QA CAMERA ${cameraA ? 'A' : 'B'}`, + }) + .catch(() => {}); + if (cameraA && delayCameraA) delayedFrames.add(respond); + else void respond(); + return; + } + if (url.pathname === '/api/terrain/heights') { + const points = (url.searchParams.get('points') || '') + .split(';') + .filter(Boolean); + return void json({ + results: points.map((point) => { + const [lon, lat] = point.split(',').map(Number); + return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 }; + }), + }); + } + if (url.pathname === '/api/openai/hud-summary') + return void json({ summary: 'QA camera controls' }); + void request.continue(); + }); + await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._cctvControls && + window.__godsEyeView?.dataManager?.layers?.has('cctv') && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.$eval('[data-collapse-target="cctv-panel"]', (button) => + button.click(), + ); + await page.click('#cctv-enable-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .cameras.length === 2 && + !document.getElementById('cctv-camera-select').disabled, + { timeout: 30000 }, + ); + check( + 'CCTV enable uses the supplied two-camera catalog', + await page.$eval( + '#cctv-camera-select', + (select) => select.options.length === 2 && !select.disabled, + ), + ); + await page.select('#cctv-camera-select', 'qa-camera-a'); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-a' && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + const state = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(), + ); + check( + 'selected camera, decoded preview and source label agree', + await page.evaluate(() => { + const camera = window.__godsEyeView.dataManager.layers + .get('cctv') + .module.getUIState().activeCamera; + return ( + camera.id === 'qa-camera-a' && + document.getElementById('cctv-frame').src.includes('qa-camera-a') && + document + .getElementById('cctv-meta') + .textContent.includes('QA camera fixture') + ); + }), + ); + const coverage = (await state()).coverageMode; + const modes = []; + for (let i = 0; i < 3; i++) { + await page.click('#cctv-coverage-btn'); + modes.push((await state()).coverageMode); + } + check( + 'coverage cycles through all three modes and returns to its starting state', + new Set(modes).size === 3 && modes.at(-1) === coverage, + modes, + ); + const projection = (await state()).showProjection; + await page.click('#cctv-projection-btn'); + check( + 'projection control updates the layer state', + (await state()).showProjection === !projection, + ); + await page.click('#cctv-projection-btn'); + await page.click('#cctv-next-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId === 'qa-camera-b', + ); + await page.click('#cctv-prev-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId === 'qa-camera-a', + ); + check( + 'native Next and Previous return to the original camera', + (await state()).activeCameraId === 'qa-camera-a', + ); + for (const [selector, key] of [ + ['#cctv-auto-hop-btn', 'autoHop'], + ['#cctv-adjust-btn', 'calibrationMode'], + ]) { + const before = (await state())[key]; + await page.click(selector); + const changed = (await state())[key] === !before; + await page.click(selector); + check( + `${key} control changes and restores the layer setting`, + changed && (await state())[key] === before, + ); + } + // Auto Hop can legitimately advance immediately; restore the calibration target. + await page.select('#cctv-camera-select', 'qa-camera-a'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId === 'qa-camera-a', + ); + const heading = (await state()).activeCamera.headingDeg; + const chip = '.cctv-cal-value[data-cal-field="heading"]'; + await page.click(chip); + await page.focus(`${chip} input`); + await page.keyboard.down(selectAllModifier); + await page.keyboard.press('a'); + await page.keyboard.up(selectAllModifier); + await page.keyboard.type(String(heading + 10)); + await page.keyboard.press('Enter'); + check( + 'native calibration editing commits an offset for the selected camera', + Math.abs((await state()).activeCamera.headingDeg - (heading + 10)) < 0.01, + ); + await page.click('#cctv-calib-save-btn'); + check( + 'Save persists the selected camera calibration', + await page.evaluate(() => + Boolean( + JSON.parse( + localStorage.getItem('godsEyeView.cctv.calibration.v2') || '{}', + )['qa-camera-a'], + ), + ), + ); + await page.click('#cctv-calib-reset-btn'); + check( + 'Reset restores the original pose', + Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01, + ); + await page.click(chip); + await page.focus(`${chip} input`); + await page.keyboard.down(selectAllModifier); + await page.keyboard.press('a'); + await page.keyboard.up(selectAllModifier); + await page.keyboard.type('110'); + await page.keyboard.press('Escape'); + check( + 'Escape cancels editing without collapsing CCTV', + (await page.evaluate( + () => + !document.querySelector('.cctv-cal-input') && + !document.getElementById('cctv-panel').classList.contains('collapsed'), + )) && Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01, + ); + await page.click(chip); + await page.evaluate(() => { + const input = document.querySelector('.cctv-cal-input'); + input.value = '110'; + window.__godsEyeView.dataManager.layers + .get('cctv') + .module.selectCamera('qa-camera-b'); + input.dispatchEvent(new Event('blur')); + }); + check( + 'an external camera selection cancels the prior calibration editor', + (await state()).activeCamera.id === 'qa-camera-b' && + Math.abs((await state()).activeCamera.headingDeg - 200) < 0.01 && + (await page.$('.cctv-cal-input')) === null, + ); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-b' && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + await page.setCacheEnabled(false); + delayCameraA = true; + await page.select('#cctv-camera-select', 'qa-camera-a'); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-a' && + document.getElementById('cctv-frame').dataset.loading === 'true', + ); + await page.evaluate(() => { + const image = + window.__godsEyeView.styleManager._cctvControls._cctvFramePreloader; + window.__qaLateCctvImage = image; + window.__qaLateCctvImageLoaded = false; + image.addEventListener( + 'load', + () => { + window.__qaLateCctvImageLoaded = true; + }, + { once: true }, + ); + }); + check( + 'a newly selected camera clears old pixels while its frame loads', + await page.$eval( + '#cctv-frame-wrap', + (element) => !element.classList.contains('has-frame'), + ), + ); + await page.select('#cctv-camera-select', 'qa-camera-b'); + await page.waitForFunction( + () => + document.getElementById('cctv-frame').dataset.cameraId === + 'qa-camera-b' && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + delayCameraA = false; + await Promise.all([...delayedFrames].map((respond) => respond())); + delayedFrames.clear(); + await page.waitForFunction(() => window.__qaLateCctvImageLoaded); + check( + 'late frames cannot overwrite the latest camera selection', + await page.$eval( + '#cctv-frame', + (image) => + image.dataset.cameraId === 'qa-camera-b' && + image.src.includes('qa-camera-b'), + ), + ); + delayCameraA = false; + await page.click('#cctv-enable-btn'); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const disabledCleanly = await page.$eval( + '#cctv-frame', + (image) => !image.getAttribute('src') && image.dataset.cameraId === '', + ); + await page.click('#cctv-enable-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.isEnabled('cctv') && + document + .getElementById('cctv-frame-wrap') + .classList.contains('has-frame'), + ); + check( + 'disable clears the preview and re-enable reacquires the camera frame', + disabledCleanly, + ); + await page.$eval('#cctv-frame', (image) => + image.scrollIntoView({ block: 'nearest' }), + ); + fs.mkdirSync('qa-shots/camera-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' }); + await page.evaluate(() => { + const camera = window.__godsEyeView.viewer.camera; + camera.setView({ + orientation: { + heading: camera.heading + Math.PI / 3, + pitch: -0.6, + roll: 0, + }, + }); + }); + await new Promise((resolve) => setTimeout(resolve, 700)); + await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); + await page.setViewport({ width: 390, height: 844 }); + await new Promise((resolve) => setTimeout(resolve, 700)); + await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); + check( + 'narrow camera panel remains within the viewport', + await page.$eval('#cctv-panel', (panel) => { + const rect = panel.getBoundingClientRect(); + return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 1; + }), + ); + check( + 'rebuilding Radio preserves the active camera controller and subscription', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + const camera = ui._cctvControls; + const cameraId = camera.getState()?.activeCameraId; + ui._initRadioPanel(); + ui._radioControls.connect(); + return ( + ui._cctvControls === camera && + !camera.destroyed && + Boolean(camera._cctvUnsubscribe) && + camera.getState()?.activeCameraId === cameraId + ); + }), + ); + await page.evaluate(() => window.__godsEyeView.styleManager.dispose()); + check( + 'CCTV controller is destroyed with the real UI', + await page.evaluate( + () => window.__godsEyeView.styleManager._cctvControls.destroyed, + ), + ); + check( + 'camera controls produce no uncaught browser errors', + errors.length === 0, + errors, + ); +} finally { + await Promise.all([...delayedFrames].map((respond) => respond())); + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 17250d487..08995603f 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -56,21 +56,18 @@ * side-effect-free export with no Cesium/scene dependency) to use as a * Node-side ground-truth oracle — see the "safe pose" search below. * - * Ground-clamp caveat (discovered writing this harness, see task report): - * Austin's fabricated pose priors (pitch -18/-24°, FOV 44/56°, range 145/210m, - * mount 8/10m — vite.config.js's two "personalities") routinely put the far - * cap's bottom edge below ground truth. `computeFrustumGeometry` clamps each - * WIREFRAME corner independently to `groundAlt + FRUSTUM_GROUND_CLEARANCE_M`, - * but the PLANE entity (`createProjectionRuntime`/`updatePlanePlacement`) is - * always an undistorted rectangle (fixed halfW/halfH, unclamped tilt) sitting - * at the (possibly-clamped) capCenter. When the pose clamps asymmetrically, - * the plane and the wireframe corners genuinely do NOT coincide — this is a - * real property of the shipped geometry, not a harness bug. To test the - * intended "coincide" contract deterministically (independent of which - * camera activates), this harness first applies a temporary calibration - * patch (large upward pitch offset + shrunk FOV + minimum range) computed - * via a Node-side search against the SAME `computeFrustumGeometry` oracle, - * chosen to clear the ground clamp with margin, then resets it afterward. + * Ground-clearance note (2026-09-13): `computeFrustumGeometry` lifts the + * whole far cap RIGIDLY by the largest clearance deficit over a 3×3 grid of + * support points against the ground under each (the mount's ground where no + * footprint measurement exists), so the plane and the wireframe corners + * always coincide and the bottom edge clears the ground it was measured + * against (except where the footprint lift cap, PLANE_FOOTPRINT_LIFT_CAP_M, + * lets a plane accept a tall building under its far edge). Austin's fabricated pose priors (pitch -18/-24°, FOV + * 44/56°, range 145/210 m, mount 8/10 m) therefore render as planes lifted + * tens of metres above their mount. The "safe pose" search below predates + * that change: it still guarantees a pose that clears the ground with margin + * so the coincide check is deterministic whichever camera activates, but it + * is no longer needed to avoid a plane/wireframe disagreement. * * Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173 * @@ -81,7 +78,10 @@ import puppeteer from 'puppeteer'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { computeFrustumGeometry, FRUSTUM_GROUND_CLEARANCE_M } from '../src/data/cctv.js'; +import { + computeFrustumGeometry, + FRUSTUM_GROUND_CLEARANCE_M, +} from '../src/data/cctv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -94,7 +94,9 @@ const getOpt = (name, 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 CCTV_URL = new URL(getOpt('--url', BASE_URL)); +CCTV_URL.searchParams.set('welcome', '0'); +const APP_URL = CCTV_URL.href; const HEADFUL = getFlag('--headful'); const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2'); @@ -106,7 +108,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', @@ -126,7 +128,12 @@ function findChromeExecutable() { 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'; + const tag = + ok === null + ? '\x1b[33mINCONCLUSIVE\x1b[0m' + : ok + ? '\x1b[32mPASS\x1b[0m' + : '\x1b[31mFAIL\x1b[0m'; console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); } @@ -138,13 +145,16 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // few frames so the real press/drag still exercises Cesium's input + pick path. async function positionPointerForGizmoDrag(page, point, frameCount = 3) { await page.evaluate(() => { - window.__godsEyeView.dataManager.layers.get('cctv').module - .setParams({ calibrationMode: false }); + window.__godsEyeView.dataManager.layers + .get('cctv') + .module.setParams({ calibrationMode: false }); }); await page.mouse.move(point.x, point.y); return page.evaluate(async (count) => { const gev = window.__godsEyeView; - gev.dataManager.layers.get('cctv').module.setParams({ calibrationMode: true }); + gev.dataManager.layers + .get('cctv') + .module.setParams({ calibrationMode: true }); const scene = gev.viewer.scene; let rendered = 0; return new Promise((resolve) => { @@ -270,20 +280,23 @@ const BEARING_SHIFT_SRC = ` * @returns {Promise} True if tilesLoaded was observed within the timeout. */ 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 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; // no tileset in the scene — nothing to wait for - }, - { timeout: timeoutMs } - ).then(() => true).catch(() => false); + return true; // no tileset in the scene — nothing to wait for + }, + { timeout: timeoutMs }, + ) + .then(() => true) + .catch(() => false); } /** @@ -308,9 +321,22 @@ function findSafeCalibrationPatch(basePose, groundAltM) { const clampFloor = groundAltM + FRUSTUM_GROUND_CLEARANCE_M; for (let fovOffset = 0; fovOffset >= -50; fovOffset -= 2) { const fovDeg = Math.max(20, Math.min(130, basePose.fovDeg + fovOffset)); - const camera = { lat: 30, lon: -97, headingDeg: 0, pitchDeg, fovDeg, rangeM, mountHeightM: basePose.mountHeightM }; + const camera = { + lat: 30, + lon: -97, + headingDeg: 0, + pitchDeg, + fovDeg, + rangeM, + mountHeightM: basePose.mountHeightM, + }; const geom = computeFrustumGeometry(camera, groundAltM, rangeM); - const minAlt = Math.min(geom.corners.tl.alt, geom.corners.tr.alt, geom.corners.br.alt, geom.corners.bl.alt); + const minAlt = Math.min( + geom.corners.tl.alt, + geom.corners.tr.alt, + geom.corners.br.alt, + geom.corners.bl.alt, + ); if (minAlt > clampFloor + 3) { return { pitchDeg: pitchOffset, fovDeg: fovOffset, rangeScale }; } @@ -327,7 +353,9 @@ async function main() { 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`); + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); process.exit(2); } @@ -358,7 +386,11 @@ async function main() { 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)) { + if ( + !/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test( + t, + ) + ) { consoleErrors.push(t); } } @@ -366,8 +398,11 @@ async function main() { 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 } + () => + window.__godsEyeView && + window.__godsEyeView.viewer && + window.__godsEyeView.dataManager, + { timeout: 60000 }, ); // Let the initial fly-to Austin and first tiles settle. await sleep(4000); @@ -382,7 +417,11 @@ async function main() { // ----------------------------------------------------------------------- const wrapInstalled = await page.evaluate(() => { const scene = window.__godsEyeView?.viewer?.scene; - if (!scene || typeof scene.pickFromRay !== 'function' || typeof scene.sampleHeight !== 'function') { + if ( + !scene || + typeof scene.pickFromRay !== 'function' || + typeof scene.sampleHeight !== 'function' + ) { return false; } window.__qaCounters = { pickFromRay: 0, sampleHeight: 0 }; @@ -399,11 +438,14 @@ async function main() { return true; }); if (!wrapInstalled) { - console.error('\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m'); + console.error( + '\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m', + ); process.exit(2); } - const readCounters = () => page.evaluate(() => ({ ...window.__qaCounters })); + const readCounters = () => + page.evaluate(() => ({ ...window.__qaCounters })); console.log('Enabling CCTV layer...'); const c0 = await readCounters(); @@ -419,42 +461,64 @@ async function main() { // and the one-shot tiles-ready completion pass can re-enqueue the whole // catalog once — 30s was calibrated for the old 36-camera default and // times out spuriously at the 250-camera default (2026-07-04). - const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count); + const camCount = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .count, + ); // ~800ms per real ground sample measured under SwiftShader (each // scene.sampleHeight forces tile loads at the probe point) — real GPU is // far faster, so this is a headless-CI ceiling, not an app expectation. const drainBudgetMs = HEADFUL ? Math.max(30000, camCount * 800 + 30000) : Math.min(120000, Math.max(30000, camCount * 800 + 30000)); - console.log(`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`); - 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: drainBudgetMs } - ).then(() => true).catch(() => false); - record(`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, - drained ? true : (HEADFUL ? false : null), + console.log( + `Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`, + ); + 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: drainBudgetMs }, + ) + .then(() => true) + .catch(() => false); + record( + `geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, + drained ? true : HEADFUL ? false : null, drained ? 'loading.active === false' : HEADFUL ? 'timed out waiting for drain on the real-GPU sign-off path' - : 'headless GL geometry sampling did not settle; current headful evidence is required'); + : 'headless GL geometry sampling did not settle; current headful evidence is required', + ); const cAfterDrain = await readCounters(); - record('pickFromRay stays 0 before any activation', cAfterDrain.pickFromRay - c0.pickFromRay === 0, - `Δ=${cAfterDrain.pickFromRay - c0.pickFromRay}`); + record( + 'pickFromRay stays 0 before any activation', + cAfterDrain.pickFromRay - c0.pickFromRay === 0, + `Δ=${cAfterDrain.pickFromRay - c0.pickFromRay}`, + ); // Every camera maps to at most one shared coarse cell, so ≤1×N remains a // safe ceiling while nearby cameras reuse the same accepted floor. const sampleAfterDrain = cAfterDrain.sampleHeight - c0.sampleHeight; - record(`sampleHeight <= 1×N after drain (N=${camCount})`, sampleAfterDrain <= camCount, - `Δ=${sampleAfterDrain}, ceiling=${camCount} (one-shot shared mesh-floor cell)`); + record( + `sampleHeight <= 1×N after drain (N=${camCount})`, + sampleAfterDrain <= camCount, + `Δ=${sampleAfterDrain}, ceiling=${camCount} (one-shot shared mesh-floor cell)`, + ); // Pick the active camera (or nearest) to activate. console.log('Activating a camera (focusNearest)...'); - const activeIdBeforeActivation = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().activeCameraId); + const activeIdBeforeActivation = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId, + ); const cBeforeActivate = await readCounters(); await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -462,11 +526,19 @@ async function main() { }); await sleep(500); // let the activation's synchronous work (probe + geometry rewrite) land const cAfterActivate = await readCounters(); - const activeId = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().activeCameraId); + const activeId = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState() + .activeCameraId, + ); - const pickDeltaActivation = cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay; - record('pickFromRay fires exactly once for the activation (§9.1 probe)', pickDeltaActivation === 1, - `Δ=${pickDeltaActivation} (camera=${activeId}, was=${activeIdBeforeActivation})`); + const pickDeltaActivation = + cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay; + record( + 'pickFromRay fires exactly once for the activation (§9.1 probe)', + pickDeltaActivation === 1, + `Δ=${pickDeltaActivation} (camera=${activeId}, was=${activeIdBeforeActivation})`, + ); // Re-selecting the ALREADY-ACTIVE camera is a no-op (owner field test // 2026-07-04: every click on the monitor plane picks its own camera, and @@ -477,7 +549,9 @@ async function main() { return JSON.stringify(serializeGeom(${JSON.stringify(activeId)})); })()`); await page.evaluate((id) => { - window.__godsEyeView.dataManager.layers.get('cctv').module.setParams({ selectedCameraId: id }); + window.__godsEyeView.dataManager.layers + .get('cctv') + .module.setParams({ selectedCameraId: id }); }, activeId); await sleep(400); const cAfterReselect = await readCounters(); @@ -485,9 +559,12 @@ async function main() { ${SERIALIZE_GEOM_SRC} return JSON.stringify(serializeGeom(${JSON.stringify(activeId)})); })()`); - record('re-selecting the active camera is a no-op (no probe, no geometry rewrite)', - cAfterReselect.pickFromRay - cAfterActivate.pickFromRay === 0 && geomBeforeReselect === geomAfterReselect, - `probeΔ=${cAfterReselect.pickFromRay - cAfterActivate.pickFromRay}, geometryChanged=${geomBeforeReselect !== geomAfterReselect}`); + record( + 're-selecting the active camera is a no-op (no probe, no geometry rewrite)', + cAfterReselect.pickFromRay - cAfterActivate.pickFromRay === 0 && + geomBeforeReselect === geomAfterReselect, + `probeΔ=${cAfterReselect.pickFromRay - cAfterActivate.pickFromRay}, geometryChanged=${geomBeforeReselect !== geomAfterReselect}`, + ); // ----------------------------------------------------------------------- // Completion FLOOR (locks update()'s one-shot tiles-ready re-enqueue): @@ -502,18 +579,23 @@ async function main() { const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); let sampleFloorOk = false; if (tilesSeenLoaded) { - sampleFloorOk = await page.waitForFunction( - (base) => window.__qaCounters.sampleHeight - base >= 1, - { timeout: 30000 }, - c0.sampleHeight - ).then(() => true).catch(() => false); + sampleFloorOk = await page + .waitForFunction( + (base) => window.__qaCounters.sampleHeight - base >= 1, + { timeout: 30000 }, + c0.sampleHeight, + ) + .then(() => true) + .catch(() => false); } const cFloor = await readCounters(); - record('>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', + record( + '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', tilesSeenLoaded ? sampleFloorOk : null, tilesSeenLoaded ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` - : 'tilesLoaded never observed within 45s — floor not assessable this run'); + : 'tilesLoaded never observed within 45s — floor not assessable this run', + ); // Let the completion pass finish before the steady-state window: require // the sampleHeight counter quiet for 12s (> the 10s update interval, so @@ -529,7 +611,9 @@ async function main() { let completionPassQuiet = false; { const settleBudgetMs = Math.max(60000, camCount * 1200 + 30000); - console.log(`Waiting for the one-shot completion pass to go quiet (budget ${Math.round(settleBudgetMs / 1000)}s)...`); + console.log( + `Waiting for the one-shot completion pass to go quiet (budget ${Math.round(settleBudgetMs / 1000)}s)...`, + ); let last = (await readCounters()).sampleHeight; let quietMs = 0; const settleStart = Date.now(); @@ -557,13 +641,18 @@ async function main() { const cIdleEnd = await readCounters(); const pickDuringIdle = cIdleEnd.pickFromRay - cIdleStart.pickFromRay; const sampleDuringIdle = cIdleEnd.sampleHeight - cIdleStart.sampleHeight; - record('pickFromRay does not grow during 15s steady-state idle', pickDuringIdle === 0, - `Δ=${pickDuringIdle}`); - record('sampleHeight stays flat during 15s steady-state idle', + record( + 'pickFromRay does not grow during 15s steady-state idle', + pickDuringIdle === 0, + `Δ=${pickDuringIdle}`, + ); + record( + 'sampleHeight stays flat during 15s steady-state idle', completionPassQuiet ? sampleDuringIdle === 0 : null, completionPassQuiet ? `Δ=${sampleDuringIdle}` - : `one-shot floor completion did not become quiet under this GL stack; observed tail Δ=${sampleDuringIdle}`); + : `one-shot floor completion did not become quiet under this GL stack; observed tail Δ=${sampleDuringIdle}`, + ); // ----------------------------------------------------------------------- // A rotational calibration edit preserves the existing shared floor and @@ -573,19 +662,26 @@ async function main() { const cBeforeCal = await readCounters(); await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; - mod.setParams({ calibration: { cameraId: camId, patch: { headingDeg: 30 } } }); + mod.setParams({ + calibration: { cameraId: camId, patch: { headingDeg: 30 } }, + }); }, activeId); await sleep(500); const cAfterCal = await readCounters(); const sampleDeltaCal = cAfterCal.sampleHeight - cBeforeCal.sampleHeight; const pickDeltaCal = cAfterCal.pickFromRay - cBeforeCal.pickFromRay; - record('sampleHeight stays flat after a heading-only calibration edit', + record( + 'sampleHeight stays flat after a heading-only calibration edit', completionPassQuiet ? sampleDeltaCal === 0 : null, completionPassQuiet ? `Δ=${sampleDeltaCal}` - : `one-shot floor completion was still unresolved; observed tail Δ=${sampleDeltaCal}`); - record('pickFromRay does not fire on a calibration patch (activation-only)', pickDeltaCal === 0, - `Δ=${pickDeltaCal}`); + : `one-shot floor completion was still unresolved; observed tail Δ=${sampleDeltaCal}`, + ); + record( + 'pickFromRay does not fire on a calibration patch (activation-only)', + pickDeltaCal === 0, + `Δ=${pickDeltaCal}`, + ); // Undo that patch before the geometry-contract assertions below so they // observe the record's steady, unpatched frustum. @@ -613,11 +709,25 @@ async function main() { return g; })(${JSON.stringify(activeId)})`); - const allPolylinesPresent = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'].every((r) => Array.isArray(geomInfo.poly[r])); - record('all 5 frustum polyline entities exist for the active camera', allPolylinesPresent, - Object.keys(geomInfo.poly).map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`).join(', ')); - record('plane + plane-label entities exist for the active camera', !!geomInfo.plane && geomInfo.hasLabel, - `plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`); + const allPolylinesPresent = [ + 'ray-tl', + 'ray-tr', + 'ray-br', + 'ray-bl', + 'cap', + ].every((r) => Array.isArray(geomInfo.poly[r])); + record( + 'all 5 frustum polyline entities exist for the active camera', + allPolylinesPresent, + Object.keys(geomInfo.poly) + .map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`) + .join(', '), + ); + record( + 'plane + plane-label entities exist for the active camera', + !!geomInfo.plane && geomInfo.hasLabel, + `plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`, + ); // Plane corner positions must coincide with the 4 corner-ray endpoints // (ε < 0.5m) — but ONLY when the frustum doesn't hit the ground clamp @@ -632,10 +742,18 @@ async function main() { return mod.getUIState().cameras.find((c) => c.id === camId); }, activeId); const basePose = activeCameraForPatch.basePose; - const groundAltMForPatch = activeCameraForPatch.elevationM - activeCameraForPatch.mountHeightM; - const safePatch = basePose ? findSafeCalibrationPatch(basePose, groundAltMForPatch) : null; - record('found a calibration patch that clears the ground clamp (test setup, not an app assertion)', safePatch !== null, - safePatch ? JSON.stringify(safePatch) : 'no legal offset combination cleared the clamp — corner-coincidence check below will run against the (possibly clamped) base pose'); + const groundAltMForPatch = + activeCameraForPatch.elevationM - activeCameraForPatch.mountHeightM; + const safePatch = basePose + ? findSafeCalibrationPatch(basePose, groundAltMForPatch) + : null; + record( + 'found a calibration patch that clears the ground clamp (test setup, not an app assertion)', + safePatch !== null, + safePatch + ? JSON.stringify(safePatch) + : 'no legal offset combination cleared the clamp — corner-coincidence check below will run against the (possibly clamped) base pose', + ); // updateRecordGeometry's B9c guard now recomputes PURELY from the cached // real ground when tilesLoaded flips false at the instant of the patch @@ -649,21 +767,36 @@ async function main() { let geomForCorners = geomInfo; let patchGeometryApplied = !safePatch; if (safePatch) { - const dimsBefore = geomInfo.plane ? JSON.stringify(geomInfo.plane.dimensions) : null; + const dimsBefore = geomInfo.plane + ? JSON.stringify(geomInfo.plane.dimensions) + : null; for (let attempt = 0; attempt < 3 && !patchGeometryApplied; attempt++) { await waitForTilesLoaded(page, 15000); - await page.evaluate(({ camId, patch }) => { - const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; - mod.setParams({ calibration: { cameraId: camId, patch } }); - }, { camId: activeId, patch: safePatch }); + await page.evaluate( + ({ camId, patch }) => { + const mod = + window.__godsEyeView.dataManager.layers.get('cctv').module; + mod.setParams({ calibration: { cameraId: camId, patch } }); + }, + { camId: activeId, patch: safePatch }, + ); await sleep(400); - geomForCorners = await page.evaluate(`(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(activeId)})`); - const dimsAfter = geomForCorners.plane ? JSON.stringify(geomForCorners.plane.dimensions) : null; + geomForCorners = await page.evaluate( + `(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(activeId)})`, + ); + const dimsAfter = geomForCorners.plane + ? JSON.stringify(geomForCorners.plane.dimensions) + : null; patchGeometryApplied = !!dimsAfter && dimsAfter !== dimsBefore; } } - record('calibration patch geometry landed (test setup, not an app assertion)', patchGeometryApplied, - patchGeometryApplied ? 'plane dimensions changed vs pre-patch' : 'plane dimensions never changed — tiles kept flipping mid-stream; corner-coincidence check below may be running against a stale pose'); + record( + 'calibration patch geometry landed (test setup, not an app assertion)', + patchGeometryApplied, + patchGeometryApplied + ? 'plane dimensions changed vs pre-patch' + : 'plane dimensions never changed — tiles kept flipping mid-stream; corner-coincidence check below may be running against a stale pose', + ); let cornerEps = null; if (allPolylinesPresent && geomForCorners.plane) { @@ -681,7 +814,8 @@ async function main() { iz * qw + iw * -qz + ix * -qy - iy * -qx, ]; } - const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + const dist = (a, b) => + Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); const { poly, plane } = geom; const center = plane.position; const halfW = plane.dimensions[0] / 2; @@ -692,11 +826,25 @@ async function main() { const corner = (sx, sy) => { const local = [sx * halfW, sy * halfH, 0]; const world = rotate(plane.orientation, local); - return [center[0] + world[0], center[1] + world[1], center[2] + world[2]]; + return [ + center[0] + world[0], + center[1] + world[1], + center[2] + world[2], + ]; + }; + const planeCorners = { + tl: corner(-1, 1), + tr: corner(1, 1), + br: corner(1, -1), + bl: corner(-1, -1), }; - const planeCorners = { tl: corner(-1, 1), tr: corner(1, 1), br: corner(1, -1), bl: corner(-1, -1) }; const rayEndpoint = (role) => poly[role][poly[role].length - 1]; - const rayCorners = { tl: rayEndpoint('ray-tl'), tr: rayEndpoint('ray-tr'), br: rayEndpoint('ray-br'), bl: rayEndpoint('ray-bl') }; + const rayCorners = { + tl: rayEndpoint('ray-tl'), + tr: rayEndpoint('ray-tr'), + br: rayEndpoint('ray-br'), + bl: rayEndpoint('ray-bl'), + }; return { tl: dist(planeCorners.tl, rayCorners.tl), tr: dist(planeCorners.tr, rayCorners.tr), @@ -708,11 +856,17 @@ async function main() { const epsLabel = safePatch ? 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m, ground-clamp-cleared pose)' : 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)'; - record(epsLabel, maxEps < 0.5, - `tl=${cornerEps.tl.toFixed(3)}m tr=${cornerEps.tr.toFixed(3)}m br=${cornerEps.br.toFixed(3)}m bl=${cornerEps.bl.toFixed(3)}m`); + record( + epsLabel, + maxEps < 0.5, + `tl=${cornerEps.tl.toFixed(3)}m tr=${cornerEps.tr.toFixed(3)}m br=${cornerEps.br.toFixed(3)}m bl=${cornerEps.bl.toFixed(3)}m`, + ); } else { - record('plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)', null, - 'skipped — geometry entities missing'); + record( + 'plane corners coincide with wireframe corner-ray endpoints (ε < 0.5m)', + null, + 'skipped — geometry entities missing', + ); } // Restore the base (unpatched) pose before the byte-stability window so @@ -729,16 +883,23 @@ async function main() { // Byte-stable over 30s idle: serialize + compare after 30s. Nothing // resamples on a timer in v2, so this is a pure drift regression. console.log('Checking geometry byte-stability over 30s idle...'); - const serializeGeom = (camId) => page.evaluate(`(function(camId){ + const serializeGeom = (camId) => + page.evaluate(`(function(camId){ ${SERIALIZE_GEOM_SRC} return serializeGeom(camId); })(${JSON.stringify(camId)})`); const geomBefore30s = await serializeGeom(activeId); await sleep(30000); const geomAfter30s = await serializeGeom(activeId); - const geomStable = JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s); - record('geometry is byte-stable over 30s idle (no per-frame/timer resample)', geomStable, - geomStable ? 'identical serialization' : 'serialization CHANGED — see harness output above'); + const geomStable = + JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s); + record( + 'geometry is byte-stable over 30s idle (no per-frame/timer resample)', + geomStable, + geomStable + ? 'identical serialization' + : 'serialization CHANGED — see harness output above', + ); if (!geomStable) { console.log(' before:', JSON.stringify(geomBefore30s)); console.log(' after :', JSON.stringify(geomAfter30s)); @@ -759,14 +920,22 @@ async function main() { const map = raw ? JSON.parse(raw) : {}; return !(camId in map); }, activeId); - record('CAL badge is raw-prior (or curated) before any manual save', baseBadge !== 'calibrated', - `calBadge=${baseBadge}`); - record('v2 store has no entry for this camera before any manual save', baseStoreEmpty, - baseStoreEmpty ? 'no entry' : 'unexpected pre-existing entry'); + record( + 'CAL badge is raw-prior (or curated) before any manual save', + baseBadge !== 'calibrated', + `calBadge=${baseBadge}`, + ); + record( + 'v2 store has no entry for this camera before any manual save', + baseStoreEmpty, + baseStoreEmpty ? 'no entry' : 'unexpected pre-existing entry', + ); await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; - mod.setParams({ calibration: { cameraId: camId, patch: { headingDeg: 30 } } }); + mod.setParams({ + calibration: { cameraId: camId, patch: { headingDeg: 30 } }, + }); }, activeId); await sleep(300); @@ -778,14 +947,29 @@ async function main() { const map = raw ? JSON.parse(raw) : {}; const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === camId); - return { stored: camId in map, calDirty: cam?.calDirty, calBadge: cam?.calBadge }; + return { + stored: camId in map, + calDirty: cam?.calDirty, + calBadge: cam?.calBadge, + }; }, activeId); - record('patch does NOT write the v2 store (save-gated)', afterPatch.stored === false, - afterPatch.stored ? 'entry written on patch — save-gating broken' : 'store untouched'); - record('patch marks the camera calDirty (EDITED chip)', afterPatch.calDirty === true, - `calDirty=${afterPatch.calDirty}`); - record('calBadge stays non-calibrated after an unsaved patch', afterPatch.calBadge !== 'calibrated', - `calBadge=${afterPatch.calBadge}`); + record( + 'patch does NOT write the v2 store (save-gated)', + afterPatch.stored === false, + afterPatch.stored + ? 'entry written on patch — save-gating broken' + : 'store untouched', + ); + record( + 'patch marks the camera calDirty (EDITED chip)', + afterPatch.calDirty === true, + `calDirty=${afterPatch.calDirty}`, + ); + record( + 'calBadge stays non-calibrated after an unsaved patch', + afterPatch.calBadge !== 'calibrated', + `calBadge=${afterPatch.calBadge}`, + ); await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; @@ -798,16 +982,28 @@ async function main() { const map = raw ? JSON.parse(raw) : {}; return map[camId] || null; }, activeId); - record('v2 localStorage key written with source:manual + savedAt on SAVE', !!(storeEntry && storeEntry.source === 'manual' && Number.isFinite(storeEntry.savedAt)), - storeEntry ? `source=${storeEntry.source} savedAt=${storeEntry.savedAt}` : 'no entry written'); + record( + 'v2 localStorage key written with source:manual + savedAt on SAVE', + !!( + storeEntry && + storeEntry.source === 'manual' && + Number.isFinite(storeEntry.savedAt) + ), + storeEntry + ? `source=${storeEntry.source} savedAt=${storeEntry.savedAt}` + : 'no entry written', + ); const afterSave = await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === camId); return { calBadge: cam?.calBadge, calDirty: cam?.calDirty }; }, activeId); - record('calBadge flips to calibrated after SAVE (and dirty clears)', afterSave.calBadge === 'calibrated' && afterSave.calDirty === false, - `calBadge=${afterSave.calBadge} calDirty=${afterSave.calDirty}`); + record( + 'calBadge flips to calibrated after SAVE (and dirty clears)', + afterSave.calBadge === 'calibrated' && afterSave.calDirty === false, + `calBadge=${afterSave.calBadge} calDirty=${afterSave.calDirty}`, + ); // Geometry rotated: cap-center bearing from the mount should have shifted // ~30 degrees vs the pre-patch baseline (shared BEARING_SHIFT_SRC helper @@ -818,8 +1014,11 @@ async function main() { return bearingShiftDeg(before, after); })(${JSON.stringify(baseGeom)}, ${JSON.stringify(geomAfterCal)})`); const rotatedByAbout30 = Math.abs(Math.abs(bearingShiftDegPatch) - 30) < 5; - record('geometry cap-center bearing shifts ~30° after headingDeg+30 patch', rotatedByAbout30, - `Δbearing=${bearingShiftDegPatch.toFixed(2)}°`); + record( + 'geometry cap-center bearing shifts ~30° after headingDeg+30 patch', + rotatedByAbout30, + `Δbearing=${bearingShiftDegPatch.toFixed(2)}°`, + ); // Reset: entry removed, base geometry restored, badge back to raw-prior. await page.evaluate((camId) => { @@ -833,15 +1032,21 @@ async function main() { const map = raw ? JSON.parse(raw) : {}; return camId in map; }, activeId); - record('v2 localStorage entry removed after reset', !storeAfterReset, - storeAfterReset ? 'entry still present' : 'removed'); + record( + 'v2 localStorage entry removed after reset', + !storeAfterReset, + storeAfterReset ? 'entry still present' : 'removed', + ); const badgeAfterReset = await page.evaluate((camId) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; return mod.getUIState().cameras.find((c) => c.id === camId)?.calBadge; }, activeId); - record('calBadge returns to raw-prior (or curated) after reset', badgeAfterReset !== 'calibrated', - `calBadge=${badgeAfterReset}`); + record( + 'calBadge returns to raw-prior (or curated) after reset', + badgeAfterReset !== 'calibrated', + `calBadge=${badgeAfterReset}`, + ); // Reset must undo the heading+30 rotation back to ~0°. Ground now resolves // through the stable shared floor cache instead of repeated raw samples. @@ -850,13 +1055,18 @@ async function main() { ${BEARING_SHIFT_SRC} return bearingShiftDeg(before, after); })(${JSON.stringify(baseGeom)}, ${JSON.stringify(geomAfterReset)})`); - record('cap-center bearing restored to base (~0° vs baseline) after reset', Math.abs(bearingShiftDegReset) < 5, - `Δbearing=${bearingShiftDegReset.toFixed(2)}°`); + record( + 'cap-center bearing restored to base (~0° vs baseline) after reset', + Math.abs(bearingShiftDegReset) < 5, + `Δbearing=${bearingShiftDegReset.toFixed(2)}°`, + ); // ========================================================================= // Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat) // ========================================================================= - console.log('Checking frame-loop health (placeholder -> frame swap, URL family)...'); + console.log( + 'Checking frame-loop health (placeholder -> frame swap, URL family)...', + ); // The plane's material.image is always defined (placeholder canvas from // the moment the runtime is created, real frame content painted onto the // same canvas/video reference once the fetch resolves) — assert it's @@ -868,7 +1078,10 @@ async function main() { const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane'); const mat = planeEnt?.plane?.material; const image = mat?.image; - const resolved = image && typeof image.getValue === 'function' ? image.getValue(time) : image; + const resolved = + image && typeof image.getValue === 'function' + ? image.getValue(time) + : image; return { hasMaterial: !!mat, hasImage: !!resolved, @@ -876,8 +1089,11 @@ async function main() { show: planeEnt?.show, }; }, activeId); - record('plane material + image are defined for the active camera (no blank gap)', materialInfo.hasMaterial && materialInfo.hasImage, - `material=${materialInfo.hasMaterial} image=${materialInfo.hasImage} tag=${materialInfo.tagName} show=${materialInfo.show}`); + record( + 'plane material + image are defined for the active camera (no blank gap)', + materialInfo.hasMaterial && materialInfo.hasImage, + `material=${materialInfo.hasMaterial} image=${materialInfo.hasImage} tag=${materialInfo.tagName} show=${materialInfo.show}`, + ); // Poll /api/cctv/frame/ directly (the same endpoint frameUrlFor // targets) to confirm the backend serves SOMETHING for this camera @@ -887,38 +1103,64 @@ async function main() { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().cameras.find((c) => c.id === camId); const res = await fetch(cam.frameUrl); - return { ok: res.ok, status: res.status, contentType: res.headers.get('content-type'), url: cam.frameUrl, mediaUrl: cam.mediaUrl }; + return { + ok: res.ok, + status: res.status, + contentType: res.headers.get('content-type'), + url: cam.frameUrl, + mediaUrl: cam.mediaUrl, + }; }, activeId); - record('backend serves a frame for the active camera (2xx, image content-type)', frameFetch.ok && /^image\//.test(frameFetch.contentType || ''), - `status=${frameFetch.status} content-type=${frameFetch.contentType} url=${frameFetch.url}`); + record( + 'backend serves a frame for the active camera (2xx, image content-type)', + frameFetch.ok && /^image\//.test(frameFetch.contentType || ''), + `status=${frameFetch.status} content-type=${frameFetch.contentType} url=${frameFetch.url}`, + ); // Panel src and the plane's frame fetch share the same // /api/cctv/frame/ URL family (same endpoint + camera id segment). - const panelImgSrc = await page.evaluate(() => document.getElementById('cctv-frame')?.getAttribute('src') || null); - const sameFamily = !!panelImgSrc && panelImgSrc.includes(`/api/cctv/frame/${encodeURIComponent(activeId)}`); - record('panel and plane share the same /api/cctv/frame/ URL family', sameFamily, - `panelSrc=${panelImgSrc}`); + const panelImgSrc = await page.evaluate( + () => document.getElementById('cctv-frame')?.getAttribute('src') || null, + ); + const sameFamily = + !!panelImgSrc && + panelImgSrc.includes(`/api/cctv/frame/${encodeURIComponent(activeId)}`); + record( + 'panel and plane share the same /api/cctv/frame/ URL family', + sameFamily, + `panelSrc=${panelImgSrc}`, + ); // ========================================================================= // Group 5: installed canvas-click ownership + true-empty deselection // ========================================================================= - console.log('Checking installed canvas click ownership and empty-space deselection...'); + console.log( + 'Checking installed canvas click ownership and empty-space deselection...', + ); const emptyClickPoint = await page.evaluate(async () => { const gev = window.__godsEyeView; const viewer = gev.viewer; const scene = viewer.scene; const canvas = scene.canvas; const rect = canvas.getBoundingClientRect(); - const { hitTestWorldOverlay } = await import('/src/overlays/worldOverlay.js'); + const { hitTestWorldOverlay } = + await import('/src/overlays/worldOverlay.js'); const candidates = [ - [0.55, 0.72], [0.45, 0.72], [0.62, 0.62], [0.38, 0.62], - [0.58, 0.22], [0.42, 0.22], [0.5, 0.52], + [0.55, 0.72], + [0.45, 0.72], + [0.62, 0.62], + [0.38, 0.62], + [0.58, 0.22], + [0.42, 0.22], + [0.5, 0.52], ]; const resolveId = (picked) => { const direct = picked?.id?.id ?? picked?.id; if (direct !== undefined && direct !== null) return String(direct); const primitive = picked?.primitive?.id?.id ?? picked?.primitive?.id; - return primitive === undefined || primitive === null ? null : String(primitive); + return primitive === undefined || primitive === null + ? null + : String(primitive); }; for (const [fx, fy] of candidates) { const x = Math.round(canvas.clientWidth * fx); @@ -926,40 +1168,76 @@ async function main() { const picked = scene.pick({ x, y }); const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' }); const top = document.elementFromPoint(rect.left + x, rect.top + y); - if (resolveId(picked) === null && !card && (top === canvas || canvas.contains(top))) { + if ( + resolveId(picked) === null && + !card && + (top === canvas || canvas.contains(top)) + ) { return { x: rect.left + x, y: rect.top + y, canvasX: x, canvasY: y }; } } return null; }); - record('true-empty canvas target is available (no scene owner or CCTV card)', !!emptyClickPoint, - emptyClickPoint ? `canvas=(${emptyClickPoint.canvasX},${emptyClickPoint.canvasY})` : 'no clean canvas point found'); + record( + 'true-empty canvas target is available (no scene owner or CCTV card)', + !!emptyClickPoint, + emptyClickPoint + ? `canvas=(${emptyClickPoint.canvasX},${emptyClickPoint.canvasY})` + : 'no clean canvas point found', + ); const clickEvidenceSetup = await page.evaluate(() => { const gev = window.__godsEyeView; const viewer = gev.viewer; const mod = gev.dataManager.layers.get('cctv').module; const snapshotPose = () => ({ - position: [viewer.camera.positionWC.x, viewer.camera.positionWC.y, viewer.camera.positionWC.z], - direction: [viewer.camera.directionWC.x, viewer.camera.directionWC.y, viewer.camera.directionWC.z], + position: [ + viewer.camera.positionWC.x, + viewer.camera.positionWC.y, + viewer.camera.positionWC.z, + ], + direction: [ + viewer.camera.directionWC.x, + viewer.camera.directionWC.y, + viewer.camera.directionWC.z, + ], up: [viewer.camera.upWC.x, viewer.camera.upWC.y, viewer.camera.upWC.z], - right: [viewer.camera.rightWC.x, viewer.camera.rightWC.y, viewer.camera.rightWC.z], - transform: Array.from({ length: 16 }, (_, i) => viewer.camera.transform[i]), + right: [ + viewer.camera.rightWC.x, + viewer.camera.rightWC.y, + viewer.camera.rightWC.z, + ], + transform: Array.from( + { length: 16 }, + (_, i) => viewer.camera.transform[i], + ), heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: viewer.camera.roll, trackedId: viewer.trackedEntity?.id ?? null, }); const cameraPoseMatches = (a, b, epsilon = 1e-5) => { - const vectorKeys = ['position', 'direction', 'up', 'right', 'transform']; + const vectorKeys = [ + 'position', + 'direction', + 'up', + 'right', + 'transform', + ]; const scalarKeys = ['heading', 'pitch', 'roll']; - return vectorKeys.every((key) => ( - Array.isArray(a?.[key]) - && Array.isArray(b?.[key]) - && a[key].length === b[key].length - && a[key].every((value, index) => Math.abs(value - b[key][index]) <= epsilon) - )) && scalarKeys.every((key) => Math.abs(a?.[key] - b?.[key]) <= epsilon) - && a?.trackedId === b?.trackedId; + return ( + vectorKeys.every( + (key) => + Array.isArray(a?.[key]) && + Array.isArray(b?.[key]) && + a[key].length === b[key].length && + a[key].every( + (value, index) => Math.abs(value - b[key][index]) <= epsilon, + ), + ) && + scalarKeys.every((key) => Math.abs(a?.[key] - b?.[key]) <= epsilon) && + a?.trackedId === b?.trackedId + ); }; window.__qaCctvClickEvidence?.dispose?.(); const evidence = { @@ -975,25 +1253,34 @@ async function main() { evidence.unsubscribe = mod.subscribe((state) => { evidence.publications.push(state.activeCameraId); if (state.activeCameraId !== evidence.lastActiveId) { - evidence.activeTransitions.push([evidence.lastActiveId, state.activeCameraId]); + evidence.activeTransitions.push([ + evidence.lastActiveId, + state.activeCameraId, + ]); evidence.lastActiveId = state.activeCameraId; } }); evidence.baselineTransitions = evidence.activeTransitions.length; - evidence.onFocus = () => { evidence.focusEvents += 1; }; + evidence.onFocus = () => { + evidence.focusEvents += 1; + }; window.addEventListener('gev:cctv-request-focus', evidence.onFocus); evidence.dispose = () => { evidence.unsubscribe?.(); window.removeEventListener('gev:cctv-request-focus', evidence.onFocus); }; window.__qaCctvClickEvidence = evidence; - return { activeId: evidence.activeId, baselineTransitions: evidence.baselineTransitions }; + return { + activeId: evidence.activeId, + baselineTransitions: evidence.baselineTransitions, + }; }); if (emptyClickPoint) { await page.evaluate(() => { - window.__godsEyeView.dataManager.layers.get('cctv').module - .setParams({ calibrationMode: true }); + window.__godsEyeView.dataManager.layers + .get('cctv') + .module.setParams({ calibrationMode: true }); }); await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); await sleep(250); @@ -1006,16 +1293,25 @@ async function main() { activeId: mod.getUIState().activeCameraId, publications: evidence.publications.length, focusEvents: evidence.focusEvents, - poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), + poseSame: evidence.cameraPoseMatches( + evidence.snapshotPose(), + evidence.pose, + ), }; }); - record('ADJUST-mode true-empty canvas click preserves active camera', - adjustClick.activeId === clickEvidenceSetup.activeId - && adjustClick.focusEvents === 0 - && adjustClick.poseSame, - `active=${adjustClick.activeId} focus=${adjustClick.focusEvents} poseSame=${adjustClick.poseSame}`); + record( + 'ADJUST-mode true-empty canvas click preserves active camera', + adjustClick.activeId === clickEvidenceSetup.activeId && + adjustClick.focusEvents === 0 && + adjustClick.poseSame, + `active=${adjustClick.activeId} focus=${adjustClick.focusEvents} poseSame=${adjustClick.poseSame}`, + ); } else { - record('ADJUST-mode true-empty canvas click preserves active camera', false, 'no true-empty target'); + record( + 'ADJUST-mode true-empty canvas click preserves active camera', + false, + 'no true-empty target', + ); } const siblingTarget = await page.evaluate(async (emptyPoint) => { @@ -1024,15 +1320,28 @@ async function main() { const viewer = gev.viewer; const scene = viewer.scene; const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js'); - const canvasPoint = new Cesium.Cartesian2(emptyPoint.canvasX, emptyPoint.canvasY); + const canvasPoint = new Cesium.Cartesian2( + emptyPoint.canvasX, + emptyPoint.canvasY, + ); const ray = viewer.camera.getPickRay(canvasPoint); if (!ray) return null; let position = null; let projected = null; for (const distance of [1_000, 10_000, 100_000, 1_000_000]) { - const candidate = Cesium.Ray.getPoint(ray, distance, new Cesium.Cartesian3()); - const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, candidate); - if (screen && Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2) { + const candidate = Cesium.Ray.getPoint( + ray, + distance, + new Cesium.Cartesian3(), + ); + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + candidate, + ); + if ( + screen && + Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2 + ) { position = candidate; projected = screen; break; @@ -1069,20 +1378,30 @@ async function main() { return { x: rect.left + emptyPoint.canvasX, y: rect.top + emptyPoint.canvasY, - pickedId: typeof pickedId === 'string' ? pickedId : pickedId?.id ?? null, + pickedId: + typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null), ownerId: owner.id, ownsExactObject: picked?.id === owner, - projectionDelta: Math.hypot(projected.x - canvasPoint.x, projected.y - canvasPoint.y), + projectionDelta: Math.hypot( + projected.x - canvasPoint.x, + projected.y - canvasPoint.y, + ), }; }, emptyClickPoint); - record('sibling test object owns its canvas pick', + record( + 'sibling test object owns its canvas pick', siblingTarget - ? siblingTarget.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject + ? siblingTarget.pickedId === 'qa-cctv-sibling-owner' && + siblingTarget.ownsExactObject : null, siblingTarget ? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}` - : 'no previously verified empty canvas ray was available'); - if (siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject) { + : 'no previously verified empty canvas ray was available', + ); + if ( + siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && + siblingTarget.ownsExactObject + ) { await page.mouse.click(siblingTarget.x, siblingTarget.y); await sleep(250); } @@ -1099,17 +1418,23 @@ async function main() { transitions: evidence.activeTransitions.length, baselineTransitions: evidence.siblingBaselineTransitions, focusEvents: evidence.focusEvents, - poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), + poseSame: evidence.cameraPoseMatches( + evidence.snapshotPose(), + evidence.pose, + ), }; }); - record('sibling canvas click passes through without CCTV selection or deselection', - siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && siblingTarget.ownsExactObject - ? siblingClick.activeId === clickEvidenceSetup.activeId - && siblingClick.transitions === siblingClick.baselineTransitions - && siblingClick.focusEvents === 0 - && siblingClick.poseSame + record( + 'sibling canvas click passes through without CCTV selection or deselection', + siblingTarget?.pickedId === 'qa-cctv-sibling-owner' && + siblingTarget.ownsExactObject + ? siblingClick.activeId === clickEvidenceSetup.activeId && + siblingClick.transitions === siblingClick.baselineTransitions && + siblingClick.focusEvents === 0 && + siblingClick.poseSame : null, - `active=${siblingClick.activeId} transitions=${siblingClick.transitions - siblingClick.baselineTransitions} focus=${siblingClick.focusEvents} poseSame=${siblingClick.poseSame}`); + `active=${siblingClick.activeId} transitions=${siblingClick.transitions - siblingClick.baselineTransitions} focus=${siblingClick.focusEvents} poseSame=${siblingClick.poseSame}`, + ); if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); @@ -1117,7 +1442,9 @@ async function main() { } const firstEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; - const state = window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(); + const state = window.__godsEyeView.dataManager.layers + .get('cctv') + .module.getUIState(); return { activeId: state.activeCameraId, enabled: state.enabled, @@ -1125,20 +1452,30 @@ async function main() { baselineTransitions: evidence.siblingBaselineTransitions, lastTransition: evidence.activeTransitions.at(-1) ?? null, focusEvents: evidence.focusEvents, - poseSame: evidence.cameraPoseMatches(evidence.snapshotPose(), evidence.pose), + poseSame: evidence.cameraPoseMatches( + evidence.snapshotPose(), + evidence.pose, + ), }; }); - record('real true-empty canvas click publishes one active-to-null transition', - !!emptyClickPoint - && firstEmptyClick.activeId === null - && firstEmptyClick.enabled === true - && firstEmptyClick.transitions === firstEmptyClick.baselineTransitions + 1 - && firstEmptyClick.lastTransition?.[0] === clickEvidenceSetup.activeId - && firstEmptyClick.lastTransition?.[1] === null, - `active=${firstEmptyClick.activeId} enabled=${firstEmptyClick.enabled} transitions=${firstEmptyClick.transitions - firstEmptyClick.baselineTransitions}`); - record('real true-empty deselection preserves pose/tracking and emits no focus request', - !!emptyClickPoint && firstEmptyClick.poseSame && firstEmptyClick.focusEvents === 0, - `poseSame=${firstEmptyClick.poseSame} focus=${firstEmptyClick.focusEvents}`); + record( + 'real true-empty canvas click publishes one active-to-null transition', + !!emptyClickPoint && + firstEmptyClick.activeId === null && + firstEmptyClick.enabled === true && + firstEmptyClick.transitions === + firstEmptyClick.baselineTransitions + 1 && + firstEmptyClick.lastTransition?.[0] === clickEvidenceSetup.activeId && + firstEmptyClick.lastTransition?.[1] === null, + `active=${firstEmptyClick.activeId} enabled=${firstEmptyClick.enabled} transitions=${firstEmptyClick.transitions - firstEmptyClick.baselineTransitions}`, + ); + record( + 'real true-empty deselection preserves pose/tracking and emits no focus request', + !!emptyClickPoint && + firstEmptyClick.poseSame && + firstEmptyClick.focusEvents === 0, + `poseSame=${firstEmptyClick.poseSame} focus=${firstEmptyClick.focusEvents}`, + ); if (emptyClickPoint) { await page.mouse.click(emptyClickPoint.x, emptyClickPoint.y); @@ -1146,7 +1483,9 @@ async function main() { } const repeatEmptyClick = await page.evaluate(() => { const evidence = window.__qaCctvClickEvidence; - const state = window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(); + const state = window.__godsEyeView.dataManager.layers + .get('cctv') + .module.getUIState(); evidence.dispose(); delete window.__qaCctvClickEvidence; return { @@ -1156,12 +1495,15 @@ async function main() { focusEvents: evidence.focusEvents, }; }); - record('repeat true-empty canvas click is null-idempotent', - !!emptyClickPoint - && repeatEmptyClick.activeId === null - && repeatEmptyClick.transitions === repeatEmptyClick.baselineTransitions + 1 - && repeatEmptyClick.focusEvents === 0, - `active=${repeatEmptyClick.activeId} transitions=${repeatEmptyClick.transitions - repeatEmptyClick.baselineTransitions} focus=${repeatEmptyClick.focusEvents}`); + record( + 'repeat true-empty canvas click is null-idempotent', + !!emptyClickPoint && + repeatEmptyClick.activeId === null && + repeatEmptyClick.transitions === + repeatEmptyClick.baselineTransitions + 1 && + repeatEmptyClick.focusEvents === 0, + `active=${repeatEmptyClick.activeId} transitions=${repeatEmptyClick.transitions - repeatEmptyClick.baselineTransitions} focus=${repeatEmptyClick.focusEvents}`, + ); // Explicit navigation from null must remain available for the remaining // coverage and gizmo groups. NEXT is the product route pinned by N4. @@ -1170,22 +1512,27 @@ async function main() { mod.cycleCamera(1); return mod.getUIState().activeCameraId; }); - record('explicit NEXT resumes from null at the first catalog camera', + record( + 'explicit NEXT resumes from null at the first catalog camera', typeof resumedId === 'string' && !!resumedId, - `active=${resumedId}`); + `active=${resumedId}`, + ); // ========================================================================= // Group 6: viewshed mode (v3 design §3b — coverage tri-state + volumes) // ========================================================================= - console.log('Checking viewshed mode (coverage tri-state + color-coded volumes)...'); - const countVolumes = () => page.evaluate(() => { - const prims = window.__godsEyeView.viewer.scene.primitives; - let n = 0; - for (let i = 0; i < prims.length; i++) { - if (prims.get(i) && prims.get(i)._gevViewshed) n += 1; - } - return n; - }); + console.log( + 'Checking viewshed mode (coverage tri-state + color-coded volumes)...', + ); + const countVolumes = () => + page.evaluate(() => { + const prims = window.__godsEyeView.viewer.scene.primitives; + let n = 0; + for (let i = 0; i < prims.length; i++) { + if (prims.get(i) && prims.get(i)._gevViewshed) n += 1; + } + return n; + }); const cBeforeViewshed = await readCounters(); const modeAfterSet = await page.evaluate(() => { @@ -1194,12 +1541,18 @@ async function main() { return mod.getUIState().coverageMode; }); await sleep(400); - record('coverageMode setParams round-trip (viewshed)', modeAfterSet === 'viewshed', - `coverageMode=${modeAfterSet}`); + record( + 'coverageMode setParams round-trip (viewshed)', + modeAfterSet === 'viewshed', + `coverageMode=${modeAfterSet}`, + ); const volumesOn = await countVolumes(); - record('viewshed volumes exist for the visible set (1..15)', volumesOn >= 1 && volumesOn <= 15, - `${volumesOn} volume primitives (visible-set cap is 14 + active)`); + record( + 'viewshed volumes exist for the visible set (1..15)', + volumesOn >= 1 && volumesOn <= 15, + `${volumesOn} volume primitives (visible-set cap is 14 + active)`, + ); // Boolean back-compat shim: showCoverage=false → 'off' (0 volumes), // showCoverage=true → 'on' (wireframes, still 0 volumes). @@ -1213,10 +1566,16 @@ async function main() { }); await sleep(200); const volumesInOnMode = await countVolumes(); - record('boolean showCoverage compat (false→off, true→on)', compat.off === 'off' && compat.on === 'on', - `false→${compat.off}, true→${compat.on}`); - record("mode 'on' renders zero volumes (wireframes only)", volumesInOnMode === 0, - `${volumesInOnMode} volumes in 'on' mode`); + record( + 'boolean showCoverage compat (false→off, true→on)', + compat.off === 'off' && compat.on === 'on', + `false→${compat.off}, true→${compat.on}`, + ); + record( + "mode 'on' renders zero volumes (wireframes only)", + volumesInOnMode === 0, + `${volumesInOnMode} volumes in 'on' mode`, + ); // Back to viewshed: volumes must be STABLE while idle (no churn — the // rebuild sites are pose edits and style refreshes only). Object identity @@ -1230,7 +1589,8 @@ async function main() { const prims = window.__godsEyeView.viewer.scene.primitives; window.__qaViewshedRefs = []; for (let i = 0; i < prims.length; i++) { - if (prims.get(i) && prims.get(i)._gevViewshed) window.__qaViewshedRefs.push(prims.get(i)); + if (prims.get(i) && prims.get(i)._gevViewshed) + window.__qaViewshedRefs.push(prims.get(i)); } }); await sleep(8000); @@ -1246,27 +1606,45 @@ async function main() { return { sameCount, sameIdentity, count: now.length }; }); const cAfterViewshed = await readCounters(); - const lateFloorSamples = cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight; - record('viewshed volumes idle-stable over 8s (same primitives, no churn)', + const lateFloorSamples = + cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight; + record( + 'viewshed volumes idle-stable over 8s (same primitives, no churn)', lateFloorSamples > 0 ? null : viewshedIdle.sameIdentity, lateFloorSamples > 0 ? `late one-shot floor completion sampled ${lateFloorSamples} cells and legitimately rebuilt geometry` - : `count=${viewshedIdle.count} sameCount=${viewshedIdle.sameCount} sameIdentity=${viewshedIdle.sameIdentity}`); - record('raycast counters flat across viewshed cycling + idle', - lateFloorSamples > 0 ? null : cAfterViewshed.pickFromRay === cBeforeViewshed.pickFromRay, + : `count=${viewshedIdle.count} sameCount=${viewshedIdle.sameCount} sameIdentity=${viewshedIdle.sameIdentity}`, + ); + record( + 'raycast counters flat across viewshed cycling + idle', + lateFloorSamples > 0 + ? null + : cAfterViewshed.pickFromRay === cBeforeViewshed.pickFromRay, lateFloorSamples > 0 ? `late one-shot floor completion: ΔsampleHeight=${lateFloorSamples}; ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay}` - : `ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay} ΔsampleHeight=0`); + : `ΔpickFromRay=${cAfterViewshed.pickFromRay - cBeforeViewshed.pickFromRay} ΔsampleHeight=0`, + ); const viewshedShot = path.join(SHOTS_DIR, 'cctv-v3-viewshed.png'); await page.screenshot({ path: viewshedShot }); - console.log(` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`); + console.log( + ` screenshot (visual review only) → ${path.relative(REPO_ROOT, viewshedShot)}`, + ); // ========================================================================= // Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag) // ========================================================================= console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...'); - const GIZMO_PARTS = ['ring-heading', 'ring-pitch', 'move-east', 'move-north', 'move-up', 'handle-range', 'handle-fov-l', 'handle-fov-r']; + const GIZMO_PARTS = [ + 'ring-heading', + 'ring-pitch', + 'move-east', + 'move-north', + 'move-up', + 'handle-range', + 'handle-fov-l', + 'handle-fov-r', + ]; const gizmoStates = await page.evaluate((parts) => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; mod.setParams({ calibrationMode: true }); @@ -1276,8 +1654,11 @@ async function main() { return e ? (e.show ? 'shown' : 'hidden') : 'missing'; }); }, GIZMO_PARTS); - record('all 8 gizmo parts exist and show in ADJUST mode', gizmoStates.every((s) => s === 'shown'), - gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' ')); + record( + 'all 8 gizmo parts exist and show in ADJUST mode', + gizmoStates.every((s) => s === 'shown'), + gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' '), + ); // Synthetic CDP drag on the EAST arrow — the owner's field-test // regression. Sample effective elevation after every mouse move: it must @@ -1292,13 +1673,22 @@ async function main() { const h = scene.canvas.clientHeight; const a = scene.cartesianToCanvasCoordinates(pts[0]); const b = scene.cartesianToCanvasCoordinates(pts[pts.length - 1]); - if (!a || !b || !Number.isFinite(a.x) || !Number.isFinite(a.y) || - !Number.isFinite(b.x) || !Number.isFinite(b.y)) return null; + if ( + !a || + !b || + !Number.isFinite(a.x) || + !Number.isFinite(a.y) || + !Number.isFinite(b.x) || + !Number.isFinite(b.y) + ) + return null; const dx = b.x - a.x; const dy = b.y - a.y; const mag = Math.hypot(dx, dy); if (mag < 10) return null; - const candidates = [0.12, 0.2, 0.28, 0.36, 0.44, 0.52, 0.6, 0.68, 0.76, 0.84, 0.92] + const candidates = [ + 0.12, 0.2, 0.28, 0.36, 0.44, 0.52, 0.6, 0.68, 0.76, 0.84, 0.92, + ] .map((t) => ({ x: a.x + dx * t, y: a.y + dy * t })) .filter((p) => p.x > 60 && p.y > 60 && p.x < w - 60 && p.y < h - 60); if (b.x > 60 && b.y > 60 && b.x < w - 60 && b.y < h - 60) { @@ -1311,12 +1701,14 @@ async function main() { const calBeforeDrag = await page.evaluate(() => { const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; const cam = mod.getUIState().activeCamera; - return cam ? { - calibration: cam.calibration, - elevationM: cam.elevationM, - groundResolveCount: cam.groundResolveCount, - groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, - } : null; + return cam + ? { + calibration: cam.calibration, + elevationM: cam.elevationM, + groundResolveCount: cam.groundResolveCount, + groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, + } + : null; }); const transientDragStates = []; @@ -1349,28 +1741,41 @@ async function main() { for (let i = 1; i <= 5; i++) { await page.mouse.move( grab.x + eastDrag.ux * i * 12, - grab.y + eastDrag.uy * i * 12 + grab.y + eastDrag.uy * i * 12, ); await sleep(40); - transientDragStates.push(await page.evaluate(() => { - const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; - const cam = mod.getUIState().activeCamera; - return cam ? { - elevationM: cam.elevationM, - eastOffsetM: cam.calibration?.offsetEastM, - groundMeshSampleRequestCount: cam.groundMeshSampleRequestCount, - } : null; - })); + transientDragStates.push( + await page.evaluate(() => { + const mod = + window.__godsEyeView.dataManager.layers.get('cctv').module; + const cam = mod.getUIState().activeCamera; + return cam + ? { + elevationM: cam.elevationM, + eastOffsetM: cam.calibration?.offsetEastM, + groundMeshSampleRequestCount: + cam.groundMeshSampleRequestCount, + } + : null; + }), + ); } const candidateCountersBeforeRelease = await readCounters(); await page.mouse.up(); await sleep(400); const candidateEast = await page.evaluate(() => { - const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; - return mod.getUIState().activeCamera?.calibration?.offsetEastM ?? null; + const mod = + window.__godsEyeView.dataManager.layers.get('cctv').module; + return ( + mod.getUIState().activeCamera?.calibration?.offsetEastM ?? null + ); }); - if (Number.isFinite(candidateEast) && - Math.abs(candidateEast - (calBeforeDrag?.calibration?.offsetEastM ?? 0)) > 0.05) { + if ( + Number.isFinite(candidateEast) && + Math.abs( + candidateEast - (calBeforeDrag?.calibration?.offsetEastM ?? 0), + ) > 0.05 + ) { countersBeforeRelease = candidateCountersBeforeRelease; break; } @@ -1387,75 +1792,153 @@ async function main() { let ownsPick = null; if (pt) { try { - const picked = viewer.scene.drillPick({ x: pt.x, y: pt.y }, 6, 14, 14) || []; - pickable = picked.some((r) => String(r?.id?.id ?? r?.id ?? '').startsWith('cctv-gizmo-')); + const picked = + viewer.scene.drillPick({ x: pt.x, y: pt.y }, 6, 14, 14) || []; + pickable = picked.some((r) => + String(r?.id?.id ?? r?.id ?? '').startsWith('cctv-gizmo-'), + ); const firstGizmo = picked .map((r) => String(r?.id?.id ?? r?.id ?? '')) .find((id) => id.startsWith('cctv-gizmo-')); ownsPick = firstGizmo === 'cctv-gizmo-move-east'; - } catch { pickable = null; } + } catch { + pickable = null; + } } return { eastOffsetM: cam?.calibration?.offsetEastM ?? null, groundResolveCount: cam?.groundResolveCount ?? null, calDirty: cam?.calDirty ?? null, - stored: cam ? (cam.id in map) : null, + stored: cam ? cam.id in map : null, inputsEnabled: viewer.scene.screenSpaceCameraController.enableInputs, pickable, ownsPick, }; }, eastDrag); - const eastChanged = eastDrag - && Number.isFinite(dragOutcome.eastOffsetM) - && Math.abs(dragOutcome.eastOffsetM - (calBeforeDrag?.calibration?.offsetEastM ?? 0)) > 0.05; - const transientElevations = transientDragStates.map((state) => state?.elevationM).filter(Number.isFinite); - const maxTransientElevationDelta = transientElevations.length && Number.isFinite(calBeforeDrag?.elevationM) - ? Math.max(...transientElevations.map((height) => Math.abs(height - calBeforeDrag.elevationM))) - : Infinity; - const transientSampleDelta = countersBeforeRelease.sampleHeight - countersBeforeDrag.sampleHeight; + const eastChanged = + eastDrag && + Number.isFinite(dragOutcome.eastOffsetM) && + Math.abs( + dragOutcome.eastOffsetM - + (calBeforeDrag?.calibration?.offsetEastM ?? 0), + ) > 0.05; + const transientElevations = transientDragStates + .map((state) => state?.elevationM) + .filter(Number.isFinite); + const maxTransientElevationDelta = + transientElevations.length && Number.isFinite(calBeforeDrag?.elevationM) + ? Math.max( + ...transientElevations.map((height) => + Math.abs(height - calBeforeDrag.elevationM), + ), + ) + : Infinity; + const transientSampleDelta = + countersBeforeRelease.sampleHeight - countersBeforeDrag.sampleHeight; const activeMeshRequestsBeforeRelease = transientDragStates .map((state) => state?.groundMeshSampleRequestCount) .filter(Number.isFinite) .at(-1); - const transientActiveMeshRequestDelta = Number.isFinite(activeMeshRequestsBeforeRelease) - ? activeMeshRequestsBeforeRelease - (calBeforeDrag?.groundMeshSampleRequestCount ?? 0) + const transientActiveMeshRequestDelta = Number.isFinite( + activeMeshRequestsBeforeRelease, + ) + ? activeMeshRequestsBeforeRelease - + (calBeforeDrag?.groundMeshSampleRequestCount ?? 0) : Infinity; if (eastChanged) { - record('east-arrow drag changes the east offset (live, unsaved)', true, - `offsetEastM ${calBeforeDrag?.calibration?.offsetEastM ?? 0} → ${dragOutcome.eastOffsetM}`); - record('east-arrow drag keeps mount elevation frozen until mouse-up', maxTransientElevationDelta < 0.01, - `max transient Δelevation=${maxTransientElevationDelta.toFixed(4)}m over ${transientElevations.length} moves`); - record('east-arrow transient moves issue zero active-camera mesh-floor requests', + record( + 'east-arrow drag changes the east offset (live, unsaved)', + true, + `offsetEastM ${calBeforeDrag?.calibration?.offsetEastM ?? 0} → ${dragOutcome.eastOffsetM}`, + ); + record( + 'east-arrow drag keeps mount elevation frozen until mouse-up', + maxTransientElevationDelta < 0.01, + `max transient Δelevation=${maxTransientElevationDelta.toFixed(4)}m over ${transientElevations.length} moves`, + ); + record( + 'east-arrow transient moves issue zero active-camera mesh-floor requests', transientActiveMeshRequestDelta === 0, - `active-camera request Δ=${transientActiveMeshRequestDelta}; global sampleHeight Δ=${transientSampleDelta} (other cells may finish asynchronously)`); - record('east-arrow release resolves the committed floor exactly once', - dragOutcome.groundResolveCount === (calBeforeDrag?.groundResolveCount ?? 0) + 1, - `ground resolutions ${calBeforeDrag?.groundResolveCount ?? 0} → ${dragOutcome.groundResolveCount}`); - record('drag leaves the camera dirty and the store untouched', dragOutcome.calDirty === true && dragOutcome.stored === false, - `calDirty=${dragOutcome.calDirty} stored=${dragOutcome.stored}`); + `active-camera request Δ=${transientActiveMeshRequestDelta}; global sampleHeight Δ=${transientSampleDelta} (other cells may finish asynchronously)`, + ); + record( + 'east-arrow release resolves the committed floor exactly once', + dragOutcome.groundResolveCount === + (calBeforeDrag?.groundResolveCount ?? 0) + 1, + `ground resolutions ${calBeforeDrag?.groundResolveCount ?? 0} → ${dragOutcome.groundResolveCount}`, + ); + record( + 'drag leaves the camera dirty and the store untouched', + dragOutcome.calDirty === true && dragOutcome.stored === false, + `calDirty=${dragOutcome.calDirty} stored=${dragOutcome.stored}`, + ); } else if (eastDrag) { - const missDetail = dragOutcome.pickable === false - ? 'drill-pick cannot see a gizmo under this GL stack' - : dragOutcome.ownsPick === false - ? 'the east arrow did not own any tested shaft pick' - : 'LEFT_DOWN did not begin an east-arrow drag despite the ownership precondition'; - record('east-arrow drag changes the east offset (live, unsaved)', null, - `${missDetail} — verify on a current headful GPU surface`); - record('east-arrow drag keeps mount elevation frozen until mouse-up', null, 'skipped with drag'); - record('east-arrow transient moves issue zero active-camera mesh-floor requests', null, 'skipped with drag'); - record('east-arrow release resolves the committed floor exactly once', null, 'skipped with drag'); - record('drag leaves the camera dirty and the store untouched', null, 'skipped with drag'); + const missDetail = + dragOutcome.pickable === false + ? 'drill-pick cannot see a gizmo under this GL stack' + : dragOutcome.ownsPick === false + ? 'the east arrow did not own any tested shaft pick' + : 'LEFT_DOWN did not begin an east-arrow drag despite the ownership precondition'; + record( + 'east-arrow drag changes the east offset (live, unsaved)', + null, + `${missDetail} — verify on a current headful GPU surface`, + ); + record( + 'east-arrow drag keeps mount elevation frozen until mouse-up', + null, + 'skipped with drag', + ); + record( + 'east-arrow transient moves issue zero active-camera mesh-floor requests', + null, + 'skipped with drag', + ); + record( + 'east-arrow release resolves the committed floor exactly once', + null, + 'skipped with drag', + ); + record( + 'drag leaves the camera dirty and the store untouched', + null, + 'skipped with drag', + ); } else { - const unavailable = 'no on-screen east-arrow shaft found under this GL stack — verify on real GPU'; - record('east-arrow drag changes the east offset (live, unsaved)', null, unavailable); - record('east-arrow drag keeps mount elevation frozen until mouse-up', null, 'skipped: drag target unavailable'); - record('east-arrow transient moves issue zero active-camera mesh-floor requests', null, 'skipped: drag target unavailable'); - record('east-arrow release resolves the committed floor exactly once', null, 'skipped: drag target unavailable'); - record('drag leaves the camera dirty and the store untouched', null, 'skipped: drag target unavailable'); + const unavailable = + 'no on-screen east-arrow shaft found under this GL stack — verify on real GPU'; + record( + 'east-arrow drag changes the east offset (live, unsaved)', + null, + unavailable, + ); + record( + 'east-arrow drag keeps mount elevation frozen until mouse-up', + null, + 'skipped: drag target unavailable', + ); + record( + 'east-arrow transient moves issue zero active-camera mesh-floor requests', + null, + 'skipped: drag target unavailable', + ); + record( + 'east-arrow release resolves the committed floor exactly once', + null, + 'skipped: drag target unavailable', + ); + record( + 'drag leaves the camera dirty and the store untouched', + null, + 'skipped: drag target unavailable', + ); } - record('camera-controller inputs re-enabled after drag', dragOutcome.inputsEnabled === true, - `enableInputs=${dragOutcome.inputsEnabled}`); + record( + 'camera-controller inputs re-enabled after drag', + dragOutcome.inputsEnabled === true, + `enableInputs=${dragOutcome.inputsEnabled}`, + ); // ADJUST off: parts hidden; leave a clean calibration for the next group. const gizmoOff = await page.evaluate((parts) => { @@ -1468,8 +1951,11 @@ async function main() { return e ? (e.show ? 'shown' : 'hidden') : 'missing'; }); }, GIZMO_PARTS); - record('gizmo parts hide when ADJUST mode turns off', gizmoOff.every((s) => s === 'hidden'), - gizmoOff.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' ')); + record( + 'gizmo parts hide when ADJUST mode turns off', + gizmoOff.every((s) => s === 'hidden'), + gizmoOff.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' '), + ); // ----------------------------------------------------------------------- // Screenshots for human visual review (NOT a pass/fail signal — SwiftShader @@ -1478,11 +1964,16 @@ async function main() { await sleep(1000); const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png'); await page.screenshot({ path: shot }); - console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`); + console.log( + ` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`, + ); // No console errors during the whole exercise. - record('no console errors during CCTV v2 exercise', consoleErrors.length === 0, - consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean'); + record( + 'no console errors during CCTV v2 exercise', + consoleErrors.length === 0, + consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean', + ); for (const r of results) { if (r.ok === false) exitCode = 1; @@ -1495,7 +1986,9 @@ async function main() { 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( + ` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`, + ); console.log('─'.repeat(60) + '\n'); process.exit(exitCode); } diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index d4804d754..bc42ab821 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -46,7 +46,7 @@ * 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, + * Defaults to ANGLE/Metal on macOS and software GL elsewhere. `--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. */ @@ -64,7 +64,7 @@ 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 SWIFTSHADER = argv.includes('--swiftshader') || process.platform !== 'darwin'; const SHOT_DIR = path.resolve('qa-shots/cockpitplates'); /** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */ @@ -139,7 +139,7 @@ const CHROME_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); @@ -222,6 +222,8 @@ async function main() { args: [ '--no-sandbox', '--disable-setuid-sandbox', + // Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall + // back to the software rasterizer or WebGL init fails outright. ...(SWIFTSHADER ? ['--use-gl=angle', '--use-angle=swiftshader'] : ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']), @@ -292,7 +294,7 @@ async function main() { console.log(` GL : ${renderer}\n`); record( SWIFTSHADER - ? 'running on software GL as requested (structural checks, not GPU evidence)' + ? 'running on software GL (structural checks, not GPU evidence)' : 'running on the real GPU, so the screenshots are real-GPU evidence', SWIFTSHADER ? softwareRenderer : !softwareRenderer, renderer, diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 1d3e91aab..5c3829462 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -13,14 +13,20 @@ fs.mkdirSync(shotsDir, { recursive: true }); const chromeCandidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); const browser = await puppeteer.launch({ headless: headful ? false : 'new', ...(executablePath ? { executablePath } : {}), - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], + // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. + args: [ + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + '--no-sandbox', + ], }); const page = await browser.newPage(); const failures = []; @@ -54,8 +60,54 @@ const check = (name, passed, detail) => { try { await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await page.setRequestInterception(true); - page.on('request', (request) => { + const routeQaRequest = (request) => { const url = new URL(request.url()); + // These scenarios exercise Context lifecycle and keyboard ownership, not + // live orbit accuracy. Reuse the tracking suite's fixed element sets so + // CelesTrak outages cannot invalidate an otherwise clean UI run. + if (url.origin === new URL(appUrl).origin + && ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) { + const dense = url.pathname.endsWith('/starlink'); + request.respond({ + status: 200, + contentType: 'text/plain', + body: (dense ? [ + 'STARLINK-1007', + '1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004', + '2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345', + ] : [ + 'ISS (ZARYA)', + '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004', + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345', + ]).join('\n') + '\n', + }); + return; + } + // Space Missions is toggled by the Context cancellation checks below. + // Its catalog is unrelated to aircraft/Cockpit behavior; keep the network + // error gate meaningful without depending on Launch Library availability. + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/launches') { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ results: [] }), + }); + return; + } + // This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an + // unrelated upstream terrain outage out of the rendered interaction gate. + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') { + const points = (url.searchParams.get('points') || '').split(';').filter(Boolean); + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ results: points.map((point) => { + const [lon, lat] = point.split(',').map(Number); + return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 }; + }) }), + }); + return; + } if (url.origin === new URL(appUrl).origin && url.pathname === '/api/ais-live') { request.respond({ status: 200, @@ -111,7 +163,8 @@ try { return; } request.continue(); - }); + }; + page.on('request', routeQaRequest); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 }); await page.waitForFunction( @@ -142,21 +195,21 @@ try { const toasts = []; styleManager._showToast = (message) => { toasts.push(String(message)); }; try { - if (styleManager._contextMode) await styleManager._selectContextMode(null); - const contactsResult = await styleManager._selectContextMode('flights'); - const missionResult = await styleManager._selectContextMode('space-missions'); + if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null); + const contactsResult = await styleManager._contextControls._selectContextMode('flights'); + const missionResult = await styleManager._contextControls._selectContextMode('space-missions'); const switched = { contactsResult, missionResult, - mode: styleManager._contextMode, + mode: styleManager._contextControls._contextMode, missionsEnabled: dataManager.isEnabled('rocket-launches'), toasts: [...toasts], }; - const exitResult = await styleManager._selectContextMode(null); + const exitResult = await styleManager._contextControls._selectContextMode(null); return { ...switched, exitResult, - modeAfterExit: styleManager._contextMode, + modeAfterExit: styleManager._contextControls._contextMode, missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'), }; } finally { @@ -239,16 +292,16 @@ try { const deadline = performance.now() + 5_000; while (performance.now() < deadline) { if ( - styleManager._contextModeEntering === null - && styleManager._contextSessionSnapshot === null + styleManager._contextControls._contextModeEntering === null + && styleManager._contextControls._contextSessionSnapshot === null && dataManager.isEnabled(siblingId) && !dataManager.isEffectivelyEnabled('rocket-launches') ) break; await new Promise((resolve) => setTimeout(resolve, 20)); } timedOut = !( - styleManager._contextModeEntering === null - && styleManager._contextSessionSnapshot === null + styleManager._contextControls._contextModeEntering === null + && styleManager._contextControls._contextSessionSnapshot === null && dataManager.isEnabled(siblingId) && !dataManager.isEffectivelyEnabled('rocket-launches') ); @@ -256,8 +309,8 @@ try { exercised: true, transitionResult, timedOut, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingRestored: dataManager.isEnabled(siblingId), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), toasts, @@ -340,7 +393,7 @@ try { rocketEntry.module.disable = async () => true; styleManager._showToast = (message) => { toasts.push(String(message)); }; try { - const entry = styleManager._selectContextMode('space-missions'); + const entry = styleManager._contextControls._selectContextMode('space-missions'); await updateStarted; const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); releaseUpdate(); @@ -348,15 +401,15 @@ try { const committed = { entryResult, replacementResult, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), missionEnabled: dataManager.isEnabled('rocket-launches'), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), toasts: [...toasts], }; - const exitResult = await styleManager._selectContextMode(null); + const exitResult = await styleManager._contextControls._selectContextMode(null); return { exercised: true, ...committed, @@ -440,7 +493,7 @@ try { styleManager._showToast = (message) => { toasts.push(String(message)); }; try { const entry = styleManager._runUserFacingContextAction( - (notificationToken) => styleManager._selectContextMode( + (notificationToken) => styleManager._contextControls._selectContextMode( 'space-missions', { notificationToken }, ), @@ -456,9 +509,9 @@ try { exercised: true, entryResult, offResult, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingRestored: dataManager.isEnabled(siblingId), missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'), toasts, @@ -525,35 +578,35 @@ try { await new Promise((resolve) => setTimeout(resolve, 80)); const voiceState = { voiceOn, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId), }; - const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])]; + const snapshotBeforeOff = [...(styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds || [])]; const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' }); - const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size; + const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size; await styleManager._waitForContextLayerSettlement(); const restoredSiblingEntry = dataManager.layers.get(siblingId); const voiceExit = { voiceOff, snapshotBeforeOff, reactionCountAfterOff, - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), siblingRestored: dataManager.isEffectivelyEnabled(siblingId), siblingEnabled: restoredSiblingEntry?.enabled, siblingLifecycle: restoredSiblingEntry?.lifecycleState, - restoreActive: Boolean(styleManager._contextRestoreState), + restoreActive: Boolean(styleManager._contextControls._contextRestoreState), }; await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' }); await new Promise((resolve) => setTimeout(resolve, 40)); const internalState = { - mode: styleManager._contextMode, - entering: styleManager._contextModeEntering, - snapshotRetained: Boolean(styleManager._contextSessionSnapshot), + mode: styleManager._contextControls._contextMode, + entering: styleManager._contextControls._contextModeEntering, + snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot), missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'), }; await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' }); @@ -665,7 +718,7 @@ try { const blockerEntry = dataManager.layers.get(blockerId); blockerEntry.initialized = true; await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' }); - const transition = styleManager._selectContextMode('flights'); + const transition = styleManager._contextControls._selectContextMode('flights'); const disableStart = await settleWithin(disableStarted, gateTimeoutMs); if (!disableStart.settled) { releaseDisable(); @@ -683,7 +736,7 @@ try { styleManager.cockpitView.syncEntry(); const entry = document.getElementById('cockpit-entry'); const pending = { - changing: styleManager._contextModeChanging, + changing: styleManager._contextControls._contextModeChanging, entryHidden: Boolean(entry?.hidden), enterResult: styleManager.cockpitView.enter(), trackerPreserved: viewer.trackedEntity === trackedEntityBefore, @@ -721,7 +774,7 @@ try { // probe must not poison the checks that run after it. releaseDisable(); if (!deferCleanup && dataManager.layers.has(blockerId)) { - if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null); + if (styleManager._contextControls._contextSessionSnapshot) await styleManager._contextControls._selectContextMode(null); await window.__gevQaUnregisterLayer(dataManager, blockerId); styleManager.cockpitView.syncEntry(); } @@ -768,146 +821,189 @@ try { // was created to fix. // Holding the network reproduces the field exactly and the panel tracks it, // which is what gives these assertions teeth. - const deferredInstallationReadiness = await page.evaluate(async () => { - const { styleManager, dataManager } = window.__godsEyeView; - const entry = dataManager.layers.get('military-installations'); - if (!entry?.module) return { exercised: false, reason: 'military-installations missing' }; - const settleWithin = (promise, ms) => Promise.race([ - promise.then( - (value) => ({ settled: true, value }), - (error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }), - ), - new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }), - ]); - const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')] - .find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations'); - const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null; - /** The REASON text, which is what tracks availability: the cohort engine - * answers 'feed unavailable' / 'feed stale' when it refuses to count. */ - const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null; - - const realFetch = window.fetch; - let released = false; - let requestSeen = false; - let transition = null; - try { - await styleManager._selectContextMode(null); - await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' }); - - // Hold the first Overpass request open, honouring the module's own - // AbortSignal so its camera-settle abort/refetch behaves as it does live. - window.fetch = (input, init) => { - const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url; - let url = null; - try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); } - if (url.pathname !== '/api/military-installations') return realFetch(input, init); - requestSeen = true; - const signal = init?.signal; - return new Promise((resolve, reject) => { - let done = false; - const fail = () => { - if (done) return; - done = true; - const error = new Error('aborted'); - error.name = 'AbortError'; - reject(error); - }; - if (signal) { - if (signal.aborted) return fail(); - signal.addEventListener('abort', fail, { once: true }); - } - const tick = () => { - if (done) return; - if (released) { - done = true; - resolve(new Response( - JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - )); - return; - } - setTimeout(tick, 100); - }; - tick(); + // Earlier scenarios have already loaded installations. Use a fresh page so + // a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch + // assertion when camera motion supersedes the manager's initial update. + const readinessPage = await browser.newPage(); + let deferredInstallationReadiness; + try { + await readinessPage.setViewport({ width: 1440, height: 900 }); + readinessPage.on('pageerror', (error) => consoleErrors.push(error.message)); + await readinessPage.setRequestInterception(true); + readinessPage.on('request', (request) => { + const url = new URL(request.url()); + if (url.origin === new URL(appUrl).origin && url.pathname === '/api/opensky') { + const now = Math.floor(Date.now() / 1000); + request.respond({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ time: now, states: [ + ['aaa051', 'QA051', 'Synthetic', now, now, -97.7431, 30.2672, + 9000, false, 230, 90, 0, null, 9000, null, false, 0], + ] }), }); - }; - - transition = styleManager._selectContextMode('flights'); - // The activation promise stays PENDING while the first fetch is out, and - // the Contacts panel comes up behind it — that is what a deferred - // dependency is for. Verified live on :4272: the panel renders and reads - // a non-numeric count for the whole of a 17 s first fetch. - const started = await settleWithin((async () => { - for (let i = 0; i < 100 && !(requestSeen && row()); i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return requestSeen && Boolean(row()); - })(), 12_000); - styleManager.cockpitView.syncEntry(); - const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations'); - const pendingCount = installationCount(); - const pendingReason = installationReason(); - - // THE WINDOW: first request outstanding, panel live. Sampled more than - // once — the field report was a panel sitting on a fabricated 0 for ~13 s, - // so a single lucky read is not evidence. - const fetchingCounts = []; - const fetchingReasons = []; - for (let i = 0; i < 6; i++) { - fetchingCounts.push(installationCount()); - fetchingReasons.push(installationReason()); - await new Promise((resolve) => setTimeout(resolve, 250)); + return; } - - released = true; - const contacts = await settleWithin(transition, 20_000); - styleManager.cockpitView.syncEntry(); - // Measured AFTER activation settles, not during it. The sibling pin above - // ("Contacts blocks early Cockpit entry, then adopts the already tracked - // flight after settlement") establishes that Contacts HIDES the entry - // while the transition is changing — that is the designed contract, and - // asserting the opposite (as this scenario used to) could never pass. - // What matters is that a slow dependency does not lock it away for good. - const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden; - const installed = await settleWithin((async () => { - while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - await new Promise((resolve) => setTimeout(resolve, 200)); - return true; - })(), 20_000); - const answered = await settleWithin((async () => { - for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) { - await new Promise((resolve) => setTimeout(resolve, 150)); - } - return true; - })(), 12_000); - - return { - exercised: true, - started, - contacts, - pendingLifecycle, - pendingCount, - pendingReason, - cockpitAvailableAfterActivation, - installed, - settledLifecycle: dataManager.getLayerLifecycleState('military-installations'), - fetchingCounts, - fetchingReasons, - answered, - settledCount: installationCount(), - settledReason: installationReason(), - }; - } finally { - released = true; - window.fetch = realFetch; - if (transition) await settleWithin(transition, 20_000); - if (styleManager._contextMode !== 'flights') { - await settleWithin(styleManager._selectContextMode('flights'), 15_000); + routeQaRequest(request); + }); + const readinessUrl = new URL(appUrl); + readinessUrl.searchParams.set('welcome', '0'); + await readinessPage.goto(readinessUrl.href, { waitUntil: 'domcontentloaded' }); + await readinessPage.waitForFunction(() => window.__godsEyeView?.dataManager + && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); + await readinessPage.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + await manager.setEnabled('flights', true, { origin: 'user' }); + if (!manager.layers.get('flights').module.trackById('aaa051')) { + throw new Error('First-fetch fixture aircraft was not tracked'); } - } - }); + if (manager.layers.get('military-installations').module.getStats().lastUpdate != null) { + throw new Error('First-fetch fixture already has an installation snapshot'); + } + }); + deferredInstallationReadiness = await readinessPage.evaluate(async () => { + const { styleManager, dataManager } = window.__godsEyeView; + const entry = dataManager.layers.get('military-installations'); + if (!entry?.module) return { exercised: false, reason: 'military-installations missing' }; + const settleWithin = (promise, ms) => Promise.race([ + promise.then( + (value) => ({ settled: true, value }), + (error) => ({ settled: true, value: `error: ${String(error?.message || error)}` }), + ), + new Promise((resolve) => { setTimeout(() => resolve({ settled: false, value: null }), ms); }), + ]); + const row = () => [...document.querySelectorAll('#military-awareness-panel .military-awareness-row')] + .find((candidate) => candidate.querySelector('strong')?.textContent?.trim() === 'Mapped installations'); + const installationCount = () => row()?.querySelector('b')?.textContent?.trim() || null; + /** The REASON text, which is what tracks availability: the cohort engine + * answers 'feed unavailable' / 'feed stale' when it refuses to count. */ + const installationReason = () => row()?.querySelector('small')?.textContent?.trim() || null; + + const realFetch = window.fetch; + let released = false; + let requestSeen = false; + let transition = null; + try { + await styleManager._contextControls._selectContextMode(null); + await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' }); + + // Hold the first Overpass request open, honouring the module's own + // AbortSignal so its camera-settle abort/refetch behaves as it does live. + window.fetch = (input, init) => { + const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url; + let url = null; + try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); } + if (url.pathname !== '/api/military-installations') return realFetch(input, init); + requestSeen = true; + const signal = init?.signal; + return new Promise((resolve, reject) => { + let done = false; + const fail = () => { + if (done) return; + done = true; + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }; + if (signal) { + if (signal.aborted) return fail(); + signal.addEventListener('abort', fail, { once: true }); + } + const tick = () => { + if (done) return; + if (released) { + done = true; + resolve(new Response( + JSON.stringify({ elements: [], retrievedAt: new Date().toISOString() }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + )); + return; + } + setTimeout(tick, 100); + }; + tick(); + }); + }; + + transition = styleManager._contextControls._selectContextMode('flights'); + // The activation promise stays PENDING while the first fetch is out, and + // the Contacts panel comes up behind it — that is what a deferred + // dependency is for. Verified live on :4272: the panel renders and reads + // a non-numeric count for the whole of a 17 s first fetch. + const started = await settleWithin((async () => { + for (let i = 0; i < 100 && !(requestSeen && row()); i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return requestSeen && Boolean(row()); + })(), 12_000); + styleManager.cockpitView.syncEntry(); + const pendingLifecycle = dataManager.getLayerLifecycleState('military-installations'); + const pendingCount = installationCount(); + const pendingReason = installationReason(); + + // THE WINDOW: first request outstanding, panel live. Sampled more than + // once — the field report was a panel sitting on a fabricated 0 for ~13 s, + // so a single lucky read is not evidence. + const fetchingCounts = []; + const fetchingReasons = []; + for (let i = 0; i < 6; i++) { + fetchingCounts.push(installationCount()); + fetchingReasons.push(installationReason()); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + released = true; + const contacts = await settleWithin(transition, 20_000); + styleManager.cockpitView.syncEntry(); + // Measured AFTER activation settles, not during it. The sibling pin above + // ("Contacts blocks early Cockpit entry, then adopts the already tracked + // flight after settlement") establishes that Contacts HIDES the entry + // while the transition is changing — that is the designed contract, and + // asserting the opposite (as this scenario used to) could never pass. + // What matters is that a slow dependency does not lock it away for good. + const cockpitAvailableAfterActivation = !document.getElementById('cockpit-entry')?.hidden; + const installed = await settleWithin((async () => { + while (dataManager.getLayerLifecycleState('military-installations')?.lifecycleState !== 'enabled') { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + return true; + })(), 20_000); + const answered = await settleWithin((async () => { + for (let i = 0; i < 60 && !/^\d+$/.test(String(installationCount())); i++) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + return true; + })(), 12_000); + + return { + exercised: true, + started, + contacts, + pendingLifecycle, + pendingCount, + pendingReason, + cockpitAvailableAfterActivation, + installed, + settledLifecycle: dataManager.getLayerLifecycleState('military-installations'), + fetchingCounts, + fetchingReasons, + answered, + settledCount: installationCount(), + settledReason: installationReason(), + }; + } finally { + released = true; + window.fetch = realFetch; + if (transition) await settleWithin(transition, 20_000); + if (styleManager._contextControls._contextMode !== 'flights') { + await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000); + } + } + }); + } finally { + await readinessPage.close(); + await page.bringToFront(); + } // The panel has two truthful ways to say "nothing has answered yet" — the // cohort count renders `?`, and an UNKNOWN relationship renders as that word. // Which one is on screen depends on which panel surface is mounted, and the @@ -916,6 +1012,11 @@ try { // assert numeric-vs-not, which is exactly the field report. const readsUnknown = (value) => typeof value === 'string' && value.length > 0 && !/^\d/.test(value); const readsNumber = (value) => typeof value === 'string' && /^\d+$/.test(value); + // Loading has explicit copy since the installations reliability update. + // Keep the numeric/count gates below: wording alone never proves readiness. + const readsPendingReason = (value) => ( + /unavailable|stale|Mapped sites not loaded|Fetching mapped sites…/i.test(String(value)) + ); check( 'mapped installations load behind Contacts without a false all-clear or a locked Cockpit', deferredInstallationReadiness.exercised @@ -924,7 +1025,7 @@ try { && deferredInstallationReadiness.contacts.value === true && deferredInstallationReadiness.pendingLifecycle?.lifecycleState === 'enabling' && readsUnknown(deferredInstallationReadiness.pendingCount) - && /unavailable|stale/i.test(String(deferredInstallationReadiness.pendingReason)) + && readsPendingReason(deferredInstallationReadiness.pendingReason) && deferredInstallationReadiness.cockpitAvailableAfterActivation && deferredInstallationReadiness.installed?.settled, JSON.stringify(deferredInstallationReadiness), @@ -937,10 +1038,11 @@ try { && deferredInstallationReadiness.fetchingCounts.length > 0 && deferredInstallationReadiness.fetchingCounts.every(readsUnknown) && Array.isArray(deferredInstallationReadiness.fetchingReasons) - && deferredInstallationReadiness.fetchingReasons.every((r) => /unavailable|stale/i.test(String(r))), + && deferredInstallationReadiness.fetchingReasons.every(readsPendingReason), JSON.stringify({ settledLifecycle: deferredInstallationReadiness.settledLifecycle, fetchingCounts: deferredInstallationReadiness.fetchingCounts, + fetchingReasons: deferredInstallationReadiness.fetchingReasons, }), ); check( @@ -948,7 +1050,7 @@ try { deferredInstallationReadiness.exercised && deferredInstallationReadiness.answered?.settled && readsNumber(deferredInstallationReadiness.settledCount) - && !/unavailable|stale/i.test(String(deferredInstallationReadiness.settledReason)), + && !readsPendingReason(deferredInstallationReadiness.settledReason), JSON.stringify({ answered: deferredInstallationReadiness.answered, settledCount: deferredInstallationReadiness.settledCount, @@ -969,7 +1071,7 @@ try { return { before: before ? { layerId: before.layerId, id: before.id } : null, after: after ? { layerId: after.layerId, id: after.id } : null, - mode: styleManager._contextMode, + mode: styleManager._contextControls._contextMode, released, refocused, trackedId: trackedInfo?.icao24 || trackedInfo?.id || null, @@ -1000,7 +1102,12 @@ try { ); const zoomedRange = cameraRange(); const refocused = awareness?.focusCurrent?.() === true; - await new Promise((resolve) => setTimeout(resolve, 180)); + // The follow frame commits in Cesium preUpdate. A fixed 180ms delay can + // sample before that frame on a busy software renderer. + const frameDeadline = performance.now() + 5000; + while (cameraRange() >= 50_000 && performance.now() < frameDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } const focusedRange = cameraRange(); const after = awareness?.getContextSnapshot?.()?.subject || null; return { @@ -1110,9 +1217,9 @@ try { // Start from outside both, so activation is a real edge. cockpit.exit({ restoreTracking: true }); await settle(); - await styleManager._selectContextMode(null); + await styleManager._contextControls._selectContextMode(null); await settle(400); - const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null; + const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null; const cleanupBlockerId = '__qa_slow_contacts_sibling__'; const cleanupUnregistered = await window.__gevQaUnregisterLayer( window.__godsEyeView.dataManager, @@ -1128,7 +1235,7 @@ try { const overriddenBeforeContacts = styleManager._detectionUserOverridden; // 1. Activating Contacts forces the tactical preset on. - const contactsOn = await styleManager._selectContextMode('flights'); + const contactsOn = await styleManager._contextControls._selectContextMode('flights'); await settle(400); const afterContactsOn = mode(); const afterContactsOnDensity = density(); @@ -1158,7 +1265,7 @@ try { await settle(); // 4. Deactivating Contacts restores the pre-Contacts state. - await styleManager._selectContextMode(null); + await styleManager._contextControls._selectContextMode(null); await settle(400); const afterContactsOff = mode(); const afterContactsOffDensity = density(); @@ -1172,7 +1279,7 @@ try { await settle(); // Leave the session exactly as the following checks expect it. - const restoredContacts = await styleManager._selectContextMode('flights'); + const restoredContacts = await styleManager._contextControls._selectContextMode('flights'); await settle(400); const restoredCockpit = await enterCockpit(); styleManager._detectionUserOverridden = originalOverridden; @@ -1252,8 +1359,8 @@ try { step, cockpitActive: cockpit.active, bodyCockpit: document.body.classList.contains('cockpit-mode'), - contextMode: styleManager._contextMode, - contextChanging: styleManager._contextModeChanging, + contextMode: styleManager._contextControls._contextMode, + contextChanging: styleManager._contextControls._contextModeChanging, subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null, tracked: tracker?.gevTrackedId || null, density: styleManager.getDetectionState().densityPct, @@ -1339,12 +1446,25 @@ try { const { styleManager, dataManager, viewer } = window.__godsEyeView; const awareness = dataManager.layers.get('military-awareness')?.module; const entity = viewer.trackedEntity || styleManager.cockpitView.trackedEntity; + const nextFrame = () => new Promise((resolve, reject) => { + const timer = setTimeout(() => { + remove(); + reject(new Error('Cockpit camera ownership check did not render a frame')); + }, 5000); + const remove = viewer.scene.postRender.addEventListener(() => { + remove(); + clearTimeout(timer); + resolve(); + }); + }); + // Let revoked listeners retire before comparing ownership across exit. + await nextFrame(); const listenersBeforeExit = viewer.scene.preUpdate.numberOfListeners; const exited = styleManager.cockpitView.exit() === true; - await new Promise((resolve) => setTimeout(resolve, 80)); + await nextFrame(); const listenersAfterExit = viewer.scene.preUpdate.numberOfListeners; const refocused = awareness?.focusCurrent?.() === true; - await new Promise((resolve) => setTimeout(resolve, 80)); + await nextFrame(); return { exited, refocused, @@ -1907,6 +2027,58 @@ try { && contextTransition.label === 'Collapse Contact panel', JSON.stringify(contextTransition), ); + // Use the real connected DOM to catch focus loss caused by moving live rows. + const signalFocus = await page.evaluate(() => { + const cockpit = window.__godsEyeView.styleManager.cockpitView; + const originalItems = cockpit.signalItems; + const wasCollapsed = cockpit.signalCollapsed; + cockpit.setSignalCollapsed(false); + const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(), + title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } }); + try { + cockpit.signalItems = [item('focus-a'), item('focus-b')]; + cockpit.renderCockpitSignals(); + const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]'); + button.focus(); + const acquired = document.activeElement === button; + cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')]; + cockpit.renderCockpitSignals(); + const retained = document.activeElement === button && button.isConnected; + const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId); + cockpit.signalItems = [item('focus-b')]; + cockpit.renderCockpitSignals(); + const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle; + const continued = document.activeElement === continuation; + cockpit.signalItems = [item('focus-a'), item('focus-b')]; + cockpit.renderCockpitSignals(); + return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation }; + } finally { + cockpit.signalItems = originalItems; + cockpit.renderCockpitSignals(); + cockpit.setSignalCollapsed(wasCollapsed); + } + }); + check('Cockpit live reorder retains the same connected focus owner', + signalFocus.acquired && signalFocus.retained + && JSON.stringify(signalFocus.order) === JSON.stringify(['focus-c', 'focus-b', 'focus-a']), + JSON.stringify(signalFocus)); + check('a departed Cockpit contact continues at the footer without later reclaiming focus', + signalFocus.continued && signalFocus.notReclaimed, JSON.stringify(signalFocus)); + + // Real keyboard events also verify the nested lightbox does not exit Cockpit. + await page.focus('#cesium-credits .cesium-credit-expand-link'); + await page.keyboard.press('Enter'); + check('keyboard attribution opening focuses Close inside Cockpit', await page.evaluate(() => ( + document.activeElement?.classList.contains('cesium-credit-lightbox-close') + && document.querySelector('.cesium-credit-expand-link').getAttribute('aria-expanded') === 'true' + ))); + await page.screenshot({ path: path.join(shotsDir, 'keyboard-attribution.png') }); + await page.keyboard.press('Escape'); + check('attribution Escape restores focus and keeps Cockpit active', await page.evaluate(() => ( + document.activeElement?.classList.contains('cesium-credit-expand-link') + && document.activeElement.getAttribute('aria-expanded') === 'false' + && window.__godsEyeView.styleManager.cockpitView.active + ))); await page.screenshot({ path: path.join(shotsDir, 'restored-desktop.png') }); check( 'restored screenshot remains in a real Cockpit session', diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index 5b9d89816..400857b54 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -93,7 +93,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs index 3b4582b9c..55d77a310 100644 --- a/scripts/qa-failstate-b10.mjs +++ b/scripts/qa-failstate-b10.mjs @@ -70,7 +70,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index ca5f23525..d32ede533 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -24,6 +24,8 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-firms.mjs --url http://localhost:4420 + * Add --fixtures for deterministic source/rendering proof without a key; + * that mode does not establish live-source acceptance. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -43,6 +45,7 @@ const getOpt = (name, dflt) => { }; const APP_URL = getOpt('--url', 'http://localhost:4420'); const HEADFUL = argv.includes('--headful'); +const FIXTURES = argv.includes('--fixtures'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -52,7 +55,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', @@ -157,7 +160,8 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) { () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(1500); + await sleep(5000); + await page.keyboard.press('Escape'); return page.evaluate(async (tS) => { const dm = window.__godsEyeView.dataManager; await dm.setEnabled('local-firms', true); @@ -208,8 +212,20 @@ function stalePayload() { }; } +/** Deterministic high-density source records for refactoring without a server key. */ +function freshFixturePayload() { + const template = stalePayload(); + const fires = Array.from({ length: 1600 }, (_, i) => ({ + ...template.fires[i % template.fires.length], + lat: 30.1 + (i % 40) * 0.008, + lon: -97.9 + Math.floor(i / 40) * 0.008, + frp: 5 + (i % 137), + })); + return { ...template, fetchedAt: Date.now(), stale: false, count: fires.length, fires }; +} + async function main() { - console.log('\nLive NASA FIRMS Proof (qa-firms)'); + console.log(FIXTURES ? '\nFIRMS fixture proof (no live-source acceptance)' : '\nLive NASA FIRMS Proof (qa-firms)'); console.log(` App URL : ${APP_URL}\n`); try { @@ -221,7 +237,7 @@ async function main() { } const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null); - if (!status?.hasKey) { + if (!FIXTURES && !status?.hasKey) { console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m'); process.exit(2); } @@ -243,14 +259,40 @@ async function main() { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); + await page.setRequestInterception(true); + let interceptMode = FIXTURES ? 'fixture' : 'live'; + const stableStalePayload = stalePayload(); + page.on('request', (req) => { + const url = req.url(); + if (interceptMode === 'live') { req.continue(); return; } + 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(interceptMode === 'fixture' ? freshFixturePayload() : stableStalePayload) }); + } + return; + } + try { req.continue(); } catch { /* already handled */ } + }); + // ── (i) LIVE feed ──────────────────────────────────────────────────────── - console.log('(i) LIVE — loading the fires layer through the cached proxy...'); + console.log(FIXTURES ? '(i) FIXTURE — loading synthetic fires through the source adapter...' : '(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, + record(`${FIXTURES ? 'FIXTURE' : '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', + if (!FIXTURES) record('LIVE: /api/firms/status has key + quota statistics', status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)), `transactions=${JSON.stringify(status.transactions)} count=${status.count}`); if (!ok) exitCode = 1; @@ -299,29 +341,7 @@ async function main() { // ── (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 */ } - }); + interceptMode = 'keyless'; const keyless = await bootAndEnable(page, { timeoutS: 20 }); { @@ -459,6 +479,26 @@ async function main() { + `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} ` + `selectedActions=${proof.selectedActionCount}`); if (!actionOk) exitCode = 1; + const refresh = await page.evaluate(async () => { + const layer = window.__godsEyeView.dataManager.layers.get('local-firms').module; + const before = layer.getSelectedInfo(); + let selections = 0; + const count = () => { selections += 1; }; + window.addEventListener('gev:entity-selected', count); + try { + await layer.update(); + return { before, after: layer.getSelectedInfo(), selections, + contextId: window.__gevContextStore.selectedEntityId }; + } finally { + window.removeEventListener('gev:entity-selected', count); + } + }); + const refreshOk = Boolean(refresh.before?.id) + && refresh.after?.id === refresh.before.id + && refresh.contextId === refresh.before.id && refresh.selections === 0; + record('REFRESH: selected detection survives without another selection event', refreshOk, + JSON.stringify(refresh)); + if (!refreshOk) exitCode = 1; await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') }); } } diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index 31a271820..b8b7c9f5e 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -13,8 +13,8 @@ * * 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 + * NOTE: two mutations edit the server voice tools/instructions, and a running dev server watches those + * files 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. @@ -25,14 +25,16 @@ 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 TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs']; const FILES = { + keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'), 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'), + voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'), + voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'), + main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'), ui: path.join(ROOT, 'src', 'ui.js'), docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'), }; @@ -117,8 +119,8 @@ const MUTATIONS = [ { defect: 'the key handler trusts the CLASS, so an invisible card eats ESC', file: 'module', - from: 'if (closing || !isTopmost()) return;', - to: 'if (closing) return;', + from: 'isActive: () => !closing && isTopmost(),', + to: 'isActive: () => !closing,', }, { defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC', @@ -164,8 +166,8 @@ const MUTATIONS = [ }, { defect: 'the launcher stops honouring a key another surface already claimed', - file: 'module', - from: ' if (event.defaultPrevented) return;', + file: 'keyboard', + from: ' || event.defaultPrevented', to: ' /* belt removed */', }, { @@ -173,8 +175,8 @@ const MUTATIONS = [ // 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 });', + from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', + to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', }, { defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', @@ -343,25 +345,25 @@ const MUTATIONS = [ }, { defect: 'Tab escapes into an app the visitor has not seen yet', - file: 'module', + file: 'keyboard', from: "if (event.key !== 'Tab') return;", to: 'return;', }, { defect: 'ESC no longer dismisses the launcher', - file: 'module', + file: 'keyboard', 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) {", + file: 'keyboard', + from: "if (typeof target?.focus === 'function' && target.isConnected) {", to: 'if (false) {', }, { defect: 'app hotkeys eat the launcher keys (bubble instead of capture)', - file: 'module', + file: 'keyboard', from: "documentRef.addEventListener('keydown', onKeyDown, true);", to: "documentRef.addEventListener('keydown', onKeyDown);", }, @@ -452,15 +454,15 @@ const MUTATIONS = [ // ── Voice: schema must not drift ────────────────────────────────────────── { defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)', - file: 'vite', + file: 'voiceTools', 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", + file: 'voiceInstructions', + from: " 'NAMED VIEWS are shorthand", + to: " // 'NAMED VIEWS are shorthand", }, ]; @@ -491,7 +493,7 @@ for (const { defect, file, from, to } of MUTATIONS) { let red = false; let by = ''; try { - execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' }); + execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' }); } catch (error) { red = true; const failed = String(error.stdout || '').split('\n') diff --git a/scripts/qa-firstrun.mjs b/scripts/qa-firstrun.mjs index 3c65abab2..24d82bd2b 100644 --- a/scripts/qa-firstrun.mjs +++ b/scripts/qa-firstrun.mjs @@ -38,7 +38,7 @@ 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; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); @@ -332,14 +332,17 @@ async function runArbitrationSection(page, { shots, consoleErrors }) { `session=${escUnderLightbox.session}`, ); record( - 'the lightbox keeps its OWN ESC semantics (Cesium binds none, so it stays)', - lightboxAfterEsc.shown, + 'ESC closes attribution without dismissing the launcher underneath', + !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()); + // The attribution keyboard handler closes the overlay and restores focus. + // A second Escape belongs to the now-uncovered launcher. + const attributionFocusRestored = await page.evaluate(() => ( + document.activeElement === document.querySelector('#cesium-credits .cesium-credit-expand-link') + )); + record('closing attribution restores its disclosure focus', attributionFocusRestored); await sleep(300); const uncovered = await launcherState(); record('closing the lightbox hands the card back the key', @@ -491,7 +494,10 @@ async function main() { ...(executablePath ? { executablePath } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', - '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist', + // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] + : ['--use-gl=angle', '--use-angle=swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], diff --git a/scripts/qa-floor-hold.mjs b/scripts/qa-floor-hold.mjs index 9cdc85f80..82bdf6baa 100644 --- a/scripts/qa-floor-hold.mjs +++ b/scripts/qa-floor-hold.mjs @@ -15,7 +15,7 @@ * 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 # Metal on macOS, SwiftShader elsewhere * node scripts/qa-floor-hold.mjs --headful # watch it * QA_BASE_URL=http://localhost:4257 node scripts/qa-floor-hold.mjs * @@ -25,10 +25,11 @@ * 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. + * default is Metal on macOS and SwiftShader elsewhere; `--angle` overrides it. + * Mesh samples describe the selected backend, not another GPU's rendered LOD. + * An unavailable rendered-mesh oracle remains a failed check, even if the + * separate bare-earth DEM check passes. Software evidence does not establish + * real-GPU visual correctness. */ import puppeteer from 'puppeteer'; import fs from 'node:fs'; @@ -37,7 +38,8 @@ 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'); +// Metal is a macOS-only ANGLE backend; defaulting to it off-Mac fails WebGL init. +const ANGLE = String(argv.angle || (process.platform === 'darwin' ? 'metal' : 'swiftshader')); 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. @@ -56,7 +58,7 @@ function record(name, ok, detail) { } fs.mkdirSync(OUT_DIR, { recursive: true }); -const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })(); +const chrome = await puppeteer.executablePath().catch(() => undefined); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', executablePath: chrome, @@ -108,6 +110,10 @@ 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(() => { + document.querySelector('#first-run-launcher:not([hidden]) [data-first-run-choice="explore"]')?.click(); +}); +await sleep(600); // let the launcher dismissal finish before taking evidence await page.evaluate((site) => { window.__SITE = site; }, SITE); const pin = () => page.evaluate(() => { @@ -123,7 +129,16 @@ const pin = () => page.evaluate(() => { v.camera.moveEnd.raiseEvent(); }); await pin(); -await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); }); +const billboardMode = await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + const flights = manager.layers.get('flights').module; + // This harness measures billboard positions. A ready 3D model deliberately + // hides its billboard; that handoff is covered by track-regression instead. + flights.setParams({ models3d: false }); + await manager.toggle('flights'); + return flights.getParams().models3d === false; +}); +record('the billboard floor test is explicitly in 2D aircraft mode', billboardMode); /** Reads the contact's rendered height and the rendered mesh beneath it. */ const measure = () => page.evaluate(async (icao) => { diff --git a/scripts/qa-floor-verify.mjs b/scripts/qa-floor-verify.mjs index afdf7ad0d..97b1483dd 100644 --- a/scripts/qa-floor-verify.mjs +++ b/scripts/qa-floor-verify.mjs @@ -26,7 +26,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // across majors. const chrome = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean).find((p) => fs.existsSync(p)); diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 615398699..0e2ac9d62 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -25,7 +25,7 @@ const TESTS = [ 'src/data/renderAltitude.test.mjs', ]; -const FLIGHTS = 'src/data/flights.js'; +const FLIGHTS = 'src/layers/flights/motion.js'; const FLOOR = 'src/data/groundFloor.js'; const ALT = 'src/data/renderAltitude.js'; @@ -94,7 +94,7 @@ const MUTATIONS = [ { defect: 'the held floor is stretched without bound (no drift limit)', edits: [ - { file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' }, + { file: FLIGHTS, from: '<= HELD_FLOOR_MAX_DRIFT_KM', to: '<= Infinity' }, ], }, { @@ -102,8 +102,8 @@ const MUTATIONS = [ edits: [ { file: FLIGHTS, - from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', - to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', + from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', + to: 'false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', }, ], }, @@ -122,8 +122,8 @@ const MUTATIONS = [ edits: [ { file: FLIGHTS, - from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', - to: ' if (next.easedM == null && Number.isFinite(stoodOnM)', + from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', + to: 'next.easedM == null && Number.isFinite(stoodOnM)', }, ], }, @@ -159,8 +159,8 @@ const MUTATIONS = [ 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) {', + from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)', + to: 'next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)', }, ], }, @@ -191,8 +191,8 @@ const MUTATIONS = [ 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);', + from: 'FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),', + to: 'Infinity, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),', }, ], }, @@ -204,7 +204,7 @@ const MUTATIONS = [ { file: FLIGHTS, from: ' if (state.retiredMs == null) {', - to: ' _displayFloorState.delete(icao24);\n if (false) {', + to: ' flightState._displayFloorState.delete(icao24);\n if (false) {', }, ], }, @@ -213,8 +213,8 @@ const MUTATIONS = [ edits: [ { file: FLIGHTS, - from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;', - to: ' return false;', + from: 'state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS', + to: 'false', }, ], }, @@ -291,12 +291,13 @@ for (const mut of MUTATIONS) { 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)) { + const anchor = new RegExp(e.from.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*')); + if (!anchor.test(cur)) { console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`); applied = false; break; } - write(e.file, cur.replace(e.from, e.to)); + write(e.file, cur.replace(anchor, () => e.to.trim())); } if (applied) { const r = runTests(); diff --git a/scripts/qa-flyroute-cinema.mjs b/scripts/qa-flyroute-cinema.mjs index 274015edb..3d4088a32 100644 --- a/scripts/qa-flyroute-cinema.mjs +++ b/scripts/qa-flyroute-cinema.mjs @@ -27,7 +27,7 @@ const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000')); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), ].filter(Boolean); const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }); diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index 9b8214b0d..b585e71ae 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -123,7 +123,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-height-datum.mjs b/scripts/qa-height-datum.mjs index daa5b7e94..b9f42fefd 100644 --- a/scripts/qa-height-datum.mjs +++ b/scripts/qa-height-datum.mjs @@ -115,7 +115,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs new file mode 100644 index 000000000..5be19fe64 --- /dev/null +++ b/scripts/qa-infra-lod.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node +/** + * qa-infra-lod — browser checks for the local-infrastructure LOD declutter. + * + * Datacenters and dams use a bounded active-stem set. Cables are loaded for + * a representative combined view but use their own unchanged renderer. + * + * Two jobs: + * 1. GATE — with datacenters + dams enabled: + * a. at a full-earth camera, each layer's getLodDiagnostics() reports + * computed===true, active<=budgetLimit, budgetLimit===INFRA_LOD_ACTIVE_MIN, + * and active { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:5180'); +const CONTROL = argv.includes('--control'); +const HEADFUL = argv.includes('--headful'); + +const INFRA_LAYER_IDS = ['local-datacenters', 'local-dams']; +const CABLE_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: HEADFUL ? false : 'new', + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, + 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(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(String(error.message).replace(/https?:\/\/\S+/g, '[URL]'))); + await page.setViewport({ width: 1440, height: 860 }); + const testUrl = new URL(APP_URL); + testUrl.searchParams.set('welcome', '0'); + await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.styleManager + && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 }); + check('first-run chooser does not cover the test surface', await page.evaluate(() => ( + !document.querySelector('#first-run-launcher:not([hidden])') + ))); + report('renderer', await page.evaluate(() => { + const gl = window.__godsEyeView.viewer.scene.context._gl; + const debug = gl.getExtension('WEBGL_debug_renderer_info'); + return debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); + })); + + // Park at a full-earth view and disable every layer so infra 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: 0, latitude: 15 * Math.PI / 180, height: 24_000_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, 4_000)); + + // Enable the infrastructure layers (skipped in --control so the orbit cost + // measures the empty scene for attribution). + if (!CONTROL) { + const loaded = await page.evaluate(async (infraIds, cableId) => { + const gev = window.__godsEyeView; + const ids = [...infraIds, cableId]; + for (const id of ids) { + try { await gev.dataManager.setEnabled(id, true, { origin: 'user' }); } catch { /* reported below */ } + } + const deadline = performance.now() + 60_000; + const stat = (id) => gev.dataManager.layers.get(id)?.module?.getStats?.() || {}; + while (performance.now() < deadline) { + // Every id, cables included. The cable layer's enable() only starts + // `void load()`, and the manager's immediate update() returns early + // because `_loading` is already true — so waiting on datacenters and + // dams alone releases the measurement before ~2,600 cable references + // exist, and the advertised three-layer frame cost underreports. + const done = ids.every((id) => { + const s = stat(id); + return (s.count || 0) > 0 || s.error; + }); + if (done) break; + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 200)); + } + const out = {}; + for (const id of ids) out[id] = stat(id); + return out; + }, INFRA_LAYER_IDS, CABLE_LAYER_ID); + report('layer load stats', loaded); + for (const id of [...INFRA_LAYER_IDS, CABLE_LAYER_ID]) { + check(`${id} loaded its bundled dataset`, (loaded[id]?.count || 0) > 0, loaded[id]); + } + // Nudge a render pass so the first post-enable LOD walk runs. + await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.()); + await new Promise((r) => setTimeout(r, 2_000)); + } + + // ── GATE a: full-earth budget engaged ───────────────────────────────── + const readLod = (label) => page.evaluate((infraIds) => { + const gev = window.__godsEyeView; + const out = {}; + for (const id of infraIds) { + out[id] = gev.dataManager.layers.get(id)?.module?.getLodDiagnostics?.() || null; + } + return out; + }, INFRA_LAYER_IDS).then((lod) => { report(label, lod); return lod; }); + + if (!CONTROL) { + const globalLod = await readLod('getLodDiagnostics @ full-earth'); + for (const id of INFRA_LAYER_IDS) { + const d = globalLod[id]; + check(`${id}: LOD selection has run`, !!d && d.computed === true, d); + check(`${id}: active stems within the band budget`, !!d && d.active <= d.budgetLimit, d); + check(`${id}: full-earth band budget is INFRA_LOD_ACTIVE_MIN`, + !!d && d.budgetLimit === INFRA_LOD_ACTIVE_MIN, { got: d?.budgetLimit, want: INFRA_LOD_ACTIVE_MIN }); + // Every bound above is satisfied by an EMPTY active set: `computed` is + // true, `0 <= budgetLimit`, and `0 < total`. A regressed candidate build + // or occlusion test would therefore pass the gate while both layers + // render nothing, so assert the selection is actually populated. + check(`${id}: active set is non-empty`, !!d && d.active > 0, d); + // "declutter engaged" only asserts when the dataset actually exceeds the + // cap — a tiny dataset legitimately shows every feature. This fixture is + // a full-earth view of a global dataset an order of magnitude past the + // global band, so the only correct outcome is a cap-filled set. + if (d && d.total > d.budgetLimit) { + check(`${id}: declutter is engaged (active < total)`, d.active < d.total, d); + check(`${id}: full-earth active set fills the band budget`, + d.active === d.budgetLimit, { active: d.active, budgetLimit: d.budgetLimit }); + } + } + + // ── GATE b: zoom in widens the budget ─────────────────────────────── + await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const ell = v.scene.globe.ellipsoid; + v.camera.cancelFlight(); + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 55_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, + }); + // setView does not fire moveEnd; raise the LOD recompute trigger. + v.camera.moveEnd.raiseEvent?.(); + v.scene.requestRender?.(); + }); + await new Promise((r) => setTimeout(r, 3_000)); + const regionalLod = await readLod('getLodDiagnostics @ city'); + for (const id of INFRA_LAYER_IDS) { + const g = globalLod[id]; + const r = regionalLod[id]; + check(`${id}: city band lifts the budget to INFRA_LOD_ACTIVE_MAX`, + !!r && r.budgetLimit === INFRA_LOD_ACTIVE_MAX, { got: r?.budgetLimit, want: INFRA_LOD_ACTIVE_MAX }); + // `>=` alone accepts 0 >= 0, so the non-shrink check needs a floor too. + check(`${id}: active set did not shrink when zooming in`, + !!r && !!g && r.active > 0 && r.active >= Math.min(g.active, r.total), + { global: g?.active, regional: r?.active }); + } + + // ── GATE c: no churn between camera moves ─────────────────────────── + const churn = await page.evaluate(async (infraIds) => { + const gev = window.__godsEyeView; + const snap = () => infraIds.map((id) => { + const visibleIds = []; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + for (const entity of gev.viewer.dataSources.get(i).entities.values) { + if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id)); + } + } + return visibleIds.sort(); + }); + const before = snap(); + for (let i = 0; i < 5; i++) { + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 120)); + } + return { before, after: snap() }; + }, INFRA_LAYER_IDS); + check('the same visible IDs remain stable without a camera move', + churn.before.every((ids) => ids.length > 0) + && JSON.stringify(churn.before) === JSON.stringify(churn.after), + { beforeCounts: churn.before.map((ids) => ids.length), afterCounts: churn.after.map((ids) => ids.length) }); + + // Back to full-earth for the frame-cost measurement. + await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const ell = v.scene.globe.ellipsoid; + v.camera.setView({ + destination: ell.cartographicToCartesian({ + longitude: 0, latitude: 15 * Math.PI / 180, height: 24_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + v.camera.moveEnd.raiseEvent?.(); + v.scene.requestRender?.(); + }); + await new Promise((r) => setTimeout(r, 3_000)); + } + + // Count actual rendered frames: scene.render() can return without drawing. + const frameCost = await page.evaluate(() => new Promise((resolve) => { + const v = window.__godsEyeView.viewer; + const scene = v.scene; + const durations = []; + let frames = 0; + let previous; + const remove = scene.postRender.addEventListener(() => { + const now = performance.now(); + if (previous !== undefined) durations.push(now - previous); + previous = now; + frames++; + }); + const t0 = performance.now(); + const tick = () => { + v.camera.rotateRight(0.0004); + scene.requestRender(); + if (performance.now() - t0 < 10_000) requestAnimationFrame(tick); + else { + remove(); + const elapsedMs = performance.now() - t0; + durations.sort((a, b) => a - b); + const n = durations.length; + const sum = durations.reduce((s, d) => s + d, 0); + resolve({ + frames, + elapsedMs: +elapsedMs.toFixed(1), + 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: +(frames * 1000 / elapsedMs).toFixed(1), + }); + } + }; + requestAnimationFrame(tick); + })); + report(CONTROL + ? 'rendered-frame intervals, control (infra OFF), 10s orbit' + : 'rendered-frame intervals, infra ON, 10s orbit', frameCost); + + // ── parked-idle honesty: the LOD walk must not force continuous render ─ + await new Promise((r) => setTimeout(r, 4_000)); + const idle = await page.evaluate(async () => { + const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js'); + return new Promise((resolve) => { + const scene = window.__godsEyeView.viewer.scene; + const startedAt = Date.now(); + const tilesLoadedBefore = scene.globe.tilesLoaded; + let renders = 0; + let framesWithPendingTiles = 0; + const remove = scene.postRender.addEventListener(() => { + renders += 1; + if (!scene.globe.tilesLoaded) framesWithPendingTiles += 1; + }); + setTimeout(() => { + remove(); + const governor = getRenderGovernorDiagnostics(); + resolve({ renders, tilesLoadedBefore, tilesLoadedAfter: scene.globe.tilesLoaded, + framesWithPendingTiles, mode: governor.mode, holds: governor.holds, + requests: governor.recentRequests.filter(request => request.at >= startedAt) }); + }, 5_000); + }); + }); + report(CONTROL + ? 'parked idle, control (postRender fires / 5s)' + : 'parked idle with infra ON (postRender fires / 5s)', idle); + if (!CONTROL) { + check('parked idle stays near zero (≤6 / 5s)', idle.renders <= 6, idle); + } + check('no uncaught application errors', pageErrors.length === 0, pageErrors); + + try { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const shot = path.join(SHOTS_DIR, CONTROL ? 'infra-lod-control.png' : 'infra-lod.png'); + await page.screenshot({ path: shot }); + console.log(` [SHOT] ${shot}`); + } catch (e) { + console.log(` [SHOT] skipped — ${e?.message || e}`); + } +} finally { + await browser.close(); +} + +const passed = results.filter((r) => r.pass).length; +console.log(`\nqa-infra-lod: ${passed}/${results.length} passed${CONTROL ? ' (control mode — gates skipped)' : ''}`); +console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`); +process.exit(passed === results.length ? 0 : 1); diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 556320aab..bb43d968f 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1205,7 +1205,7 @@ async function runBrowserGroup(record) { const emit = (id, res, ms) => { if (ids.includes(id)) record(CHECKS.find((c) => c.id === id), res, ms); }; const only = (id) => ids.includes(id); - const exe = (() => { try { return puppeteer.executablePath(); } catch { return null; } })(); + const exe = await puppeteer.executablePath().catch(() => null); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', ...(exe ? { executablePath: exe } : {}), diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs index 3a4584274..b7d17752c 100644 --- a/scripts/qa-label-readability.mjs +++ b/scripts/qa-label-readability.mjs @@ -157,7 +157,7 @@ const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, // Pin puppeteer's Chrome-for-Testing: system Chrome auto-updates underneath // the harnesses and its software-GL behaviour shifts across majors. - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), ].filter(Boolean); function findChrome() { diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 30179931b..8d26dcb30 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -56,7 +56,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs new file mode 100644 index 000000000..453009929 --- /dev/null +++ b/scripts/qa-layer-panel.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** Browser proof of Layers panel replacement and subscription ownership. */ +import puppeteer from 'puppeteer'; +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +let failures = 0; +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +const check = (name, passed) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +}; +try { + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => window.__godsEyeView?.dataManager?._layerPanel, + { timeout: 60000 }, + ); + const results = await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + const container = manager._toggleContainer; + const id = 'qa-panel-lifecycle'; + let listener = null; + let enabled = 0; + window.__gevQaRegisterLayer(manager, { + id, + name: 'Literal layer', + icon: '◌', + source: 'Local fixture', + updateInterval: -1, + init() {}, + enable() { + enabled++; + }, + disable() {}, + update() {}, + destroy() {}, + getStats: () => ({ count: 1250, lastUpdate: Date.now(), stale: true }), + getRowControls: () => ({ chips: [] }), + setRowControlsListener: (callback) => { + listener = callback; + }, + }); + const result = []; + try { + manager.buildTogglePanel(container); + const row = () => container.querySelector(`[data-layer-id="${id}"]`); + const first = row().querySelector('.data-toggle-btn'); + result.push([ + 'descriptor text is literal content', + row().querySelector('.data-name').textContent === + 'Literal layer' && !row().querySelector('b'), + ]); + result.push([ + 'row subscription is installed', + typeof listener === 'function', + ]); + manager.buildTogglePanel(container); + first.click(); + await Promise.resolve(); + result.push([ + 'a detached row cannot issue an enable action', + enabled === 0 && !manager.isEnabled(id), + ]); + await manager.setEnabled(id, true); + result.push([ + 'count is presented from the supplied snapshot', + row().querySelector('.data-count').textContent === '1.3K', + ]); + result.push([ + 'feed state reflects the settled layer snapshot', + row().querySelector('.data-toggle-btn').dataset.feedState === 'stale', + ]); + const ui = window.__godsEyeView.styleManager; + ui._clearSelectedLayersBtn.click(); + result.push([ + 'native clear activation presents busy state', + ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' && + !!ui._contextControls._clearSelectedLayersPromise, + ]); + await ui._contextControls._clearSelectedLayersPromise; + result.push([ + 'clear settles through the existing layer transaction', + !manager.isEnabled(id) && + ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false', + ]); + await manager.setEnabled(id, true); + const final = row().querySelector('.data-toggle-btn'); + manager._layerPanel.destroy(); + final.click(); + await Promise.resolve(); + result.push([ + 'destroyed panel releases row subscriptions', + listener === null, + ]); + result.push([ + 'destroyed controls cannot change the layer', + manager.isEnabled(id), + ]); + } finally { + manager._layerPanel?.destroy(); + manager._layerPanel = null; + await window.__gevQaUnregisterLayer(manager, id); + manager.buildTogglePanel(container); + } + result.push([ + 'ordinary layer rows return after reassembly', + container.querySelectorAll('.data-toggle-row').length > 10, + ]); + return result; + }); + for (const [name, passed] of results) check(name, passed); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs new file mode 100644 index 000000000..606d67b83 --- /dev/null +++ b/scripts/qa-location-controls.mjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node +/** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +let failures = 0; +const check = (name, ok) => { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +}; +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._locationControls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.click('#location-bar-toggle'); + await page.click('[data-pin-target="location-bar"]'); + const cityId = await page.$eval( + '.location-pill', + (node) => node.dataset.locationId, + ); + await page.click('.location-pill'); + await page.waitForFunction(() => + document.getElementById('poi-row').classList.contains('expanded'), + ); + check( + 'native city selection highlights and expands its POIs', + await page.evaluate( + (id) => + window.__godsEyeView.styleManager._activeLocationId === id && + document.querySelector('.location-pill.active')?.dataset.locationId === + id, + cityId, + ), + ); + await page.keyboard.press('w'); + check( + 'POI keyboard action selects the second landmark', + await page.evaluate( + () => + window.__godsEyeView.styleManager._activePoiIndex === 1 && + document.querySelector('.poi-pill.active')?.dataset.poiIndex === '1', + ), + ); + check( + 'orbit controls and indicator agree', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + ui.setOrbit(true); + const active = + ui.orbitController.active && + document.getElementById('orbit-indicator').classList.contains('active'); + ui.setOrbit(false); + return ( + active && + !ui.orbitController.active && + !document.getElementById('orbit-indicator').classList.contains('active') + ); + }), + ); + await page.click('#search-toggle'); + await page.type('#location-search', 'qwerty'); + check( + 'typing in search leaves POI selection unchanged', + await page.evaluate( + () => window.__godsEyeView.styleManager._activePoiIndex === 1, + ), + ); + await page.evaluate(() => { + const lookup = window.__godsEyeView.styleManager._locationLookup; + window.__qaSearchRequests = []; + window.__qaLocationState = []; + window.__godsEyeView.styleManager.subscribeLocationSearch((notification) => + window.__qaLocationState.push(notification), + ); + lookup.search = (query, options) => + new Promise((resolve) => + window.__qaSearchRequests.push({ query, options, resolve }), + ); + document.getElementById('location-search').value = 'First'; + }); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.__qaSearchRequests.length === 1); + await page.evaluate(() => { + document.getElementById('location-search').value = 'Second'; + document.getElementById('location-search').focus(); + }); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.__qaSearchRequests.length === 2); + check( + 'second native submit aborts the first request and keeps current busy state', + await page.evaluate( + () => + window.__qaSearchRequests[0].options.signal.aborted && + document + .getElementById('location-search') + .classList.contains('searching'), + ), + ); + await page.evaluate(() => + window.__qaSearchRequests[1].resolve({ + label: 'Second landmark, Test city', + }), + ); + await page.waitForFunction( + () => + window.__godsEyeView.styleManager._searchedLocationLabel === + 'Second landmark, Test city', + ); + await page.evaluate(() => + window.__qaSearchRequests[0].resolve({ + label: 'First landmark, Test city', + }), + ); + check( + 'late result cannot overwrite the current location readout or style', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + return ( + ui._searchedLocationLabel === 'Second landmark, Test city' && + ui._locationMiniCity.textContent.includes('Second landmark') && + ui._locationMiniPoi.textContent === 'Test city' && + ui.activeStyle === 'normal' && + !ui._locationSearch.classList.contains('searching') + ); + }), + ); + check( + 'Location subscriptions accept only the current result and retain request identities', + await page.evaluate(() => { + const seen = window.__qaLocationState; + const started = seen.filter(({ change }) => change?.type === 'started'); + const found = seen.filter(({ change }) => change?.type === 'found'); + return ( + seen[0].initial && + started.length === 2 && + found.length === 1 && + found[0].change.requestId === started[1].change.requestId && + found[0].state.destination.label === 'Second landmark, Test city' && + Object.isFrozen(found[0].state.destination) + ); + }), + ); + check( + 'cancelled POI expansion stays closed after a frame', + await page.evaluate(async (id) => { + const ui = window.__godsEyeView.styleManager; + ui._expandPOIRow(id); + ui._collapsePOIRow(); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return ( + !ui._poiRow.classList.contains('expanded') && + !ui._locationBarDivider.classList.contains('visible') + ); + }, cityId), + ); + await page.evaluate( + (id) => window.__godsEyeView.styleManager._onCityPillClick(id), + cityId, + ); + await page.waitForFunction(() => + document.getElementById('poi-row').classList.contains('expanded'), + ); + // Finish the camera's existing city flight before inspecting both layouts. + await page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 10000 }, + ); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const settled = await page + .waitForFunction( + () => { + const scene = window.__godsEyeView.viewer.scene; + for (let index = 0; index < scene.primitives.length; index++) { + const primitive = scene.primitives.get(index); + if ( + typeof primitive.tilesLoaded === 'boolean' && + !primitive.tilesLoaded + ) + return false; + } + return true; + }, + { timeout: 60000 }, + ) + .then( + () => true, + () => false, + ); + check('visible city tiles settle before screenshots', settled); + fs.mkdirSync('qa-shots/location-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction( + () => + document.getElementById('left-panel-stack')?.dataset.layoutMode === + 'mobile', + ); + await page.screenshot({ path: 'qa-shots/location-controls/narrow.png' }); + check( + 'native reset returns through the existing camera handoff', + await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + ui._resetGlobeBtn.click(); + const pending = ui._globeResetPromise; + if (!pending) return false; + await pending; + return ( + !ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity + ); + }), + ); + check( + 'destroyed controls cannot navigate or submit another lookup', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + const count = window.__qaSearchRequests.length; + ui._locationControls.destroy(); + ui._locationLookup.destroy(); + const before = ui._navigationGeneration; + ui._locationPills.querySelector('button').click(); + ui._resetGlobeBtn.click(); + ui._locationSearch.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + return ( + before === ui._navigationGeneration && + count === window.__qaSearchRequests.length + ); + }), + ); + check('no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs new file mode 100644 index 000000000..c03e96a79 --- /dev/null +++ b/scripts/qa-map-source-controls.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** Browser acceptance for Map Source control lifecycle and truthful selection. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +const check = (name, ok) => { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +}; +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return ( + window.__godsEyeView?.styleManager?._mapSourceControls && + cover?.classList.contains('hidden') && + Number(getComputedStyle(cover).opacity) === 0 + ); + }, + { timeout: 60_000 }, + ); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const controller = manager.mapStackController; + const original = controller.setStack; + window.__qaMapSelections = []; + controller.setStack = function (id) { + window.__qaMapSelections.push(id); + return original.call(this, id); + }; + manager.setPanelCollapsed('control-panel', false, { + persist: false, + syncShare: false, + }); + }); + await page.click('[data-stack-id="osm"]'); + await page.waitForFunction( + () => + window.__godsEyeView.styleManager.mapStackController.getActiveId() === + 'osm', + ); + check( + 'a native chip click requests OSM exactly once', + await page.evaluate( + () => + window.__qaMapSelections.length === 1 && + window.__qaMapSelections[0] === 'osm', + ), + ); + check( + 'active chip and status follow the displayed OSM source', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + return ( + manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset + .stackId === 'osm' && + manager._mapStackStatus.textContent === + manager.mapStackController.getState().activeStack.shortLabel + ); + }), + ); + check( + 'the public map action uses the same component and truthful result', + await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const result = await manager.setMapStack('esri-imagery'); + const active = manager.mapStackController.getActiveId(); + return ( + window.__qaMapSelections.at(-1) === 'esri-imagery' && + manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset + .stackId === active && + (result.ok || !!manager.mapStackController.getState().lastError) + ); + }), + ); + check( + 'refresh revokes retained old chips', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]'); + manager._mapSourceControls.refresh(); + const before = window.__qaMapSelections.length; + old.click(); + return window.__qaMapSelections.length === before; + }), + ); + check( + 'refreshed chips still select once', + await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const before = window.__qaMapSelections.length; + manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + await Promise.resolve(); + return window.__qaMapSelections.length === before + 1; + }), + ); + await page.waitForFunction( + () => + window.__godsEyeView.styleManager.mapStackController.getActiveId() === + 'osm', + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + await manager.setMapStack('photoreal'); + manager.setPanelCollapsed('control-panel', false, { + persist: false, + syncShare: false, + }); + document.querySelector('.map-stack-chip.active')?.focus(); + }); + await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.scene.tweens?.removeAll?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (-97.7431 * Math.PI) / 180, + latitude: (30.2568 * Math.PI) / 180, + height: 1200, + }), + orientation: { heading: 0, pitch: -0.85, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const settled = await page + .waitForFunction( + () => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const primitive = primitives.get(i); + if ( + typeof primitive.tilesLoaded === 'boolean' && + !primitive.tilesLoaded + ) + return false; + } + return true; + }, + { timeout: 60_000 }, + ) + .then( + () => true, + () => false, + ); + check('visible tile content settles before visual captures', settled); + fs.mkdirSync('qa-shots/map-source-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction( + () => + document.getElementById('left-panel-stack')?.dataset.layoutMode === + 'mobile', + ); + await page.evaluate(() => + window.__godsEyeView.styleManager.setPanelCollapsed( + 'control-panel', + false, + { persist: false, syncShare: false }, + ), + ); + await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' }); + check( + 'destroyed controls cannot issue requests from retained chips', + await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const before = window.__qaMapSelections.length; + manager._mapSourceControls.destroy(); + manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + const result = await manager._mapSourceControls.select('esri-imagery'); + return result === null && window.__qaMapSelections.length === before; + }), + ); + check('no uncaught browser errors', errors.length === 0); + if (errors.length) console.log(JSON.stringify(errors)); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 0071475a3..8449097a0 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const shotsDir = path.join(repoRoot, 'qa-shots', 'map-source-tray'); +const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray'); const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173'; const headful = process.argv.includes('--headful'); // The no-ion-token contract is a real shipped state that a normal keyed @@ -19,7 +19,7 @@ const headful = process.argv.includes('--headful'); const forceKeyless = process.argv.includes('--keyless') || process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1'; const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH - || (() => { try { return puppeteer.executablePath(); } catch { return null; } })(); + || await puppeteer.executablePath().catch(() => null); if (!executablePath || !fs.existsSync(executablePath)) { throw new Error('Puppeteer Chrome for Testing is unavailable'); @@ -29,7 +29,15 @@ fs.mkdirSync(shotsDir, { recursive: true }); const browser = await puppeteer.launch({ headless: headful ? false : 'new', executablePath, - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], + // Metal is a macOS-only ANGLE backend: passing it on Windows or Linux makes + // WebGL initialization fail outright, so Cesium never constructs and this + // harness dies at the boot wait before a single assertion runs. + args: [ + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + '--no-sandbox', + ], }); const page = await browser.newPage(); const failures = []; @@ -83,6 +91,143 @@ const trayMetrics = () => page.evaluate(() => { }; }); +// Observe rendered focus, including clipping by the scrolling style row. A +// focus-visible match alone does not prove that the keyboard user sees a ring. +const focusMetrics = (selector = '.style-btn') => page.evaluate((match) => { + const button = document.activeElement; + if (!button?.matches(match)) return { focusedStyle: null, focusedId: null }; + const rect = button.getBoundingClientRect(); + const css = getComputedStyle(button); + const outlineWidth = parseFloat(css.outlineWidth) || 0; + const outlineOffset = parseFloat(css.outlineOffset) || 0; + const outset = Math.max(0, outlineWidth + outlineOffset); + const ring = { + left: rect.left - outset, right: rect.right + outset, + top: rect.top - outset, bottom: rect.bottom + outset, + }; + const clips = []; + for (let parent = button.parentElement; parent; parent = parent.parentElement) { + const parentCss = getComputedStyle(parent); + const parentRect = parent.getBoundingClientRect(); + const left = parentRect.left + parent.clientLeft; + const top = parentRect.top + parent.clientTop; + if (/auto|scroll|hidden|clip/.test(parentCss.overflowX) + && (ring.left < left - 1 || ring.right > left + parent.clientWidth + 1)) { + clips.push(`${parent.id || parent.className}:horizontal`); + } + if (/auto|scroll|hidden|clip/.test(parentCss.overflowY) + && (ring.top < top - 1 || ring.bottom > top + parent.clientHeight + 1)) { + clips.push(`${parent.id || parent.className}:vertical`); + } + } + const center = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + return { + focusedStyle: button.dataset.style, focusedId: button.id || null, tag: button.tagName, type: button.type, + layerId: button.closest('[data-layer-id]')?.dataset.layerId || null, + text: button.textContent.trim(), ariaLabel: button.getAttribute('aria-label'), + controls: button.getAttribute('aria-controls'), expanded: button.getAttribute('aria-expanded'), + selectedStyle: window.__godsEyeView.styleManager.activeStyle, + selectedMap: window.__godsEyeView.styleManager.mapStackController.getActiveId(), + focusVisible: button.matches(':focus-visible'), + selected: button.classList.contains('active'), + outlineStyle: css.outlineStyle, outlineWidth, outlineOffset, outlineColor: css.outlineColor, + visible: rect.width > 0 && rect.height > 0 && css.visibility === 'visible' + && (center === button || button.contains(center)), + insideViewport: ring.left >= 0 && ring.right <= innerWidth + && ring.top >= 0 && ring.bottom <= innerHeight, + clips, + ring, + viewport: { width: innerWidth, height: innerHeight }, + rowScrollLeft: document.getElementById('style-buttons').scrollLeft, + }; +}, selector); +const styleFocusMetrics = () => focusMetrics('.style-btn'); +const hasVisibleControlFocus = (state) => state.focusVisible && state.visible && state.insideViewport && state.clips.length === 0 + && state.outlineStyle !== 'none' && state.outlineStyle !== 'hidden' && state.outlineWidth >= 2 + && state.outlineColor !== 'transparent' && state.outlineColor !== 'rgba(0, 0, 0, 0)'; +const hasVisibleStyleFocus = (state, style) => state.focusedStyle === style && hasVisibleControlFocus(state); +const pressTabs = async (count, backwards = false) => { + if (backwards) await page.keyboard.down('Shift'); + try { + for (let index = 0; index < count; index += 1) await page.keyboard.press('Tab'); + } finally { + if (backwards) await page.keyboard.up('Shift'); + } + // The row scrolls smoothly when native Tab brings an edge button into view. + await new Promise((resolve) => setTimeout(resolve, 250)); +}; +const checkResponsiveStyleFocus = async (width) => { + // The disclosure is only the starting boundary. Every style target below is + // reached by actual Tab events, never by focusing the style under test. + const selectedMapBefore = await page.evaluate(() => ( + window.__godsEyeView.styleManager.mapStackController.getActiveId() + )); + await page.focus('#control-panel-toggle'); + await pressTabs(2); // disclosure -> pin -> Normal + for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) { + if (index) await pressTabs(3); + const state = await styleFocusMetrics(); + check(`${width} px ${style} keyboard ring is visible and unclipped`, + hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal' + && state.selectedMap === selectedMapBefore, JSON.stringify(state)); + await page.screenshot({ path: path.join(shotsDir, `${width}-style-${style}-focus.png`) }); + } +}; + +// Start from an explicit boundary, then use real Tab events for every target. +// No layer or location action is activated merely to establish keyboard focus. +const tabTo = async (selector, { backwards = false, limit = 160 } = {}) => { + if (backwards) await page.keyboard.down('Shift'); + try { + for (let step = 1; step <= limit; step += 1) { + await page.keyboard.press('Tab'); + if (await page.evaluate((match) => document.activeElement?.matches(match), selector)) { + await new Promise((resolve) => setTimeout(resolve, 250)); + return { reached: true, steps: step }; + } + } + return { reached: false, steps: limit }; + } finally { + if (backwards) await page.keyboard.up('Shift'); + } +}; +const locationState = async () => ({ + ...await focusMetrics('#location-bar-toggle'), + ...await page.evaluate(() => { + const panel = document.getElementById('location-bar'); + const toggle = document.getElementById('location-bar-toggle'); + const voice = window.__godsEyeView.voiceCommands; + return { + locationExpanded: toggle?.getAttribute('aria-expanded'), + locationLabel: toggle?.getAttribute('aria-label'), + popoverId: panel.querySelector('.dock-popover-content')?.id, + pinned: panel.classList.contains('dock-pinned'), + searchExpanded: document.getElementById('location-search').classList.contains('expanded'), + searchValue: document.getElementById('location-search').value, + transitions: [...(window.__qaLocationFocus?.transitions || [])], + voice: { status: voice.status, epoch: voice.startEpoch, held: voice.spaceKeyHeld, pushToTalk: voice.pushToTalkKeyHeld }, + }; + }), +}); +const resetLocationTransitions = () => page.evaluate(() => { window.__qaLocationFocus.transitions = []; }); +const layerFocusState = async (id) => ({ + ...await focusMetrics('.data-toggle-btn'), + ...await page.evaluate((layerId) => { + const manager = window.__godsEyeView.dataManager; + const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); + const list = document.getElementById('data-toggles'); + return { + lifecycle: manager.getLayerLifecycleState(layerId), + feedState: button?.dataset.feedState, label: button?.textContent.trim(), + buttonActive: button?.classList.contains('active'), + disabled: button?.disabled, + ariaDisabled: button?.getAttribute('aria-disabled'), + ariaBusy: button?.getAttribute('aria-busy'), + listScrollTop: list.scrollTop, listClientHeight: list.clientHeight, listScrollHeight: list.scrollHeight, + }; + }, id), +}); + try { await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); await page.setRequestInterception(true); @@ -142,6 +287,12 @@ try { const styleManager = window.__godsEyeView.styleManager; const controller = styleManager.mapStackController; await styleManager._setMapStack('esri-imagery', { syncShare: false }); + // Cesium updates on-screen credits on a rendered frame, after setStack + // resolves. Observe that boundary before checking the visible source. + const creditDeadline = performance.now() + 5000; + while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } const provider = controller._activeImageryProvider; const before = { activeId: controller.getActiveId(), @@ -158,9 +309,17 @@ try { await new Promise((resolve) => setTimeout(resolve, 25)); } // The controller commits `activeId` before its fallback promise callback - // emits the terminal error state that re-syncs the chips. Give that - // callback one turn so the DOM assertion observes the completed contract. - await new Promise((resolve) => setTimeout(resolve, 50)); + // emits the terminal error state that re-syncs the chips, and Cesium drops + // the Esri credit on a later render frame again. A fixed delay races both: + // 50ms was enough most runs and not enough on a slow one, which is the same + // flake as the tray timers above (#54). Poll the observable truth instead. + const domDeadline = performance.now() + 3000; + const settled = () => !document.body.innerText.includes('Powered by Esri') + && JSON.stringify([...document.querySelectorAll('.map-stack-chip[aria-pressed="true"]')] + .map((chip) => chip.dataset.stackId)) === JSON.stringify(['osm']); + while (!settled() && performance.now() < domDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } const afterTwo = { activeId: controller.getActiveId(), lastError: controller.getState().lastError, @@ -190,21 +349,52 @@ try { JSON.stringify(esriTileFailureFallback), ); + // The tray's state changes are timer-driven: opening schedules the focus + // hand-off to a Map Source tile 240ms later (scheduleMapSourceFocus in + // ui.js). A fixed sleep races that timer rather than observing it settle — + // 300ms left only 60ms of margin, which is what made these checks flake on + // a clean checkout (#54). Wait on controller truth instead, the way the + // Bing-switch check further down already does. + // The wait only settles state; the check() below it is still the assertion, + // so a swallowed timeout surfaces as that check failing with real values + // rather than as an opaque puppeteer error. + const waitTray = (wantExpanded, wantFocus) => page.waitForFunction( + (expanded, focus) => { + const toggle = document.getElementById('control-panel-toggle'); + if (toggle?.getAttribute('aria-expanded') !== expanded) return false; + if (!focus) return true; + const active = document.activeElement; + return focus === 'toggle' + ? active?.id === 'control-panel-toggle' + : active?.dataset?.stackId === focus; + }, + { timeout: 2000 }, + wantExpanded, wantFocus, + ).catch(() => {}); + + await page.evaluate(() => window.__godsEyeView.styleManager._setMapStack('osm', { syncShare: false })); + const keyboardSource = await page.evaluate(() => ( + window.__godsEyeView.styleManager.mapStackController.getActiveId() + )); + check('keyboard checks start on selected OSM as the last tile', + keyboardSource === 'osm' && await page.evaluate(() => ( + document.querySelector('#map-stack-chips .map-stack-chip:last-child')?.dataset.stackId === 'osm' + ))); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitTray('true', keyboardSource); const keyboardOpen = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, })); check( - 'Enter opens the tray and hands focus to a Map Source tile', - keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === 'photoreal', + 'Enter opens the tray and hands focus to the selected Map Source tile', + keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === keyboardSource, JSON.stringify(keyboardOpen), ); await page.keyboard.press('Escape'); - await new Promise((resolve) => setTimeout(resolve, 40)); + await waitTray('false', 'toggle'); const keyboardClose = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeId: document.activeElement?.id || null, @@ -216,14 +406,14 @@ try { ); await page.keyboard.press('Space'); - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitTray('true', keyboardSource); const spaceOpen = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, })); check( 'Space opens the tray through the same keyboard path', - spaceOpen.expanded === 'true' && spaceOpen.activeStack === 'photoreal', + spaceOpen.expanded === 'true' && spaceOpen.activeStack === keyboardSource, JSON.stringify(spaceOpen), ); @@ -231,19 +421,292 @@ try { await page.keyboard.down('Enter'); await new Promise((resolve) => setTimeout(resolve, 320)); await page.keyboard.up('Enter'); + await waitTray('true', keyboardSource); // let the hold's scheduled focus hand-off land await page.keyboard.press('Escape'); + await waitTray('false', 'toggle'); await page.keyboard.press('Enter'); - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitTray('true', keyboardSource); const longHoldRecovery = await page.evaluate(() => ({ expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'), activeStack: document.activeElement?.dataset?.stackId || null, })); check( 'long Enter hold cannot strand the disclosure keyboard path', - longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === 'photoreal', + longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === keyboardSource, JSON.stringify(longHoldRecovery), ); + // Force a delayed visible state while using the real controller and keyboard routes. + const hideTray = () => page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('control-panel', true); + window.__qaTrayStyles = [...document.querySelectorAll('.map-stack-chip')] + .map((chip) => [chip, chip.style.cssText]); + for (const [chip] of window.__qaTrayStyles) chip.style.setProperty('visibility', 'hidden', 'important'); + document.getElementById('control-panel-toggle').focus(); + }); + const showTray = () => page.evaluate(() => { + for (const [chip, cssText] of window.__qaTrayStyles) chip.style.cssText = cssText; + delete window.__qaTrayStyles; + }); + await hideTray(); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 350)); + const delayedBefore = await page.evaluate(() => document.activeElement?.id); + await showTray(); + await waitTray('true', keyboardSource); + const delayedAfter = await page.evaluate(() => document.activeElement?.dataset?.stackId); + check('a delayed visible tray receives selected-source focus after the first attempt', + delayedBefore === 'control-panel-toggle' && delayedAfter === keyboardSource, + JSON.stringify({ delayedBefore, delayedAfter, keyboardSource })); + + await hideTray(); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 350)); + await page.keyboard.press('Tab'); + const departure = await page.evaluate(() => { + window.__qaDepartedFocus = document.activeElement; + return document.activeElement !== document.getElementById('control-panel-toggle'); + }); + await showTray(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const focusRetained = await page.evaluate(() => { + const same = document.activeElement === window.__qaDepartedFocus; + delete window.__qaDepartedFocus; + return same; + }); + check('Tab away during the opening transition revokes delayed focus', departure && focusRetained, + JSON.stringify({ departure, focusRetained })); + + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true, { explicit: true })); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Escape'); + await new Promise((resolve) => setTimeout(resolve, 1100)); + check('Escape cancels the pending opening handoff', await page.evaluate(() => ( + document.activeElement === document.body + && document.getElementById('control-panel').classList.contains('collapsed') + ))); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await waitTray('true', keyboardSource); + + await page.keyboard.press('Escape'); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await page.keyboard.down('Shift'); + await page.keyboard.press('Tab'); + await page.keyboard.up('Shift'); + const leftBeforeReturn = await page.evaluate(() => document.activeElement?.id !== 'control-panel-toggle'); + await page.focus('#control-panel-toggle'); + await new Promise((resolve) => setTimeout(resolve, 1100)); + check('returning to the disclosure does not revive a cancelled keyboard opening', + leftBeforeReturn && await page.evaluate(() => document.activeElement?.id === 'control-panel-toggle')); + + await page.keyboard.press('Escape'); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Escape'); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', false)); + await new Promise((resolve) => setTimeout(resolve, 1100)); + check('programmatic reopening cannot inherit a cancelled keyboard handoff', await page.evaluate(() => ( + document.activeElement === document.body + && document.getElementById('control-panel-toggle').getAttribute('aria-expanded') === 'true' + ))); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Escape'); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await waitTray('true', keyboardSource); + + // Visual Styles precede the five Map Source tiles. Keep the existing opening + // handoff to selected OSM, then use the user's real Shift+Tab path into styles. + await pressTabs(5, true); + const snowFocus = await styleFocusMetrics(); + check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected', + hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected + && snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm', + JSON.stringify(snowFocus)); + await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') }); + await pressTabs(3, true); + const middleFocus = await styleFocusMetrics(); + check('Shift+Tab traverses the middle FLIR style without activating it', + hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected + && middleFocus.selectedStyle === 'normal' && middleFocus.selectedMap === 'osm', + JSON.stringify(middleFocus)); + await pressTabs(2, true); + await page.keyboard.press('Enter'); + const enterStyle = await styleFocusMetrics(); + check('Enter activates the focused CRT style and preserves selected OSM', + enterStyle.focusedStyle === 'retro' && enterStyle.selectedStyle === 'retro' + && enterStyle.selected && enterStyle.selectedMap === 'osm', JSON.stringify(enterStyle)); + await pressTabs(1, true); + const normalFocus = await styleFocusMetrics(); + check('the first Normal style has a focus ring independent from selected CRT', + hasVisibleStyleFocus(normalFocus, 'normal') && !normalFocus.selected + && normalFocus.selectedStyle === 'retro' && normalFocus.selectedMap === 'osm', + JSON.stringify(normalFocus)); + + // Count real native clicks and calls into the unchanged style implementation. + // Stub only the provider start seam so timing and focus arbitration can be + // exercised without opening a live microphone session during browser QA. + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const voice = window.__godsEyeView.voiceCommands; + const grid = document.getElementById('style-buttons'); + const root = document.getElementById('gev-voice-control'); + const originalSetStyle = manager.setStyle; + const originalVoiceStart = voice.start; + const probe = { events: [], activations: [], voiceMutations: [], voiceStarts: [] }; + const voiceState = () => ({ + present: Boolean(voice && root), status: voice?.status, startEpoch: voice?.startEpoch, + spaceKeyHeld: voice?.spaceKeyHeld, pushToTalkKeyHeld: voice?.pushToTalkKeyHeld, + pushToTalkMode: voice?.pushToTalkMode, radioVoiceDucked: voice?.radioVoiceDucked, + visibleStatus: root?.dataset.status, visibleHold: root?.dataset.pushToTalk || null, + }); + const record = (event) => probe.events.push({ + type: event.type, code: event.code || null, repeat: event.repeat || false, + trusted: event.isTrusted, detail: event.detail ?? null, + style: event.target.closest('.style-btn')?.dataset.style || null, + }); + // A claimed hold deliberately blurs the button. Repeats and release then + // target the document body, so observe Space beyond the original grid. + const recordSpace = (event) => { + if (event.code === 'Space') record(event); + }; + for (const type of ['keydown', 'keyup']) document.addEventListener(type, recordSpace, true); + grid.addEventListener('click', record, true); + manager.setStyle = function (...args) { + probe.activations.push(args[0]); + return originalSetStyle.apply(this, args); + }; + voice.start = async function (options) { + probe.voiceStarts.push({ + options: { ...options }, + focusedStyle: document.activeElement?.dataset.style || null, + activeTag: document.activeElement?.tagName || null, + }); + }; + const observer = new MutationObserver((records) => { + for (const record of records) probe.voiceMutations.push({ + attribute: record.attributeName, oldValue: record.oldValue, + value: root.getAttribute(record.attributeName), + }); + }); + if (root) observer.observe(root, { + attributes: true, attributeOldValue: true, attributeFilter: ['data-status', 'data-push-to-talk'], + }); + probe.voiceBefore = voiceState(); + probe.snapshot = () => ({ + events: [...probe.events], activations: [...probe.activations], voiceMutations: [...probe.voiceMutations], + voiceStarts: [...probe.voiceStarts], + voiceBefore: probe.voiceBefore, voiceNow: voiceState(), + selectedStyle: manager.activeStyle, selectedMap: manager.mapStackController.getActiveId(), + focusedStyle: document.activeElement?.dataset.style || null, + holdObservation: { + pageFocused: document.hasFocus(), visibility: document.visibilityState, + timerPending: Boolean(voice.pushToTalkHoldTimer), + focusOwnerMatches: document.activeElement === voice.pushToTalkHoldFocusOwner, + }, + }); + probe.reset = () => { + probe.events.length = 0; + probe.activations.length = 0; + probe.voiceMutations.length = 0; + probe.voiceStarts.length = 0; + probe.voiceBefore = voiceState(); + }; + probe.restore = () => { + manager.setStyle = originalSetStyle; + voice.start = originalVoiceStart; + observer.disconnect(); + for (const type of ['keydown', 'keyup']) document.removeEventListener(type, recordSpace, true); + grid.removeEventListener('click', record, true); + }; + window.__qaStyleKeyProbe = probe; + }); + let shortSpaceDown; + let shortSpaceReleased; + let longSpaceDown; + let longSpaceHeld; + let longSpaceReleased; + let styleSpaceIsDown = false; + try { + await page.keyboard.down('Space'); + styleSpaceIsDown = true; + shortSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot()); + await new Promise((resolve) => setTimeout(resolve, 150)); + await page.keyboard.up('Space'); + styleSpaceIsDown = false; + await new Promise((resolve) => setTimeout(resolve, 100)); + shortSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot()); + + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('retro'); + window.__qaStyleKeyProbe.reset(); + document.querySelector('.style-btn[data-style="normal"]')?.focus(); + }); + await page.keyboard.down('Space'); + styleSpaceIsDown = true; + longSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot()); + // Use the browser's clock, like the production hold timer. Runner-side + // delays can finish while Chromium's timer is still pending under load. + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 250))); + await page.keyboard.down('Space'); // exercise repeat without resetting the hold deadline + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 400))); + longSpaceHeld = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot()); + await page.keyboard.up('Space'); + styleSpaceIsDown = false; + await new Promise((resolve) => setTimeout(resolve, 100)); + longSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot()); + } finally { + if (styleSpaceIsDown) await page.keyboard.up('Space'); + await page.evaluate(() => { + window.__qaStyleKeyProbe?.restore(); + delete window.__qaStyleKeyProbe; + window.__godsEyeView.styleManager.setStyle('normal'); + }); + } + const voiceUntouched = (state) => state.voiceBefore.present + && state.voiceStarts.length === 0 && JSON.stringify(state.voiceBefore) === JSON.stringify(state.voiceNow) + && state.voiceMutations.length === 0; + check('Space down on focused Normal does not activate the style or start voice', + shortSpaceDown.focusedStyle === 'normal' && shortSpaceDown.selectedStyle === 'retro' + && shortSpaceDown.selectedMap === 'osm' && shortSpaceDown.activations.length === 0 + && shortSpaceDown.events.every((event) => event.type !== 'click') + && shortSpaceDown.voiceStarts.length === 0 && shortSpaceDown.voiceMutations.length === 0 + && shortSpaceDown.voiceNow.status === shortSpaceDown.voiceBefore.status + && shortSpaceDown.voiceNow.startEpoch === shortSpaceDown.voiceBefore.startEpoch + && shortSpaceDown.voiceNow.spaceKeyHeld === true + && shortSpaceDown.voiceNow.pushToTalkKeyHeld === false, + JSON.stringify(shortSpaceDown)); + const releasedClicks = shortSpaceReleased.events.filter((event) => event.type === 'click'); + check('short Space activates Normal once on trusted key release and preserves OSM', + shortSpaceReleased.focusedStyle === 'normal' && shortSpaceReleased.selectedStyle === 'normal' + && shortSpaceReleased.selectedMap === 'osm' + && JSON.stringify(shortSpaceReleased.activations) === JSON.stringify(['normal']) + && releasedClicks.length === 1 && releasedClicks[0].trusted && releasedClicks[0].detail === 0 + && shortSpaceReleased.events.some((event) => event.type === 'keyup' && event.trusted) + && voiceUntouched(shortSpaceReleased), JSON.stringify(shortSpaceReleased)); + check('long Space blurs the focused style before requesting push-to-talk', + longSpaceDown.focusedStyle === 'normal' && longSpaceDown.selectedStyle === 'retro' + && longSpaceDown.activations.length === 0 && longSpaceDown.voiceStarts.length === 0 + && longSpaceHeld.focusedStyle === null && longSpaceHeld.selectedStyle === 'retro' + && longSpaceHeld.activations.length === 0 + && longSpaceHeld.events.some((event) => event.type === 'keydown' && event.repeat && event.trusted) + && longSpaceHeld.events.every((event) => event.type !== 'click') + && longSpaceHeld.voiceStarts.length === 1 + && longSpaceHeld.voiceStarts[0].options.pushToTalk === true + && longSpaceHeld.voiceStarts[0].focusedStyle === null, + JSON.stringify({ down: longSpaceDown, held: longSpaceHeld })); + check('releasing a claimed long Space hold does not activate the blurred style', + longSpaceReleased.focusedStyle === null && longSpaceReleased.selectedStyle === 'retro' + && longSpaceReleased.selectedMap === 'osm' && longSpaceReleased.activations.length === 0 + && longSpaceReleased.voiceStarts.length === 1 + && longSpaceReleased.events.every((event) => event.type !== 'click'), + JSON.stringify(longSpaceReleased)); + if (forceKeyless) { await page.evaluate(async () => { const styleManager = window.__godsEyeView.styleManager; @@ -251,6 +714,14 @@ try { if (controller.googleTileset) controller.googleTileset.show = false; controller.googleTileset = null; controller.cesiumToken = ''; + // Availability is now composed in the source registry. Override only + // this fixture's choices, as the previous token-field seam did. + for (const source of controller._sources.values()) { + if (source.descriptor.requiresIon || source.descriptor.kind === 'photoreal') { + source.available = false; + } + } + controller._registry.state.hasCesiumIonToken = false; await styleManager._setMapStack('osm', { syncShare: false }); styleManager._initMapStackControl(); }); @@ -273,12 +744,29 @@ try { const activeBeforeIonAttempt = await page.evaluate(() => ( window.__godsEyeView.styleManager.mapStackController.getActiveId() )); + // The long-Space test above deliberately blurs its style button. On a slower + // keyless rebuild that can give the tray's pending auto-close enough time to + // hide its chips before page.focus() runs. Focus the disclosure first to + // clear that close timer, then ensure the tray is visibly open so this check + // exercises the unavailable tile rather than a hidden element. + await page.focus('#control-panel-toggle'); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed( + 'control-panel', false, { persist: false, syncShare: false }, + )); + await waitTray('true', null); await page.focus('[data-stack-id="bing-aerial"]'); const ionAvailable = await page.$eval( '[data-stack-id="bing-aerial"]', (chip) => chip.getAttribute('aria-disabled') !== 'true', ); - await page.click('[data-stack-id="bing-aerial"]'); + const ionFocusedBeforeActivation = await page.$eval( + '[data-stack-id="bing-aerial"]', + (chip) => document.activeElement === chip, + ); + // Exercise the keyboard path. Unavailable tiles intentionally reject pointer + // hit testing while remaining focusable so their missing-key explanation is + // available to keyboard and assistive-technology users. + await page.keyboard.press('Space'); if (ionAvailable) { // Cesium creates the imagery provider asynchronously. Wait for controller // truth instead of assuming a keyed switch can settle in one animation. @@ -304,11 +792,12 @@ try { .map((candidate) => candidate.dataset.stackId), }; }); + ionSource.focusedBeforeActivation = ionFocusedBeforeActivation; if (forceKeyless || ionSource.ariaDisabled === 'true') { check( 'key-required sources stay focusable, explained, and inert when no ion token is configured', ionSource.ariaDisabled === 'true' - && ionSource.focused + && ionSource.focusedBeforeActivation // #143 names the missing key: "Needs CESIUM_ION_TOKEN — add it in Provider Settings". && /needs [A-Z_]+.*provider settings/i.test(ionSource.ariaLabel) && ionSource.activeId === activeBeforeIonAttempt @@ -437,15 +926,15 @@ try { }); const waitForQueuedNotice = async (label, timeoutMs = 1000) => { const deadline = performance.now() + timeoutMs; - while (styleManager._globalStatusNotice?.label !== label + while (styleManager._feedback._globalStatusNotice?.label !== label && performance.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 16)); } - return styleManager._globalStatusNotice?.label === label; + return styleManager._feedback._globalStatusNotice?.label === label; }; const baseNow = performance.now(); try { - styleManager._loadingFeedbackState = { + styleManager._feedback._loadingFeedbackState = { phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0, activeIds: [], batchOutcome: null, terminal: null, operation: null, }; @@ -465,7 +954,7 @@ try { styleManager._updateGlobalLoadingFeedback(baseNow); styleManager._updateGlobalLoadingFeedback(baseNow + 200); dataManager.getAll = () => []; - styleManager._loadingFeedbackEvent = { + styleManager._feedback._loadingFeedbackEvent = { type: 'visibility-failed', layerId: 'qa-unrelated-layer', error: new Error('QA offline'), @@ -575,8 +1064,17 @@ try { await new Promise((resolve) => setTimeout(resolve, 200)); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); // opens and hands focus to the active tile - await new Promise((resolve) => setTimeout(resolve, 400)); - await page.keyboard.press('Tab'); // tab ONTO a tile, keyboard modality + const selectedForHold = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId()); + await waitTray('true', selectedForHold); + // Keyless starts on OSM, the last tile. Tab forward there correctly leaves + // the tray, so navigate to a neighbouring tile in the available direction. + const activeIsLastTile = await page.evaluate(() => { + const chips = [...document.querySelectorAll('#map-stack-chips .map-stack-chip')]; + return document.activeElement === chips.at(-1); + }); + if (activeIsLastTile) await page.keyboard.down('Shift'); + await page.keyboard.press('Tab'); + if (activeIsLastTile) await page.keyboard.up('Shift'); await page.keyboard.press('Enter'); // activate it from the keyboard await new Promise((resolve) => setTimeout(resolve, 200)); const keyboardAfterActivate = await page.evaluate(() => ({ @@ -611,8 +1109,14 @@ try { await setControlPanelPinned(false); await page.evaluate(() => window.__godsEyeView.styleManager ._setMapStack('photoreal', { syncShare: false })); - // Hand the tray back OPEN and unpinned — the responsive block below starts by - // clicking the pin control, which is only hittable while the tray is showing. + // Approach the disclosure before opening: the previous mouse-away case + // intentionally leaves a pending close, so an unattended programmatic reopen + // can disappear while the next real pointer click is being dispatched. + const pinApproach = await page.$eval('#control-panel-toggle', (toggle) => { + const rect = toggle.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }); + await page.mouse.move(pinApproach.x, pinApproach.y); await page.evaluate(() => window.__godsEyeView.styleManager .setPanelCollapsed('control-panel', false, { explicit: true })); await new Promise((resolve) => setTimeout(resolve, 240)); @@ -638,6 +1142,8 @@ try { JSON.stringify(desktop), ); + await checkResponsiveStyleFocus(1000); + await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); await new Promise((resolve) => setTimeout(resolve, 180)); const at620 = await trayMetrics(); @@ -651,6 +1157,8 @@ try { JSON.stringify(at620), ); + await checkResponsiveStyleFocus(620); + await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 }); await new Promise((resolve) => setTimeout(resolve, 180)); const at480 = await trayMetrics(); @@ -669,6 +1177,7 @@ try { JSON.stringify(at480), ); await page.screenshot({ path: path.join(shotsDir, '480-open.png') }); + await checkResponsiveStyleFocus(480); await page.click('.dock-pin-btn[data-pin-target="control-panel"]'); await page.click('#control-panel-toggle'); @@ -746,6 +1255,396 @@ try { await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) }); } + // Location is a native disclosure: Enter is immediate, while Space waits for + // key release. A repeated keydown must not toggle or claim voice early. + await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await page.mouse.move(20, 20); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + for (const id of ['control-panel', 'location-bar']) { + manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false }); + manager.setPanelCollapsed(id, true, { persist: false, syncShare: false }); + } + const original = manager.setPanelCollapsed; + const probe = { transitions: [], restore: () => { manager.setPanelCollapsed = original; } }; + manager.setPanelCollapsed = function (id, ...args) { + const before = document.getElementById(id)?.classList.contains('collapsed'); + const result = original.call(this, id, ...args); + const after = document.getElementById(id)?.classList.contains('collapsed'); + if (id === 'location-bar' && before !== after) probe.transitions.push({ before, after }); + return result; + }; + window.__qaLocationFocus = probe; + }); + try { + await page.focus('#gev-voice-button'); + const locationTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 }); + const locationClosed = await locationState(); + check('Location: native disclosure semantics and closed Tab focus without opening', + locationTab.reached && hasVisibleControlFocus(locationClosed) + && locationClosed.tag === 'BUTTON' && locationClosed.type === 'button' + && locationClosed.controls === 'location-bar-popover' && locationClosed.popoverId === 'location-bar-popover' + && locationClosed.locationExpanded === 'false' && /^Expand Location$/i.test(locationClosed.locationLabel) + && locationClosed.transitions.length === 0, JSON.stringify({ locationTab, locationClosed })); + + await resetLocationTransitions(); + await page.keyboard.press('Enter'); + const locationEnter = await locationState(); + check('Location: Enter opens once with visible focus and accurate name', + hasVisibleControlFocus(locationEnter) && locationEnter.locationExpanded === 'true' + && /^Collapse Location$/i.test(locationEnter.locationLabel) && locationEnter.transitions.length === 1, + JSON.stringify(locationEnter)); + await page.keyboard.press('Escape'); + const locationEscape = await locationState(); + check('Location: Escape on the disclosure closes without retaining focus', + locationEscape.focusedId === null && locationEscape.locationExpanded === 'false' + && /^Expand Location$/i.test(locationEscape.locationLabel), JSON.stringify(locationEscape)); + + await page.focus('#location-bar-toggle'); + await resetLocationTransitions(); + const voiceBeforeLocationSpace = locationEscape.voice; + let locationSpaceHeld = false; + let locationSpaceDown; + let locationSpaceRepeat; + try { + await page.keyboard.down('Space'); + locationSpaceHeld = true; + locationSpaceDown = await locationState(); + await new Promise((resolve) => setTimeout(resolve, 320)); + await page.keyboard.down('Space'); + await page.keyboard.down('Space'); + locationSpaceRepeat = await locationState(); + await page.keyboard.up('Space'); + locationSpaceHeld = false; + } finally { + if (locationSpaceHeld) await page.keyboard.up('Space'); + } + const locationSpaceUp = await locationState(); + check('Location: short repeated Space toggles once on release without starting voice', + [locationSpaceDown, locationSpaceRepeat].every((state) => ( + state.focusedId === 'location-bar-toggle' && state.locationExpanded === 'false' + && state.transitions.length === 0 && state.voice.status === voiceBeforeLocationSpace.status + && state.voice.epoch === voiceBeforeLocationSpace.epoch + && state.voice.held === true && state.voice.pushToTalk === false + )) + && locationSpaceUp.focusedId === 'location-bar-toggle' + && locationSpaceUp.locationExpanded === 'true' && locationSpaceUp.transitions.length === 1 + && JSON.stringify(locationSpaceUp.voice) === JSON.stringify(voiceBeforeLocationSpace), + JSON.stringify({ down: locationSpaceDown, repeat: locationSpaceRepeat, up: locationSpaceUp })); + + const searchTab = await tabTo('#search-toggle', { limit: 30 }); + await page.keyboard.press('Enter'); + await page.keyboard.type('focus cleanup'); // no submission or navigation + const typedSearch = await page.$eval('#location-search', (input) => ({ + focused: document.activeElement === input, expanded: input.classList.contains('expanded'), value: input.value, + })); + await page.keyboard.press('Escape'); + const searchEscape = await locationState(); + check('Location: Escape clears typed search and restores disclosure focus', + searchTab.reached && typedSearch.focused && typedSearch.expanded && typedSearch.value === 'focus cleanup' + && searchEscape.focusedId === 'location-bar-toggle' && searchEscape.locationExpanded === 'false' + && !searchEscape.searchExpanded && searchEscape.searchValue === '', JSON.stringify({ typedSearch, searchEscape })); + + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); // begin the Map Source handoff + const siblingTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 30 }); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 1100)); + const siblingState = await locationState(); + check('Location: sibling opening cancels pending Map Source focus', + siblingTab.reached && siblingState.focusedId === 'location-bar-toggle' + && siblingState.locationExpanded === 'true' + && await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'false'), + JSON.stringify(siblingState)); + await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 }); + await page.keyboard.press('Enter'); + await page.focus('#control-panel-toggle'); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 400)); + const pinnedSibling = await locationState(); + check('Location: pinned tray survives sibling opening', + pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true' + && await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'true'), + JSON.stringify(pinnedSibling)); + await page.focus('#location-bar-toggle'); + await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 }); + await page.keyboard.press('Enter'); + check('Location: pin can be removed by keyboard', !(await locationState()).pinned); + + for (const width of [1000, 620, 480]) { + await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); + manager.setPanelCollapsed('location-bar', true, { persist: false, syncShare: false }); + }); + await page.focus('#gev-voice-button'); + const closedTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 }); + const closed = await locationState(); + check(`Location: ${width} px closed Tab ring is visible without opening`, + closedTab.reached && hasVisibleControlFocus(closed) && closed.locationExpanded === 'false', JSON.stringify(closed)); + await page.screenshot({ path: path.join(shotsDir, `${width}-location-closed-focus.png`) }); + await page.keyboard.press('Enter'); + await pressTabs(1); // disclosure -> pin + const openTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 3 }); + const open = await locationState(); + check(`Location: ${width} px open ShiftTab ring is visible`, + openTab.reached && hasVisibleControlFocus(open) && open.locationExpanded === 'true', JSON.stringify(open)); + await page.screenshot({ path: path.join(shotsDir, `${width}-location-open-focus.png`) }); + await page.keyboard.press('Escape'); + } + } finally { + await page.evaluate(() => { + window.__qaLocationFocus?.restore(); + delete window.__qaLocationFocus; + const manager = window.__godsEyeView.styleManager; + for (const id of ['control-panel', 'location-bar']) { + manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false }); + manager.setPanelCollapsed(id, true, { persist: false, syncShare: false }); + } + }); + } + + // The existing dev-only QA registration seam supplies stable ON/STALE rows + // through the real manager/renderer. These fixtures fetch nothing, render no + // entities and have no periodic update; production layers are never toggled. + let dataSetup; + try { + dataSetup = await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + const production = () => manager.getAll().filter((layer) => !layer.id.startsWith('qa-keyboard-focus-')) + .map((layer) => ({ + id: layer.id, enabled: layer.enabled, phase: layer.lifecycleState, uncertain: layer.lifecycleUncertain, + visibilityIntentEpoch: manager.layers.get(layer.id).visibilityIntentEpoch, + paramsIntentEpoch: manager.layers.get(layer.id).paramsIntentEpoch, + })); + // Inspect this known layer-state key only. Keep its value inside the page; + // evidence receives equality booleans, never stored contents. + const durableBefore = localStorage.getItem('gev:layer-state:v2'); + const state = { ids: [], before: production(), production, durableBefore, collapsed: document.getElementById('data-panel').classList.contains('collapsed') }; + window.__qaDataFocus = state; + if (typeof window.__gevQaRegisterLayer !== 'function' || typeof window.__gevQaUnregisterLayer !== 'function') { + return { ready: false, reason: 'Existing dev-only QA registration seam is unavailable' }; + } + for (const [suffix, stale] of [['on', false], ['status', true]]) { + const id = `qa-keyboard-focus-${suffix}`; + window.__gevQaRegisterLayer(manager, { + id, name: `QA focus ${stale ? 'STALE' : 'ON'}`, icon: '◌', source: 'Local focus fixture', + init() {}, enable() {}, disable() {}, destroy() {}, update() {}, + getStats: () => ({ count: 1, lastUpdate: Date.now() - (stale ? 60_000 : 0), stale, source: 'Local focus fixture' }), + }); + state.ids.push(id); + await manager.setEnabled(id, true, { origin: 'programmatic' }); + } + const transitionId = 'qa-keyboard-focus-transition'; + const transition = { + id: transitionId, + enableCalls: 0, + disableCalls: 0, + releaseEnable: null, + releaseDisable: null, + }; + state.transition = transition; + window.__gevQaRegisterLayer(manager, { + id: transitionId, + name: 'QA focus transition', + icon: '◌', + source: 'Local focus fixture', + init() {}, + enable() { + transition.enableCalls += 1; + return new Promise((resolve) => { + transition.releaseEnable = () => { + transition.releaseEnable = null; + resolve(); + }; + }); + }, + disable() { + transition.disableCalls += 1; + return new Promise((resolve) => { + transition.releaseDisable = () => { + transition.releaseDisable = null; + resolve(); + }; + }); + }, + destroy() {}, + update() {}, + getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }), + }); + state.ids.push(transitionId); + manager._renderToggles(); + const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id; + return { + ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId, + productionUnchanged: JSON.stringify(state.before) === JSON.stringify(production()), + durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2'), + }; + }); + check('Data Layers: explicit dev fixtures provide status coverage without changing production layers', + dataSetup.ready && dataSetup.productionUnchanged && dataSetup.durableUnchanged, JSON.stringify(dataSetup)); + if (dataSetup.ready) { + for (const width of [1000, 620, 480]) { + await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); + // Resizing schedules rail placement on animation frames. Wait for that + // pass and its CSS transitions before comparing a focus rectangle with + // hit testing; a fixed Tab delay can observe two different positions. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + await page.waitForFunction((expectedWidth) => { + const stack = document.getElementById('left-panel-stack'); + if (innerWidth !== expectedWidth || !stack) return false; + if ((stack.dataset.layoutMode === 'mobile') !== (expectedWidth <= 720)) return false; + return !stack.getAnimations({ subtree: true }).some((animation) => ( + animation instanceof CSSTransition && animation.playState === 'running' + )); + }, { timeout: 5_000 }, width); + await page.focus('#data-panel .panel-collapse-btn'); + const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]]; + for (const [id, label, enabled] of targets) { + const navigation = await tabTo(`[data-layer-id="${id}"] .data-toggle-btn`); + const state = await layerFocusState(id); + check(`Data Layers: ${width} px ${label} Tab focus ring is visible`, + navigation.reached && state.layerId === id && hasVisibleControlFocus(state) + && state.label === label && state.buttonActive === enabled && state.lifecycle?.enabled === enabled + && state.lifecycle?.lifecycleState === (enabled ? 'enabled' : 'disabled') && !state.lifecycle?.uncertain, + JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state })); + await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) }); + if (label === 'OFF') { + const passive = await page.evaluate((layerId) => { + const probe = window.__qaDataFocus; + const focusBefore = document.activeElement; + const productionBefore = JSON.stringify(probe.production()); + const durableBefore = localStorage.getItem('gev:layer-state:v2'); + window.__godsEyeView.dataManager._refreshTogglePanel(); + return { + focusRetained: document.activeElement === focusBefore + && focusBefore.matches('.data-toggle-btn') + && focusBefore.closest('[data-layer-id]')?.dataset.layerId === layerId, + productionUnchanged: productionBefore === JSON.stringify(probe.production()) + && productionBefore === JSON.stringify(probe.before), + durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2') + && durableBefore === probe.durableBefore, + }; + }, id); + const refreshed = await layerFocusState(id); + check(`Data Layers: ${width} px passive refresh preserves OFF focus and state`, + passive.focusRetained && passive.productionUnchanged && passive.durableUnchanged + && hasVisibleControlFocus(refreshed) && refreshed.label === 'OFF' && !refreshed.lifecycle?.enabled, + JSON.stringify({ passive, refreshed })); + } + if (label === 'STALE') check(`Data Layers: ${width} px native Tab scrolls to the lower status row`, + state.listScrollHeight > state.listClientHeight && state.listScrollTop > 0 && hasVisibleControlFocus(state), JSON.stringify(state)); + } + } + check('Data Layers: focus traversal leaves production visibility unchanged', await page.evaluate(() => ( + JSON.stringify(window.__qaDataFocus.before) === JSON.stringify(window.__qaDataFocus.production()) + && window.__qaDataFocus.durableBefore === localStorage.getItem('gev:layer-state:v2') + ))); + + await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); + await page.focus('#data-panel .panel-collapse-btn'); + const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`); + const transitionBefore = await layerFocusState(dataSetup.transitionId); + check('Data Layers: transition fixture receives native Tab focus', + transitionTab.reached && transitionBefore.label === 'OFF' && hasVisibleControlFocus(transitionBefore), + JSON.stringify({ transitionTab, transitionBefore })); + + await page.keyboard.press('Space'); + await page.waitForFunction((layerId) => { + const state = window.__qaDataFocus?.transition; + const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); + return state?.enableCalls === 1 && typeof state.releaseEnable === 'function' + && button?.textContent.trim() === 'ENABLING'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const enabling = await layerFocusState(dataSetup.transitionId); + check('Data Layers: focused Space keeps a visible ring through ENABLING', + hasVisibleControlFocus(enabling) && enabling.label === 'ENABLING' && !enabling.disabled + && enabling.ariaDisabled === 'true' && enabling.ariaBusy === 'true', JSON.stringify(enabling)); + const enablingEpoch = await page.evaluate((layerId) => ( + window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch + ), dataSetup.transitionId); + await page.keyboard.press('Space'); + const enablingRepeat = await page.evaluate((layerId) => ({ + calls: window.__qaDataFocus.transition.enableCalls, + epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch, + }), dataSetup.transitionId); + check('Data Layers: repeated Space is inert while ENABLING', + enablingRepeat.calls === 1 && enablingRepeat.epoch === enablingEpoch, JSON.stringify(enablingRepeat)); + await page.evaluate(() => window.__qaDataFocus.transition.releaseEnable()); + await page.waitForFunction((layerId) => { + const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId); + return state.enabled && state.lifecycleState === 'enabled'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const enabled = await layerFocusState(dataSetup.transitionId); + check('Data Layers: settled ON keeps the same visible keyboard focus', + hasVisibleControlFocus(enabled) && !enabled.disabled && enabled.ariaDisabled === 'false' + && enabled.ariaBusy === 'false' && enabled.lifecycle?.enabled, JSON.stringify(enabled)); + + await page.keyboard.press('Space'); + await page.waitForFunction((layerId) => { + const state = window.__qaDataFocus?.transition; + const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); + return state?.disableCalls === 1 && typeof state.releaseDisable === 'function' + && button?.textContent.trim() === 'DISABLING'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const disabling = await layerFocusState(dataSetup.transitionId); + check('Data Layers: focused Space keeps a visible ring through DISABLING', + hasVisibleControlFocus(disabling) && disabling.label === 'DISABLING' && !disabling.disabled + && disabling.ariaDisabled === 'true' && disabling.ariaBusy === 'true', JSON.stringify(disabling)); + const disablingEpoch = await page.evaluate((layerId) => ( + window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch + ), dataSetup.transitionId); + await page.keyboard.press('Space'); + const disablingRepeat = await page.evaluate((layerId) => ({ + calls: window.__qaDataFocus.transition.disableCalls, + epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch, + }), dataSetup.transitionId); + check('Data Layers: repeated Space is inert while DISABLING', + disablingRepeat.calls === 1 && disablingRepeat.epoch === disablingEpoch, JSON.stringify(disablingRepeat)); + await page.evaluate(() => window.__qaDataFocus.transition.releaseDisable()); + await page.waitForFunction((layerId) => { + const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId); + return !state.enabled && state.lifecycleState === 'disabled'; + }, { timeout: 5_000 }, dataSetup.transitionId); + const disabled = await layerFocusState(dataSetup.transitionId); + check('Data Layers: settled OFF keeps the same visible keyboard focus', + hasVisibleControlFocus(disabled) && !disabled.disabled && disabled.ariaDisabled === 'false' + && disabled.ariaBusy === 'false' && !disabled.lifecycle?.enabled, JSON.stringify(disabled)); + } + } finally { + const cleaned = await page.evaluate(async () => { + const state = window.__qaDataFocus; + if (!state) return { complete: false, reason: 'No fixture setup state' }; + const manager = window.__godsEyeView.dataManager; + state.transition?.releaseEnable?.(); + state.transition?.releaseDisable?.(); + const removed = []; + for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id)); + manager._renderToggles(); + window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false }); + // A native user-origin toggle legitimately asks the production state + // coordinator to persist. Restore the exact pre-fixture value so this + // hermetic QA journey leaves the user's durable layer snapshot untouched. + if (state.durableBefore === null) localStorage.removeItem('gev:layer-state:v2'); + else localStorage.setItem('gev:layer-state:v2', state.durableBefore); + const result = { + complete: removed.every(Boolean) && state.ids.every((id) => !manager.layers.has(id)), + productionUnchanged: JSON.stringify(state.before) === JSON.stringify(state.production()), + durableUnchanged: state.durableBefore === localStorage.getItem('gev:layer-state:v2'), + }; + delete window.__qaDataFocus; + return result; + }); + check('Data Layers: fixture teardown restores the original production registry and visibility', + cleaned.complete && cleaned.productionUnchanged && cleaned.durableUnchanged, JSON.stringify(cleaned)); + await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + } + check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | ')); } finally { await browser.close(); diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 550500315..c5629d2f4 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2,7 +2,8 @@ /** * Deterministic browser proof for the Radio companion layer. * - * Intercepts only `/api/radio/*` with a 750-station fixture and stubs the + * Intercepts Radio endpoints with a 750-station fixture, supplies fixed + * satellite/context support responses, and stubs the * browser media `play()` primitive. It proves marker scale/culling, dynamic * station-tag filtering, first-click selection, direct-action-only playback, panel/Context * independence, restoration without autoplay, responsive UI, and a clean @@ -25,11 +26,15 @@ const option = (name, fallback) => { }; const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173'); const APP_ORIGIN = new URL(APP_URL).origin; +// Radio owns this harness's keyboard and pointer actions; the welcome dialog +// has its own acceptance harness and must not intercept those gestures. +const RADIO_URL = new URL(APP_URL); +RADIO_URL.searchParams.set('welcome', '0'); const HEADFUL = args.includes('--headful'); const chromeCandidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', ].filter(Boolean); @@ -76,6 +81,22 @@ function check(name, ok, detail = '') { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// Native pick buffers and clustered overlay entries settle on rendered frames, +// not on wall-clock sleeps, particularly with software rendering. +async function settleRadioFrames(page) { + await page.evaluate(() => new Promise((resolve, reject) => { + const scene = window.__godsEyeView.viewer.scene; + let remaining = 4; + const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000); + const remove = scene.postRender.addEventListener(() => { + if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); } + else requestAnimationFrame(() => scene.requestRender()); + }); + scene.requestRender(); + })); +} + + async function main() { const response = await fetch(APP_URL).catch(() => null); if (!response?.ok) { @@ -87,7 +108,10 @@ async function main() { headless: HEADFUL ? false : 'new', ...(chrome ? { executablePath: chrome } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', + '--no-sandbox', '--disable-setuid-sandbox', + ...(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=1440,900', ], @@ -142,6 +166,28 @@ async function main() { let catalogResponseDelayMs = 0; page.on('request', (request) => { const url = new URL(request.url()); + + // These scenarios exercise Context lifecycle and keyboard ownership, not + // live orbit accuracy. Reuse the tracking suite's fixed element sets so + // CelesTrak outages cannot invalidate an otherwise clean UI run. + if (url.origin === APP_ORIGIN + && ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) { + const dense = url.pathname.endsWith('/starlink'); + request.respond({ + status: 200, + contentType: 'text/plain', + body: (dense ? [ + 'STARLINK-1007', + '1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004', + '2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345', + ] : [ + 'ISS (ZARYA)', + '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004', + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345', + ]).join('\n') + '\n', + }); + return; + } if (url.origin === APP_ORIGIN && url.pathname === '/api/radio/stations') { const response = { status: 200, @@ -213,7 +259,7 @@ async function main() { } }); - await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 }); await page.waitForFunction( @@ -281,7 +327,7 @@ async function main() { fullPlayEnabled: !document.getElementById('radio-play-btn').disabled, fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'), tunerVisible: !document.getElementById('radio-tuner').hidden, - tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length, + tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length, tunerLabel: document.getElementById('radio-tuner-band-label').textContent, }; }); @@ -332,19 +378,19 @@ async function main() { const staticResult = module.setTuningStatic(true); const filterBefore = module.getUIState().filter; const filterControl = document.getElementById('radio-filter'); - const originalPinned = style._radioTunerBandPinnedForNavigation; - const originalPool = style._radioTunerPool; - style._radioTunerBandPinnedForNavigation = true; + const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation; + const originalPool = style._radioControls._radioTunerPool; + style._radioControls._radioTunerBandPinnedForNavigation = true; filterControl.value = filterBefore === 'news' ? 'all' : 'news'; filterControl.dispatchEvent(new Event('change', { bubbles: true })); const rejectedUIFilter = { moduleFilter: module.getUIState().filter, controlFilter: filterControl.value, - pinned: style._radioTunerBandPinnedForNavigation, - poolIdentityPreserved: style._radioTunerPool === originalPool, + pinned: style._radioControls._radioTunerBandPinnedForNavigation, + poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool, }; - style._radioTunerBandPinnedForNavigation = originalPinned; - style._radioTunerPool = originalPool; + style._radioControls._radioTunerBandPinnedForNavigation = originalPinned; + style._radioControls._radioTunerPool = originalPool; const filterResult = module.setFilter('news'); const toggleResult = await module.togglePlayback({ origin: 'user' }); const directSelect = module.selectStation(stationId, { @@ -571,7 +617,14 @@ async function main() { await page.evaluate(() => window.__qaReleaseRadioEnable?.()); await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio')); await page.evaluate(() => window.__qaRestoreRadioEnable?.()); - await sleep(650); + await page.waitForFunction(() => { + const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); + const viewport = scroller.getBoundingClientRect(); + const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect(); + const play = document.getElementById('radio-play-btn').getBoundingClientRect(); + return directory.top >= viewport.top && directory.bottom <= viewport.bottom + && play.top >= viewport.top && play.bottom <= viewport.bottom; + }, { timeout: 10_000 }); const explicitRevealAfter = await page.evaluate(() => { const gev = window.__godsEyeView; const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); @@ -638,6 +691,7 @@ async function main() { viewer.scene.requestRender(); }, { lon: spec.lon, height: view.height }); await sleep(700); + await settleRadioFrames(page); const sample = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; @@ -729,10 +783,10 @@ async function main() { const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds; const originalIsEnabled = dataManager.isEnabled; const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled; - const originalClear = styleManager._clearLayersOutsideContextMode; - const originalCapture = styleManager._captureContextSessionSnapshot; - const originalContextMode = styleManager._contextMode; - const originalSnapshot = styleManager._contextSessionSnapshot; + const originalClear = styleManager._contextControls._clearLayersOutsideContextMode; + const originalCapture = styleManager._contextControls._captureContextSessionSnapshot; + const originalContextMode = styleManager._contextControls._contextMode; + const originalSnapshot = styleManager._contextControls._contextSessionSnapshot; const originalShowToast = styleManager._showToast; const installations = dataManager.layers.get('military-installations')?.module; const originalSearchNearby = installations?.searchNearby; @@ -743,12 +797,12 @@ async function main() { const semanticFalseMessage = document.getElementById('toast').textContent; const enabled = new Set(); - styleManager._captureContextSessionSnapshot = () => {}; + styleManager._contextControls._captureContextSessionSnapshot = () => {}; const runContextFailure = async (mode, phase, outcome) => { enabled.clear(); enabled.add('local-datacenters'); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(enabled), userAdded: new Set(), userRemoved: new Set(), @@ -768,7 +822,7 @@ async function main() { return true; }; const result = await styleManager._runUserFacingContextAction( - () => styleManager._selectContextMode(mode), + () => styleManager._contextControls._selectContextMode(mode), `${mode} QA transition failed`, ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -777,8 +831,8 @@ async function main() { phase, outcome, result, - visibleMode: styleManager._contextMode, - snapshotCleared: styleManager._contextSessionSnapshot === null, + visibleMode: styleManager._contextControls._contextMode, + snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null, priorLayerRestored: enabled.has('local-datacenters'), entryEnabled: enabled.has(entryLayerId), flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled, @@ -816,8 +870,8 @@ async function main() { for (const outcome of ['false', 'reject']) { enabled.clear(); enabled.add('local-datacenters'); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(enabled), userAdded: new Set(), userRemoved: new Set(), @@ -832,7 +886,7 @@ async function main() { dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => { if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) { failureUsed = true; - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-failed', layerId: candidateId, enabled: shouldEnable, @@ -851,7 +905,7 @@ async function main() { enabled: true, origin: 'user', }); - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-blocked', layerId, enabled: true, @@ -871,10 +925,10 @@ async function main() { reason, retryReason, toastMessages, - mode: styleManager._contextMode, - snapshotCleared: styleManager._contextSessionSnapshot === null, + mode: styleManager._contextControls._contextMode, + snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null, priorLayerRestored, - modeChanging: styleManager._contextModeChanging, + modeChanging: styleManager._contextControls._contextModeChanging, }); } } @@ -883,8 +937,8 @@ async function main() { const directActivationFailures = []; for (const layerId of ['military-awareness', 'rocket-launches']) { enabled.clear(); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(['local-datacenters']), userAdded: new Set(), userRemoved: new Set(), @@ -894,7 +948,7 @@ async function main() { shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId); return true; }; - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-failed', layerId, enabled: true, @@ -903,8 +957,8 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 50)); directActivationFailures.push({ layerId, - mode: styleManager._contextMode, - snapshotCleared: styleManager._contextSessionSnapshot === null, + mode: styleManager._contextControls._contextMode, + snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null, priorLayerRestored: enabled.has('local-datacenters'), failedLayerDisabled: !enabled.has(layerId), }); @@ -913,8 +967,8 @@ async function main() { const directActivationRollbackFailures = []; for (const layerId of ['military-awareness', 'rocket-launches']) { enabled.clear(); - styleManager._contextMode = null; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = null; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set(['local-datacenters']), userAdded: new Set(), userRemoved: new Set(), @@ -931,7 +985,7 @@ async function main() { shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId); return true; }; - styleManager._handleContextLayerChange({ + styleManager._contextControls._handleContextLayerChange({ type: 'visibility-failed', layerId, enabled: true, @@ -942,7 +996,7 @@ async function main() { directActivationRollbackFailures.push({ layerId, toastMessages, - retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true, + retryRetained: styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true, }); } @@ -972,8 +1026,8 @@ async function main() { window.__gevQaRegisterLayer(dataManager, qaLayer); try { const enabledBeforeExit = dataManager.getEnabledLayerIds(); - styleManager._contextMode = 'flights'; - styleManager._contextSessionSnapshot = { + styleManager._contextControls._contextMode = 'flights'; + styleManager._contextControls._contextSessionSnapshot = { enabledLayerIds: new Set([...enabledBeforeExit, layerId]), userAdded: new Set(), userRemoved: new Set(), @@ -985,13 +1039,13 @@ async function main() { }; const unhandledBefore = unhandled.length; const result = await styleManager._runUserFacingContextAction( - (notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }), + (notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }), `QA Context exit ${outcome} surfaced once`, ); - const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true; + const retainedForRetry = styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true; enableOutcome = 'success'; const retryResult = await styleManager._runUserFacingContextAction( - (notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }), + (notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }), `QA Context exit ${outcome} retry failed`, ); contextExitFailures.push({ @@ -1001,9 +1055,9 @@ async function main() { toastMessages, unhandledDelta: unhandled.length - unhandledBefore, retainedForRetry, - mode: styleManager._contextMode, - modeChanging: styleManager._contextModeChanging, - snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null, + mode: styleManager._contextControls._contextMode, + modeChanging: styleManager._contextControls._contextModeChanging, + snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null, enabledAfterRetry: dataManager.isEnabled(layerId), toastRole: document.getElementById('toast').getAttribute('role'), toastLive: document.getElementById('toast').getAttribute('aria-live'), @@ -1109,10 +1163,10 @@ async function main() { dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds; dataManager.isEnabled = originalIsEnabled; dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled; - styleManager._clearLayersOutsideContextMode = originalClear; - styleManager._captureContextSessionSnapshot = originalCapture; - styleManager._contextMode = originalContextMode; - styleManager._contextSessionSnapshot = originalSnapshot; + styleManager._contextControls._clearLayersOutsideContextMode = originalClear; + styleManager._contextControls._captureContextSessionSnapshot = originalCapture; + styleManager._contextControls._contextMode = originalContextMode; + styleManager._contextControls._contextSessionSnapshot = originalSnapshot; styleManager._showToast = originalShowToast; if (installations) installations.searchNearby = originalSearchNearby; window.removeEventListener('unhandledrejection', onUnhandled); @@ -1320,12 +1374,9 @@ async function main() { && clusterBadge?.rect?.h > 0, JSON.stringify(clusterBadge), ); - const highGlobalClusterLabels = await page.evaluate(async () => { + await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; - const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const ellipsoid = viewer.scene.globe.ellipsoid; - const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); viewer.camera.cancelFlight(); viewer.camera.setView({ destination: ellipsoid.cartographicToCartesian({ @@ -1337,6 +1388,14 @@ async function main() { }); viewer.scene.requestRender(); await new Promise((resolve) => setTimeout(resolve, 500)); + }); + await settleRadioFrames(page); + const highGlobalClusterLabels = await page.evaluate(async () => { + const viewer = window.__godsEyeView.viewer; + const radio = window.__godsEyeView.dataManager.layers.get('radio').module; + const ellipsoid = viewer.scene.globe.ellipsoid; + const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const source = radio.getOverlayDiagnostics(); const cameraPosition = viewer.camera.positionWC; const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z); @@ -1347,11 +1406,23 @@ async function main() { const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection; let clusterPoint = null; + // Pool order is not visibility order after a camera change. Choose an + // on-screen point independently of the native pick result being tested. + const visiblePoints = []; for (let index = 0; index < (points?.length || 0); index += 1) { const point = points.get(index); if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue; const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position); if (!anchor) continue; + if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue; + visiblePoints.push({ point, anchor }); + } + visiblePoints.sort((a, b) => ( + Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2) + - Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2) + )); + if (visiblePoints.length) { + const { point, anchor } = visiblePoints[0]; const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => ( picked?.primitive === point && picked?.id === point.id )); @@ -1359,7 +1430,6 @@ async function main() { maxDistance: point.distanceDisplayCondition?.far ?? null, pickable: Boolean(exactPick), }; - break; } return { source, @@ -1632,8 +1702,8 @@ async function main() { dataset: { ...stack.dataset }, }]; })), - preferredLeftPanelId: manager._leftStackPreferredPanelId, - preferredRightPanelId: manager._rightStackPreferredPanelId, + preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId, + preferredRightPanelId: manager._panelLayout._rightStackPreferredPanelId, hudMode: manager.hud.getMode(), hudVariant: manager.hud.getVariant(), focusId: document.activeElement?.id || null, @@ -1697,7 +1767,7 @@ async function main() { const scenePanel = document.getElementById('scene-panel'); const contextPanel = document.getElementById('global-context-panel'); result.tacticalAutoCollapse = { - latestOpenedPanel: manager._leftStackPreferredPanelId, + latestOpenedPanel: manager._panelLayout._leftStackPreferredPanelId, scenesExpanded: !scenePanel.classList.contains('collapsed'), dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'), dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0 @@ -1744,13 +1814,13 @@ async function main() { manager.hud.setMode(prior.hudMode); manager._updateHudButtonState(); await new Promise((resolve) => setTimeout(resolve, 320)); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } for (const [id, state] of Object.entries(prior.panels)) { const panel = document.getElementById(id); @@ -1769,18 +1839,18 @@ async function main() { for (const key of Object.keys(stack.dataset)) delete stack.dataset[key]; Object.assign(stack.dataset, state.dataset); } - manager._leftStackPreferredPanelId = prior.preferredLeftPanelId; - manager._rightStackPreferredPanelId = prior.preferredRightPanelId; + manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; + manager._panelLayout._rightStackPreferredPanelId = prior.preferredRightPanelId; localStorage.clear(); for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } // Disclosure restoration can wake the installed layout observers. Let // those observers settle, then restore the captured stack presentation @@ -1810,8 +1880,8 @@ async function main() { result.restoreDetails = { panelsRestored, stacksRestored, - preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId, - preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId, + preferredPanelRestored: manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId, + preferredRightPanelRestored: manager._panelLayout._rightStackPreferredPanelId === prior.preferredRightPanelId, hudModeRestored: manager.hud.getMode() === prior.hudMode, hudVariantRestored: manager.hud.getVariant() === prior.hudVariant, storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage), @@ -1875,7 +1945,10 @@ async function main() { hudVariant: manager.hud.getVariant(), focusId: document.activeElement?.id || null, }; - const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320)); + const waitForLayout = async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const cases = []; manager.setPanelCollapsed('global-context-panel', true); @@ -1952,7 +2025,25 @@ async function main() { // owns the real tracked-aircraft camera session. Hold the frame update so // the intentionally synthetic Cockpit shell is not auto-exited mid-check. const realCockpitUpdate = manager.cockpitView.update; - const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320)); + const intelHud = document.getElementById('intel-hud'); + const priorHudTransition = intelHud.style.getPropertyValue('transition'); + const priorHudTransitionPriority = intelHud.style.getPropertyPriority('transition'); + const waitForLayout = async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; + const waitForHudSettle = async () => { + await new Promise((resolve) => setTimeout(resolve, 560)); + // Let panel changes settle. qa-cockpit-utility covers scheduling with + // the real tracked-aircraft controller; this synthetic shell pauses it. + intelHud.getBoundingClientRect(); + // This fixture pauses the controller update. Run its actual layout + // methods against the settled HUD, then measure both unchanged lanes. + manager.cockpitView.syncContextLayout(); + manager.cockpitView.syncSignalLayout(); + manager._syncLeftPanelAdaptiveLayout(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const prior = { cockpit: document.body.classList.contains('cockpit-mode'), cockpitActive: manager.cockpitView.active, @@ -1971,7 +2062,7 @@ async function main() { detectionMode: manager.getDetectionState().detectionMode, models3dEnabled: manager._models3dEnabled, models3dMode: manager._models3dMode, - preferredLeftPanelId: manager._leftStackPreferredPanelId, + preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId, panels: Object.fromEntries([ 'data-panel', 'global-context-panel', @@ -1989,6 +2080,9 @@ async function main() { let result = {}; try { manager.cockpitView.update = () => {}; + // This block measures settled panel geometry, not fade timing. The + // synthetic paused controller cannot advance layout during a HUD fade. + intelHud.style.setProperty('transition', 'none', 'important'); document.body.classList.add('cockpit-mode'); manager.cockpitView.active = true; manager._setCockpitDisplayPortalActive(true); @@ -2002,7 +2096,7 @@ async function main() { manager._setHudVariant('tactical'); manager.hud.setMode('on'); manager._updateHudButtonState(); - await waitForLayout(); + await waitForHudSettle(); const layoutSteps = []; // The two Cockpit lanes are solved independently: the accordion against @@ -2055,9 +2149,7 @@ async function main() { layoutMode: document.getElementById('left-panel-stack').dataset.layoutMode, }); }; - // The Intel HUD fades over 400ms and keeps its readout rects for the - // whole transition, so both lanes are measured only once it has settled. - const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560)); + // Both lanes are measured against the settled HUD variant. for (let index = 0; index < 5; index += 1) { recordLayoutStep(); if (index < 4) { @@ -2208,19 +2300,23 @@ async function main() { const stableTop = utility.getBoundingClientRect().top; const transitionTops = []; let sampling = true; + let sampleFrame = null; const sampleTop = () => { + if (!sampling) return; transitionTops.push(utility.getBoundingClientRect().top); - if (sampling) requestAnimationFrame(sampleTop); + sampleFrame = requestAnimationFrame(sampleTop); }; - requestAnimationFrame(sampleTop); + sampleFrame = requestAnimationFrame(sampleTop); await manager._setMapStack('osm', { syncShare: false }); await waitForLayout(); sampling = false; + cancelAnimationFrame(sampleFrame); mapProviderUtilityStable = transitionTops.length > 1 && transitionTops.every((top) => Math.abs(top - stableTop) < 1); } result = { cockpitPanelInteraction, + layoutSteps, displayOpened, sharedDisplayControlsPortaled, radioOpened, @@ -2269,6 +2365,8 @@ async function main() { manager._setModels3dEnabled(prior.models3dEnabled); manager._setHudVariant(prior.hudVariant); manager.hud.setMode(prior.hudMode); + if (priorHudTransition) intelHud.style.setProperty('transition', priorHudTransition, priorHudTransitionPriority); + else intelHud.style.removeProperty('transition'); manager._updateHudButtonState(); manager.cockpitView.active = prior.cockpitActive; manager.cockpitView.update = realCockpitUpdate; @@ -2284,13 +2382,13 @@ async function main() { if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop); else hud.style.removeProperty('--cockpit-utility-top'); await waitForLayout(); - if (manager._leftStackLayoutFrame !== null) { - cancelAnimationFrame(manager._leftStackLayoutFrame); - manager._leftStackLayoutFrame = null; + if (manager._panelLayout._leftStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame); + manager._panelLayout._leftStackLayoutFrame = null; } - if (manager._rightStackLayoutFrame !== null) { - cancelAnimationFrame(manager._rightStackLayoutFrame); - manager._rightStackLayoutFrame = null; + if (manager._panelLayout._rightStackLayoutFrame !== null) { + cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame); + manager._panelLayout._rightStackLayoutFrame = null; } for (const [id, state] of Object.entries(prior.panels)) { const panel = document.getElementById(id); @@ -2301,7 +2399,7 @@ async function main() { else panel.setAttribute('aria-hidden', state.ariaHidden); manager._syncPanelCollapseButton(panel); } - manager._leftStackPreferredPanelId = prior.preferredLeftPanelId; + manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId; localStorage.clear(); for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); @@ -2316,6 +2414,8 @@ async function main() { && document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded) && hud.hidden === prior.hudHidden && signal.hidden === prior.signalHidden + && intelHud.style.getPropertyValue('transition') === priorHudTransition + && intelHud.style.getPropertyPriority('transition') === priorHudTransitionPriority && manager.hud.getMode() === prior.hudMode && manager.hud.getVariant() === prior.hudVariant && Object.entries(prior.panels).every(([id, state]) => { @@ -2324,7 +2424,7 @@ async function main() { && panel.getAttribute('style') === state.style && panel.getAttribute('aria-hidden') === state.ariaHidden; }) - && manager._leftStackPreferredPanelId === prior.preferredLeftPanelId + && manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId && JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage) && (!prior.focusId || document.activeElement?.id === prior.focusId); } @@ -2652,9 +2752,15 @@ async function main() { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; await radio.play(); const beforeSpace = radio.getUIState().audioState; - document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' })); + // Simulate an already-started hold-Space session; click-started open mic + // deliberately ignores Space takeover, and short taps never claim voice. + const priorPushToTalkMode = voice.pushToTalkMode; + voice.pushToTalkMode = true; + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; - document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' })); + document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + voice.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; @@ -2719,7 +2825,7 @@ async function main() { const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect(); const sliderRect = slider.getBoundingClientRect(); const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect(); - const count = style._radioTunerStations.length; + const count = style._radioControls._radioTunerStations.length; const usable = sliderRect.width - 14; const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1); const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, { @@ -2745,7 +2851,7 @@ async function main() { }; }; const playsBefore = window.__qaRadioPlayCalls.length; - const frozenSignature = style._radioTunerBandSignature; + const frozenSignature = style._radioControls._radioTunerBandSignature; pointer('pointerdown', xFor(0)); const left = snapshot(); const centerCoordinate = (count - 1) / 2; @@ -2765,14 +2871,14 @@ async function main() { visible: !document.getElementById('radio-tuner').hidden, stationCount: count, sliderMax: Number(slider.max), - bandFrozen: style._radioTunerBandSignature === frozenSignature, + bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature, left, center, shifted, right, - leftExpectedId: style._radioTunerStations[0]?.id || null, - centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null, - rightExpectedId: style._radioTunerStations[count - 1]?.id || null, + leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null, + centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null, + rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null, centerExpected: Math.floor(centerCoordinate + 0.5), tapeDelta, needleDelta, @@ -2817,7 +2923,7 @@ async function main() { const slider = document.getElementById('radio-tuner-slider'); // Capture a fresh gesture's complete presentation anchor before moving // to a different absolute directory station. - const beforeStations = [...style._radioTunerStations]; + const beforeStations = [...style._radioControls._radioTunerStations]; const sliderRect = slider.getBoundingClientRect(); const xFor = (index) => sliderRect.left + 7 + (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1); @@ -2834,7 +2940,7 @@ async function main() { const flyToCalls = []; camera.flyTo = (options) => flyToCalls.push(options); const before = { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, ids: beforeStations.map((station) => station.id), slot: Number(slider.value), selectedId: radio.getUIState().selected?.id || null, @@ -2848,10 +2954,10 @@ async function main() { .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; const flyToCallsBeforeCancel = flyToCalls.length; pointer('pointercancel', targetIndex); - const afterStations = style._radioTunerStations; + const afterStations = style._radioControls._radioTunerStations; const afterState = radio.getUIState(); const after = { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, ids: afterStations.map((station) => station.id), slot: Number(slider.value), selectedId: afterState.selected?.id || null, @@ -2972,7 +3078,7 @@ async function main() { const tunerRefreshTarget = await page.evaluate(() => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); - const stations = gev.styleManager._radioTunerStations; + const stations = gev.styleManager._radioControls._radioTunerStations; const targetIndex = 1; const rect = slider.getBoundingClientRect(); const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1); @@ -3055,7 +3161,7 @@ async function main() { const max = Number(slider.max); const direction = startSlot < max ? 1 : -1; const targetIndex = startSlot + direction; - const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null; + const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null; const rect = slider.getBoundingClientRect(); const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max); const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, { @@ -3101,23 +3207,32 @@ async function main() { && tunerDirectRelease.spread < 0.5, JSON.stringify(tunerDirectRelease), ); + // Earlier cases scroll and resize the directory. Bring the physical drag + // target back into its scroller before deriving viewport mouse coordinates. + await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' })); + await page.waitForFunction(() => { + const slider = document.getElementById('radio-tuner-slider'); + const rect = slider.getBoundingClientRect(); + const x = rect.left + 7 + (rect.width - 14) * Number(slider.value) / Math.max(1, Number(slider.max)); + return document.elementFromPoint(x, rect.top + rect.height / 2) === slider; + }); const tunerCommitTarget = await page.evaluate(() => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); const targetIndex = 11; slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 })); - const station = gev.styleManager._radioTunerStations[targetIndex]; + const station = gev.styleManager._radioControls._radioTunerStations[targetIndex]; const rect = slider.getBoundingClientRect(); const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max)); window.__qaRadioDelayNextPlay = true; return { id: station.id, targetIndex, - count: gev.styleManager._radioTunerStations.length, - frozenSignature: gev.styleManager._radioTunerBandSignature, + count: gev.styleManager._radioControls._radioTunerStations.length, + frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature, startX: rect.left + 7 + (rect.width - 14) * currentRatio, targetX: rect.left + 7 + (rect.width - 14) * targetIndex - / Math.max(1, gev.styleManager._radioTunerStations.length - 1), + / Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1), y: rect.top + rect.height / 2, }; }); @@ -3135,11 +3250,11 @@ async function main() { audioState: state.audioState, tuningStatic: state.tuningStatic, awaiting: state.tuningAwaitingStationId, - signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature, - selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id), + signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature, + selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id), sliderSlot: Number(slider.value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - pinned: gev.styleManager._radioTunerBandPinnedForNavigation, + pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation, }; }, tunerCommitTarget); check( @@ -3183,7 +3298,7 @@ async function main() { y: rect.top + rect.height / 2, ratio, usableWidth: rect.width - 14, - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, }; }); const microDragSamples = []; @@ -3199,7 +3314,7 @@ async function main() { pixelDelta, slot: Number(slider.value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, }; }, delta)); } @@ -3238,9 +3353,9 @@ async function main() { const gev = window.__godsEyeView; const style = gev.styleManager; const before = { - signature: style._radioTunerBandSignature, - ids: style._radioTunerStations.map((station) => station.id), - selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId), + signature: style._radioControls._radioTunerBandSignature, + ids: style._radioControls._radioTunerStations.map((station) => station.id), + selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId), needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), }; const current = gev.viewer.camera.positionCartographic; @@ -3256,11 +3371,11 @@ async function main() { }); gev.viewer.camera.changed.raiseEvent(); await new Promise((resolve) => setTimeout(resolve, 400)); - const stations = gev.styleManager._radioTunerStations; + const stations = gev.styleManager._radioControls._radioTunerStations; const selectedIndex = stations.findIndex((station) => station.id === selectedId); return { before, - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, ids: stations.map((station) => station.id), count: stations.length, selectedIndex, @@ -3279,14 +3394,14 @@ async function main() { const nextNeedleBefore = await page.evaluate(() => { const style = window.__godsEyeView.styleManager; const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id; - const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId); - const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId); - const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length; + const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId); + const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId); + const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length; return { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, selectedIndex, - count: style._radioTunerStations.length, - expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null, + count: style._radioControls._radioTunerStations.length, + expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null, }; }); await page.$eval('#radio-next-btn', (button) => button.click()); @@ -3294,15 +3409,15 @@ async function main() { const nextNeedleAfter = await page.evaluate(() => { const style = window.__godsEyeView.styleManager; const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id; - const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId); + const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId); return { - signature: style._radioTunerBandSignature, + signature: style._radioControls._radioTunerBandSignature, selectedId, selectedIndex, - count: style._radioTunerStations.length, + count: style._radioControls._radioTunerStations.length, sliderSlot: Number(document.getElementById('radio-tuner-slider').value), ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), - pinned: style._radioTunerBandPinnedForNavigation, + pinned: style._radioControls._radioTunerBandPinnedForNavigation, }; }); check( @@ -3322,13 +3437,14 @@ async function main() { const slider = document.getElementById('radio-tuner-slider'); const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, { bubbles: true, + cancelable: true, key: value, })); const markerId = () => gev.viewer.entities.values .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; const startIndex = Number(slider.value); - const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1); - const expectedId = style._radioTunerStations[expectedIndex]?.id || null; + const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1); + const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null; const playsBefore = window.__qaRadioPlayCalls.length; key('keydown', 'ArrowRight'); const preview = { @@ -3353,7 +3469,7 @@ async function main() { tuningActive: radio.getUIState().tuningActive, playDelta: window.__qaRadioPlayCalls.length - committedPlays, }; - const count = style._radioTunerStations.length; + const count = style._radioControls._radioTunerStations.length; const pageStep = Math.max(1, Math.round((count - 1) / 10)); const previewAndCancel = (value, expectedSlot) => { const beforePlays = window.__qaRadioPlayCalls.length; @@ -3376,7 +3492,8 @@ async function main() { previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)), previewAndCancel('PageDown', Math.max(0, base - pageStep)), ]; - return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation }; + return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation, + panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') }; }); check( 'keyboard preview is silent, key release commits once, and Escape restores the committed station', @@ -3388,7 +3505,8 @@ async function main() { && !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1 && tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId && tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex - && !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0, + && !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0 + && tunerKeyboard.panelStayedOpen, JSON.stringify(tunerKeyboard), ); check( @@ -3405,7 +3523,7 @@ async function main() { const radio = gev.dataManager.layers.get('radio').module; const slider = document.getElementById('radio-tuner-slider'); const rect = slider.getBoundingClientRect(); - const count = style._radioTunerStations.length; + const count = style._radioControls._radioTunerStations.length; const centerIndex = Math.floor((count - 1) / 2 + 0.5); const clientX = rect.left + rect.width / 2; slider.dispatchEvent(new PointerEvent('pointerdown', { @@ -3425,7 +3543,7 @@ async function main() { ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')), markerId: gev.viewer.entities.values .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null, - expectedId: style._radioTunerStations[centerIndex]?.id || null, + expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null, }; slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, @@ -3450,7 +3568,7 @@ async function main() { const fullPoolNavigation = await page.evaluate(() => { const gev = window.__godsEyeView; const radio = gev.dataManager.layers.get('radio').module; - const pool = gev.styleManager._radioTunerPool; + const pool = gev.styleManager._radioControls._radioTunerPool; const ids = pool.map((station) => station.id); const selectWithoutPlayback = (index) => radio.selectStation(ids[index], { autoplay: false, @@ -3500,7 +3618,7 @@ async function main() { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); const rect = slider.getBoundingClientRect(); - const count = gev.styleManager._radioTunerStations.length; + const count = gev.styleManager._radioControls._radioTunerStations.length; const current = Number(slider.value); const target = Math.min(count - 1, current + 4); const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1); @@ -3695,7 +3813,7 @@ async function main() { Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations').clustering.enabled = true; radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false }); - const pool = gev.styleManager._radioTunerPool; + const pool = gev.styleManager._radioControls._radioTunerPool; const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id); window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length]; window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length]; @@ -4390,7 +4508,10 @@ async function main() { }); await page.click('#context-radio-toggle-btn'); await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed')); - await sleep(450); + await page.waitForFunction((priorScroll) => ( + document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel' + && document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll + ), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop); const expandedContextRadioAfter = await page.evaluate(() => { const gev = window.__godsEyeView; gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState()); diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs new file mode 100644 index 000000000..f5c18dfed --- /dev/null +++ b/scripts/qa-scene-controls.mjs @@ -0,0 +1,393 @@ +#!/usr/bin/env node +/** Exercise installed Scene controls and real project/playback operations. */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const shots = path.resolve('qa-shots/scene-controls'); +fs.mkdirSync(shots, { recursive: true }); +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let promptValue = 'QA Scene'; +page.on('dialog', async (dialog) => { + await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); +}); +let failures = 0; +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +} +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector?._controls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(() => { + window.__qaSceneState = []; + window.__godsEyeView.sceneDirector.subscribe((notification) => + window.__qaSceneState.push(notification), + ); + }); + await page.click('[data-collapse-target="scene-panel"]'); + await page.click('#scene-new-btn'); + await page.waitForFunction( + () => + document.querySelector('#scene-select option:checked')?.textContent === + 'QA Scene', + ); + check( + 'New creates and selects a persisted empty scene', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const saved = JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + return ( + director._getSelectedScene().shots.length === 0 && + !!document.querySelector('.scene-shot-empty') && + saved.scenes.some((scene) => scene.title === 'QA Scene') + ); + }), + ); + await page.click('#scene-capture-btn'); + await page.waitForFunction( + () => document.querySelectorAll('.scene-shot-row').length === 1, + ); + check( + 'Capture stores the actual camera and visual state', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const shot = director._getSelectedScene().shots[0]; + const current = director.styleManager.getCameraState(); + return ( + Number.isFinite(shot.camera.lat) && + Math.abs(shot.camera.lat - current.lat) < 0.01 && + shot.visual.style === director.styleManager.getVisualState().style + ); + }), + ); + promptValue = 'QA shot'; + await page.click('.scene-shot-label', { count: 2 }); + await page.waitForFunction( + () => + document.querySelector('.scene-shot-label')?.textContent === + 'QA shot', + ); + check( + 'Rename persists literal text without interpreting markup', + await page.evaluate( + () => + !document.querySelector('.scene-shot-label b') && + JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ).scenes.some((scene) => + scene.shots.some((shot) => shot.title === 'QA shot'), + ), + ), + ); + await page.click('#scene-capture-btn'); + await page.waitForFunction( + () => document.querySelectorAll('.scene-shot-row').length === 2, + ); + await page.click('.scene-shot-label'); + check( + 'Shot selection and Update use the selected shot', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const scene = director._getSelectedScene(); + const count = scene.shots.length; + document.getElementById('scene-update-shot-btn').click(); + return ( + director._selectedShotId === scene.shots[0].id && + scene.shots.length === count && + document.querySelector('.scene-shot-row.active .scene-shot-label') + .textContent === scene.shots[0].title + ); + }), + ); + await page.evaluate(() => { + window.__sceneExports = []; + const create = URL.createObjectURL.bind(URL); + URL.createObjectURL = (blob) => { + if (blob.type === 'application/json') + window.__sceneExports.push(blob.text()); + return create(blob); + }; + }); + await page.click('#scene-export-btn'); + const exported = await page.evaluate(async () => + JSON.parse(await window.__sceneExports[0]), + ); + check( + 'Export produces the current project JSON', + exported.scenes.some( + (scene) => scene.title === 'QA Scene' && scene.shots.length === 2, + ), + ); + // Keep real camera/style playback while isolating this control check from + // live provider availability. Layer transactions are covered by their suites. + const scene = exported.scenes.find((item) => item.title === 'QA Scene'); + scene.shots = scene.shots + .slice(0, 1) + .map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); + const fixture = { ...exported, scenes: [scene] }; + const projectFile = path.join(shots, 'project.json'); + fs.writeFileSync(projectFile, JSON.stringify(fixture)); + const input = await page.$('#scene-import-file'); + await input.uploadFile(projectFile); + await page.waitForFunction( + () => + document.getElementById('scene-status').textContent === + 'Imported project.json', + ); + check( + 'Import replaces the project and clears the file input', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 1 && + document.querySelectorAll('.scene-shot-row').length === 1 && + document.getElementById('scene-import-file').value === '', + ), + ); + await page.click('.scene-shot-btn'); + await page.waitForFunction( + () => + document.getElementById('scene-status').textContent.startsWith('Loaded:'), + { timeout: 30000 }, + ); + check( + 'LOAD completes the real visual and camera operation', + await page.evaluate( + () => + !window.__godsEyeView.sceneDirector.running && + document + .getElementById('scene-status') + .textContent.includes('QA shot'), + ), + ); + await page.click('#scene-start-btn'); + await page.waitForFunction( + () => + window.__godsEyeView.sceneDirector.running && + document.body.classList.contains('scene-playback-mode'), + ); + check( + 'Playback enters recording presentation and disables editing', + await page.evaluate( + () => + document.getElementById('scene-capture-btn').disabled && + document.getElementById('scene-start-btn').disabled && + !document.getElementById('scene-stop-btn').disabled && + window.__godsEyeView.styleManager.getControlState().recording, + ), + ); + // A focused panel owns Escape to collapse one level. Release panel focus + // before testing the global playback shortcut; the canvas is not tabbable. + await page.evaluate(() => document.activeElement?.blur()); + check( + 'global playback shortcut has document focus', + await page.evaluate(() => document.activeElement === document.body), + ); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => + !window.__godsEyeView.sceneDirector.running && + !document.body.classList.contains('scene-playback-mode'), + ); + check( + 'Escape ends playback and restores idle/recording controls', + await page.evaluate( + () => + !document.getElementById('scene-start-btn').disabled && + document.getElementById('scene-stop-btn').disabled && + !document.getElementById('scene-download-btn').disabled && + !window.__godsEyeView.styleManager.getControlState().recording, + ), + ); + // Escape can also collapse the Scene accordion. Reopen it through its + // installed disclosure before testing the next visible user action. + if ( + await page.$eval('#scene-panel', (panel) => + panel.classList.contains('collapsed'), + ) + ) + await page.click('[data-collapse-target="scene-panel"]'); + await page.waitForSelector('#scene-download-btn', { visible: true }); + await page.click('#scene-download-btn'); + const metadata = await page.evaluate(async () => + JSON.parse(await window.__sceneExports.at(-1)), + ); + check( + 'Run download contains the completed cancelled run', + Boolean( + metadata.endedAt && + metadata.wasCancelled && + Array.isArray(metadata.events), + ), + ); + await page.click('#scene-start-btn'); + await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); + await page.$eval('#scene-stop-btn', (button) => button.click()); + await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); + check( + 'The installed Stop control also completes playback cleanup', + await page.evaluate( + () => + !document.body.classList.contains('scene-playback-mode') && + !document.getElementById('scene-start-btn').disabled, + ), + ); + const badFile = path.join(shots, 'invalid.json'); + fs.writeFileSync(badFile, '{invalid'); + await input.uploadFile(badFile); + await page.waitForFunction(() => + document + .getElementById('scene-status') + .textContent.includes('invalid JSON'), + ); + check( + 'Invalid import reports failure and preserves the current project', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 1 && + window.__godsEyeView.sceneDirector._getSelectedScene().title === + 'QA Scene', + ), + ); + await page.screenshot({ path: path.join(shots, 'desktop.png') }); + await page.setViewport({ width: 390, height: 844 }); + await page.screenshot({ path: path.join(shots, 'narrow.png') }); + check( + 'Scene controls stay inside the narrow viewport', + await page.$eval('#scene-select', (element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; + }), + ); + await page.setViewport({ width: 1440, height: 900 }); + await page.click('.scene-shot-danger'); + await page.waitForFunction( + () => !!document.querySelector('.scene-shot-empty'), + ); + check( + 'Delete shot leaves the empty-state presentation', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === + 0, + ), + ); + await page.click('#scene-delete-btn'); + await page.waitForFunction( + () => + !window.__godsEyeView.sceneDirector._project.scenes.some( + (item) => item.title === 'QA Scene', + ), + ); + check( + 'Deleting the final scene restores the built-in recipes', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length > 0 && + document.querySelectorAll('#scene-select option').length > 0, + ), + ); + check( + 'Scene subscriptions include current state and the completed native editing actions', + await page.evaluate(() => { + const seen = window.__qaSceneState; + const types = new Set(seen.map(({ change }) => change?.type)); + return ( + seen[0].initial && + Object.isFrozen(seen[0].state) && + [ + 'scene-created', + 'shot-captured', + 'shot-renamed', + 'shot-updated', + 'project-exported', + 'project-imported', + 'shot-deleted', + 'scene-deleted', + 'run-event', + ].every((type) => types.has(type)) && + seen + .filter(({ change }) => change?.shot) + .every(({ change }) => Object.isFrozen(change.shot)) + ); + }), + ); + const teardown = await page.evaluate(async () => { + const director = window.__godsEyeView.sceneDirector; + const controls = director._controls; + const previousStatus = document.getElementById('scene-status').textContent; + const project = director._project; + let finish; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const pending = controls.run('import', { + name: 'late.json', + text: async () => { + await gate; + return '{"scenes":[]}'; + }, + }); + const stopping = director.destroy(); + const notificationsAtStop = window.__qaSceneState.length; + const stoppedSynchronously = + controls.destroyed && + controls.removers.length === 0 && + controls.rowRemovers.length === 0; + document.getElementById('scene-new-btn').click(); + controls.run('capture'); + finish(); + await pending; + await stopping; + await director.destroy(); + return { + stoppedSynchronously, + stateStopped: window.__qaSceneState.length === notificationsAtStop, + sameProject: director._project === project, + noLateStatus: + document.getElementById('scene-status').textContent === previousStatus, + rowsGone: !document.querySelector('.scene-shot-row'), + }; + }); + check( + 'Disposal revokes fixed and row actions before waiting for scene work', + teardown.stoppedSynchronously && teardown.rowsGone, + ); + check( + 'Late import completion cannot replace the disposed project or status', + teardown.sameProject && teardown.noLateStatus, + ); + check( + 'Scene subscriptions stop before late imports settle', + teardown.stateStopped, + ); + check( + 'Scene control interaction produces no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index bc7d8f1e3..93eb6eb02 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -70,7 +70,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-traffic-baseline.mjs b/scripts/qa-traffic-baseline.mjs index b39759507..7059bf438 100644 --- a/scripts/qa-traffic-baseline.mjs +++ b/scripts/qa-traffic-baseline.mjs @@ -55,7 +55,7 @@ const DEGRADED_DELAY_MS = Math.max(0, getNumberOpt('--degraded-delay-ms', 2500)) const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 72c540f81..73d28ca60 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -61,7 +61,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-traffic-navigation.mjs b/scripts/qa-traffic-navigation.mjs new file mode 100644 index 000000000..b45daeb2c --- /dev/null +++ b/scripts/qa-traffic-navigation.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Deterministic city-navigation exercise; road/flow responses are fixtures. +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { trafficFixtureResponse } from './traffic-fixtures.mjs'; + +const args = process.argv.slice(2); +const url = args[args.indexOf('--url') + 1] || 'http://localhost:4173'; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const browser = await puppeteer.launch({ + headless: true, + protocolTimeout: 120000, + args: [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-background-timer-throttling', + ], +}); +try { + for (const cctv of [false, true]) { + const page = await browser.newPage(); + const errors = []; + let londonRequests = 0; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.text().includes('[Data:Traffic]')) + console.log(message.text()); + }); + await page.setViewport({ width: 1280, height: 800 }); + await page.setRequestInterception(true); + page.on('request', (request) => { + let response = trafficFixtureResponse(request); + const query = new URLSearchParams(request.postData()).get('data') || ''; + if (query.includes('highway') && query.includes('(51.')) { + londonRequests++; + if (londonRequests === 1) + response = { status: 504, body: 'Temporary road source timeout' }; + } + return response ? request.respond(response) : request.continue(); + }); + await page.goto(`${url}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 60000, + }); + await sleep(5000); + await page.keyboard.press('Escape'); + await page.evaluate(async (withCctv) => { + const { viewer, dataManager } = window.__godsEyeView; + const C = await import('/node_modules/.vite/deps/cesium.js'); + for (const id of ['satellites', 'flights', 'military']) + await dataManager.setEnabled(id, false); + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: C.Cartesian3.fromDegrees(-97.744, 30.267, 3200), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await dataManager.setEnabled('traffic', true); + if (withCctv) await dataManager.setEnabled('cctv', true); + }, cctv); + const ready = () => { + const stats = window.__godsEyeView.dataManager.layers + .get('traffic') + .module.getStats(); + return stats.count > 0 && !stats.loading; + }; + try { + await page.waitForFunction(ready, { timeout: 45000 }); + } catch (error) { + console.log( + 'Traffic timeout state', + await page.evaluate(() => { + const { viewer, dataManager } = window.__godsEyeView; + return { + stats: dataManager.layers.get('traffic').module.getStats(), + camera: viewer.camera.positionCartographic, + }; + }), + ); + throw error; + } + await sleep(2500); + await page.evaluate(async () => { + const { viewer } = window.__godsEyeView; + const C = await import('/node_modules/.vite/deps/cesium.js'); + viewer.camera.flyTo({ + destination: C.Cartesian3.fromDegrees(-0.1276, 51.5072, 3200), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + duration: 4, + }); + }); + const deadline = Date.now() + 45000; + while (londonRequests < 2 && Date.now() < deadline) await sleep(500); + assert.ok( + londonRequests >= 2, + 'London must recover its failed request without a toggle', + ); + await page.waitForFunction(ready, { timeout: 45000 }); + const stats = await page.evaluate(() => + window.__godsEyeView.dataManager.layers.get('traffic').module.getStats(), + ); + assert.equal(stats.error, null); + assert.deepEqual(errors, []); + console.log( + 'PASS: Austin → London, first destination request fails then recovers', + { cctv, londonRequests, count: stats.count }, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('traffic', false), + ); + assert.equal( + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('traffic') + .module.getStats().loading, + ), + false, + ); + await page.close(); + } +} finally { + await browser.close(); +} diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index f2c1d7352..44319af0d 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -77,7 +77,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 3f52c5c6e..38382f8c4 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -25,6 +25,8 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-traffic.mjs --url http://localhost:4410 + * Add --fixtures for synthetic roads and the recorded flow tile without a key; + * that mode does not qualify live source access or quota accounting. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -44,6 +46,8 @@ const getOpt = (name, dflt) => { }; const APP_URL = getOpt('--url', 'http://localhost:4410'); const HEADFUL = argv.includes('--headful'); +const FIXTURES = argv.includes('--fixtures'); +const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -53,7 +57,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', @@ -112,7 +116,8 @@ async function main() { process.exit(2); } - const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); process.exit(2); @@ -133,6 +138,11 @@ async function main() { let exitCode = 0; try { const page = await browser.newPage(); + const onFixture = (request) => { + const response = fixtureResponse(request); + if (response) void request.respond(response); else void request.continue(); + }; + if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -152,7 +162,8 @@ async function main() { () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(1500); + await sleep(5000); + await page.keyboard.press('Escape'); // ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom // coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for @@ -240,16 +251,17 @@ async function main() { // ── (iii) budget honesty ───────────────────────────────────────────────── console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...'); { - const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); + const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); const grew = statusAfter.dailyCount - statusBefore.dailyCount; const ok = grew >= 0 && grew <= flowRequests.length; - record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok, - `before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`); + record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok, + FIXTURES ? 'Synthetic source mode; live quota was not exercised' : `before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`); if (!ok) exitCode = 1; } // ── (v) KEYLESS fallback (intercepted — server key untouched) ──────────── console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...'); + if (FIXTURES) page.off('request', onFixture); await page.setRequestInterception(true); const keylessFlowReqs = []; page.on('request', (req) => { @@ -263,6 +275,8 @@ async function main() { req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); return; } + const response = fixtureResponse(req); + if (response) { void req.respond(response); return; } try { req.continue(); } catch { /* already handled */ } }); await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -270,7 +284,8 @@ async function main() { () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); - await sleep(1500); + await sleep(5000); + await page.keyboard.press('Escape'); let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 }); if (!simStats || simStats.count === 0) { // Public Overpass can throttle bursts across harness runs — one retry. diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs new file mode 100644 index 000000000..d393648df --- /dev/null +++ b/scripts/qa-ui-disposal.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +/** Exercise the real UI disposal path, including unrelated resource owners. */ +import puppeteer from 'puppeteer'; +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +} +try { + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._dataManager && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + const counts = {}; + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: '`', + code: 'Backquote', + bubbles: true, + cancelable: true, + }), + ); + const fpsReadout = document.querySelector('.frame-rate-readout'); + const fpsWasVisible = fpsReadout && !fpsReadout.hidden; + const watchObserver = (name) => { + const owner = + name === '_draggableResizeObserver' + ? ui._panelPosition + : ui._panelLayout; + const observer = owner[name]; + if (!observer) return false; + const disconnect = observer.disconnect.bind(observer); + counts[name] = 0; + observer.disconnect = () => { + counts[name]++; + return disconnect(); + }; + return true; + }; + const observed = [ + '_commandDockTrayObserver', + '_draggableResizeObserver', + '_leftStackResizeObserver', + '_rightStackResizeObserver', + '_leftStackMutationObserver', + '_rightStackMutationObserver', + ].filter(watchObserver); + const cctvUnsubscribe = ui._cctvControls._cctvUnsubscribe; + counts.cctv = 0; + if (cctvUnsubscribe) + ui._cctvControls._cctvUnsubscribe = () => { + counts.cctv++; + return cctvUnsubscribe(); + }; + const contextFields = [ + '_contextManagerUnsubscribe', + '_dataManagerVisibilityRequestUnsubscribe', + '_dataManagerVisibilityGuardUnsubscribe', + '_dataManagerBeforeDestroyUnsubscribe', + ]; + const contextConnected = contextFields.every( + (field) => typeof ui._contextControls[field] === 'function', + ); + for (const field of contextFields) { + const unsubscribe = ui._contextControls[field]; + counts[field] = 0; + ui._contextControls[field] = () => { + counts[field]++; + return unsubscribe?.(); + }; + } + const cockpit = ui.cockpitView; + const portal = ui._cockpitDisplayPortal; + const cockpitListenerCount = cockpit._listenerRemovers.length; + counts.cockpit = 0; + cockpit._listenerRemovers = cockpit._listenerRemovers.map( + (remove) => () => { + counts.cockpit++; + return remove(); + }, + ); + const portalHomes = portal.records.map((record) => ({ + group: record.group, + home: record.anchor.parentNode, + anchor: record.anchor, + })); + portal.setActive(true); + let releaseRestoration; + const restorationGate = new Promise((resolve) => { + releaseRestoration = resolve; + }); + const restoreForDisposal = ui._contextControls.restoreForDisposal.bind( + ui._contextControls, + ); + ui._contextControls.restoreForDisposal = async () => { + await restorationGate; + return restoreForDisposal(); + }; + const resizeHandler = ui._windowResizeHandler; + const removeEventListener = window.removeEventListener; + counts.resize = 0; + window.removeEventListener = function (type, callback, options) { + if (type === 'resize' && callback === resizeHandler) counts.resize++; + return removeEventListener.call(this, type, callback, options); + }; + const { setSplitFlapText } = await import('/src/splitFlap.js'); + const feedbackLabel = document.getElementById('global-loading-label'); + setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA'); + setSplitFlapText(feedbackLabel, 'LOAD COMPLETE'); + const permanentText = + feedbackLabel.querySelector('.gev-flap-text')?.firstChild; + let stateNotifications = 0; + ui.subscribeShareState(() => stateNotifications++, { emitCurrent: false }); + ui.subscribeLocationSearch(() => stateNotifications++, { + emitCurrent: false, + }); + try { + const disposal = ui.dispose(); + const focusBefore = document.activeElement; + const stoppedBeforeRestoration = + ui._panelLayout.destroyed && + ui._panelPosition.destroyed && + ui._feedback.destroyed && + ui._recording.destroyed && + cockpit.destroyed && + portal.stopped && + cockpit._listenerRemovers.length === 0 && + portal.frames.size === 0 && + cockpit.enter() === false && + cockpit.navigateContext(1) === false; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + const noDeferredFocus = document.activeElement === focusBefore; + releaseRestoration(); + await disposal; + ui._syncShareState(); + ui.subscribeShareState(() => stateNotifications++); + ui.subscribeLocationSearch(() => stateNotifications++); + const once = JSON.stringify(counts); + await ui.dispose(); + window.dispatchEvent(new Event('resize')); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return { + cockpitStoppedBeforeRestoration: + stoppedBeforeRestoration && noDeferredFocus, + cockpitReleased: + cockpit.disposed && + cockpit.destroyed && + counts.cockpit === cockpitListenerCount && + cockpitListenerCount > 0 && + portal.destroyed && + portal.listeners.signal.aborted && + portal.frames.size === 0 && + ui._cockpitDisplayPortal === null && + portalHomes.length === 4 && + portalHomes.every( + (record) => + record.group.parentNode === record.home && + !record.anchor.isConnected, + ), + connected: Boolean( + cctvUnsubscribe && + resizeHandler && + observed.includes('_commandDockTrayObserver'), + ), + contextReleased: + contextConnected && + ui._contextControls.destroyed && + contextFields.every( + (field) => + counts[field] === 1 && ui._contextControls[field] === null, + ), + observersReleased: observed.every( + (name) => + counts[name] === 1 && + (name === '_draggableResizeObserver' + ? ui._panelPosition + : ui._panelLayout)[name] === null, + ), + subscriptionReleased: + counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null, + resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, + controlsReleased: + ui._radioControls.destroyed && + ui._locationControls.destroyed && + ui._cctvControls.destroyed, + feedbackTextPreserved: + permanentText?.nodeType === Node.TEXT_NODE && + feedbackLabel.querySelector('.gev-flap-text')?.firstChild === + permanentText && + permanentText.data === 'LOAD COMPLETE' && + !feedbackLabel.classList.contains('gev-flap-active') && + feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length === + 0, + shellWorkReleased: + ui._lifetime.destroyed && + ui._lifetime.frames.size === 0 && + ui._lifetime.timers.size === 0 && + ui._lifetime.removers.size === 0, + frameRateReleased: + fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden, + stateStopped: stateNotifications === 0, + idempotent: once === JSON.stringify(counts), + }; + } finally { + releaseRestoration(); + window.removeEventListener = removeEventListener; + } + }); + check( + 'visible frame-rate monitor is removed on UI disposal', + result.frameRateReleased, + ); + check( + 'real UI has the expected live resources before disposal', + result.connected, + ); + check( + 'UI disposal releases all Context subscriptions and stops its controls', + result.contextReleased, + ); + check( + 'UI disposal disconnects each active panel observer once', + result.observersReleased, + ); + check( + 'UI disposal releases the CCTV state subscription', + result.subscriptionReleased, + ); + check( + 'UI disposal removes the registered window resize handler', + result.resizeReleased, + ); + check( + 'Radio, Location and CCTV controls are disposed with the UI', + result.controlsReleased, + ); + check( + 'Cockpit actions and queued portal work stop before layer restoration', + result.cockpitStoppedBeforeRestoration, + ); + check( + 'Cockpit cleanup releases listeners and returns all Display groups home', + result.cockpitReleased, + ); + check( + 'feedback teardown preserves the permanent accessible text node', + result.feedbackTextPreserved, + ); + check( + 'shell teardown cancels deferred work and releases listeners', + result.shellWorkReleased, + ); + check( + 'state subscriptions stop synchronously with UI disposal', + result.stateStopped, + ); + check('repeated UI disposal is inert', result.idempotent); + check( + 'disposal and subsequent resize produce no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index 04ad124e3..b5ad8ac87 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -13,14 +13,14 @@ * * Usage: * node scripts/qa-vessel-cards.mjs --tag new # current code - * node scripts/qa-vessel-cards.mjs --tag old # after swapping in the old layer file + * node scripts/qa-vessel-cards.mjs --tag old # from a checkout of the comparison revision * node scripts/qa-vessel-cards.mjs --ports rotterdam # subset * node scripts/qa-vessel-cards.mjs --url http://localhost:4173 * node scripts/qa-vessel-cards.mjs --data synthetic # deterministic test fixture * node scripts/qa-vessel-cards.mjs --data synthetic --headful # real-GPU capture * - * A/B flow: run --tag new, `git show :src/data/aisLiveVessels.js > - * src/data/aisLiveVessels.js`, run --tag old, then `git restore` the file. + * A/B flow: run each accepted revision from a separate checkout, using the + * same source configuration and camera fixtures with distinct --tag values. * Retained screenshots and same-basename JSON manifests land at * qa-shots/vessel-cards---.*. * The name and manifest both record the data source; the manifest also records @@ -184,7 +184,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', @@ -253,6 +253,10 @@ async function main() { const page = await browser.newPage(); await page.setViewport({ width: 1600, height: 900 }); + await page.evaluateOnNewDocument(() => { + // This harness inspects cards, so keep first-run chrome out of the view. + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + }); page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`)); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -319,7 +323,10 @@ async function main() { orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, }); }, port); - const resettled = settled ? true : await page + // A changed camera invalidates the previous settled result. Let the new + // view schedule its tiles before checking readiness again. + await new Promise((resolve) => setTimeout(resolve, 1500)); + const resettled = await page .waitForFunction(() => { const gev = window.__godsEyeView; const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); diff --git a/scripts/qa-vessel-datum.mjs b/scripts/qa-vessel-datum.mjs index ccd1464a0..188f447c7 100644 --- a/scripts/qa-vessel-datum.mjs +++ b/scripts/qa-vessel-datum.mjs @@ -61,7 +61,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', diff --git a/scripts/qa-view-target-prewarm.mjs b/scripts/qa-view-target-prewarm.mjs index c44cab207..d0d6cb711 100644 --- a/scripts/qa-view-target-prewarm.mjs +++ b/scripts/qa-view-target-prewarm.mjs @@ -32,14 +32,20 @@ fs.mkdirSync(shotsDir, { recursive: true }); const chromeCandidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)); const browser = await puppeteer.launch({ headless: headful ? false : 'new', ...(executablePath ? { executablePath } : {}), - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], + // Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init. + args: [ + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + '--no-sandbox', + ], }); const page = await browser.newPage(); const failures = []; diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs new file mode 100644 index 000000000..db0696e08 --- /dev/null +++ b/scripts/qa-visual-effects.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** Rendered acceptance for style transitions and effect-state ownership. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, passed) { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures++; +} +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._visualEffects && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60_000 }, + ); + await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.scene.tweens?.removeAll?.(); + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (-97.7431 * Math.PI) / 180, + latitude: (30.2568 * Math.PI) / 180, + height: 1200, + }), + orientation: { heading: 0, pitch: -0.85, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + const settled = await page + .waitForFunction( + () => { + const primitives = window.__godsEyeView.viewer.scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const primitive = primitives.get(i); + if ( + typeof primitive.tilesLoaded === 'boolean' && + !primitive.tilesLoaded + ) + return false; + } + return true; + }, + { timeout: 60_000 }, + ) + .then( + () => true, + () => false, + ); + check('visible tile content settles before visual captures', settled); + fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); + for (const style of [ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + 'normal', + ]) { + await page.evaluate( + (name) => window.__godsEyeView.styleManager.setStyle(name), + style, + ); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + { timeout: 10_000 }, + ); + const state = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const effects = manager._visualEffects; + const visible = Object.entries(effects.stages) + .filter(([, stage]) => stage.enabled) + .map(([name]) => name); + const animated = Object.values(effects.stages).some( + (stage) => + stage.enabled && + stage.uniforms.time !== undefined && + stage.uniforms.intensity > 0.001, + ); + return { + active: manager.activeStyle, + visible, + clock: effects.frameId !== null, + animated, + }; + }); + check( + `${style}: only the selected style remains visible`, + state.active === style && + JSON.stringify(state.visible) === + JSON.stringify(style === 'normal' ? [] : [style]), + ); + check( + `${style}: the animation clock follows visible animated stages`, + state.clock === state.animated, + ); + if (['normal', 'surveillance', 'thermal'].includes(style)) { + await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` }); + if (style === 'surveillance') + await page.evaluate(() => + window.__godsEyeView.viewer.camera.lookRight(0.2), + ); + } + } + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('noir'); + manager.setStyle('retro'); + manager.setStyle('normal'); + }); + await page.waitForFunction( + () => window.__godsEyeView.styleManager.transitions.size === 0, + { timeout: 10_000 }, + ); + check( + 'rapid switches settle to Normal without an old style or clock', + await page.evaluate(() => { + const effects = window.__godsEyeView.styleManager._visualEffects; + return ( + effects.frameId === null && + Object.values(effects.stages).every((stage) => !stage.enabled) + ); + }), + ); + check( + 'bloom actions, effect state and saved snapshot agree', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const states = []; + const unsubscribe = manager.subscribeShareState((value) => + states.push(value), + ); + const result = manager.setBloom({ enabled: true, intensityPct: 98 }); + unsubscribe(); + const published = states.at(-1); + const snapshot = manager.getVisualState(); + return ( + states[0].initial && + states.length > 1 && + Object.isFrozen(published.state.options) && + published.state.bloomEnabled && + published.state.options.bloomIntensity === 98 && + result.ok && + result.bloom.intensityPct === 98 && + snapshot.bloom.intensity === 98 && + manager._visualEffects.bloomIntensity === 98 && + manager._bloomStage.enabled + ); + }), + ); + check( + 'sharpen actions, effect state and saved snapshot agree', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const result = manager.setSharpen({ enabled: true, intensityPct: 61 }); + return ( + result.ok && + result.sharpen.intensityPct === 61 && + manager.getVisualState().sharpen.intensity === 61 && + manager._visualEffects.sharpenIntensity === 0.61 && + Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9 + ); + }), + ); + await page.setViewport({ width: 620, height: 900 }); + await page.evaluate(() => + window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }), + ); + await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' }); + const teardown = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const effects = manager._visualEffects; + manager.setStyle('retro'); + const owned = [...Object.values(effects.stages), effects.sharpenStage]; + const collection = manager.viewer.scene.postProcessStages; + const previousBloom = effects.previousBloom; + const pending = manager.dispose(); + const stoppedBeforeAwait = + effects.stopped && + effects.frameId === null && + effects.transitions.size === 0; + await pending; + return { + stoppedBeforeAwait, + removed: owned.every((stage) => !collection.contains(stage)), + restored: + effects.bloomStage.enabled === previousBloom.enabled && + Object.entries(previousBloom.uniforms).every( + ([name, value]) => effects.bloomStage.uniforms[name] === value, + ), + }; + }); + check( + 'UI teardown stops effect animation before its first await', + teardown.stoppedBeforeAwait, + ); + check( + 'UI teardown removes owned stages and restores borrowed bloom', + teardown.removed && teardown.restored, + ); + check( + 'effects do not introduce uncaught browser errors', + errors.length === 0, + ); + if (errors.length) console.log(JSON.stringify(errors)); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs new file mode 100644 index 000000000..3117e1414 --- /dev/null +++ b/scripts/qa-visual-input.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** Browser acceptance for application shortcuts and generated parameter controls. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +const check = (name, passed) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) failures += 1; +}; +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60_000 }, + ); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return ( + cover?.classList.contains('hidden') && + Number(getComputedStyle(cover).opacity) === 0 + ); + }, + { timeout: 60_000 }, + ); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('normal'); + document.activeElement?.blur(); + }); + await page.keyboard.press('2'); + check( + 'native number shortcut selects CRT', + await page.evaluate( + () => window.__godsEyeView.styleManager.activeStyle === 'retro', + ), + ); + const initial = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }); + manager._sliderPanel.classList.remove('collapsed'); + const slider = manager._sliderContainer.querySelector('input'); + window.__qaOldSlider = slider; + const name = 'pixelation'; + window.__qaParameter = { + name, + before: manager.stages.retro.uniforms[name], + }; + slider.focus(); + return { + before: Number(slider.value), + step: Number(slider.step), + label: slider.getAttribute('aria-label'), + }; + }); + check( + 'generated range has an accessible label and a numeric step', + initial.label === 'Pixelation' && initial.step > 0, + ); + await page.keyboard.press('ArrowRight'); + const changed = await page.evaluate(() => { + const slider = window.__qaOldSlider; + const manager = window.__godsEyeView.styleManager; + return { + value: Number(slider.value), + readout: slider.nextElementSibling.textContent, + uniform: manager.stages.retro.uniforms[window.__qaParameter.name], + }; + }); + check( + 'native range input changes the shader and formatted readout', + changed.value > initial.before && + changed.uniform === changed.value && + Number(changed.readout) === changed.value, + ); + await page.keyboard.press('3'); + check( + 'focused range retains native input instead of changing style', + await page.evaluate( + () => window.__godsEyeView.styleManager.activeStyle === 'retro', + ), + ); + check( + 'replacing a style revokes its detached slider', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const before = manager.stages.retro.uniforms[window.__qaParameter.name]; + manager.setStyle('thermal'); + window.__qaOldSlider.value = '0'; + window.__qaOldSlider.dispatchEvent(new Event('input', { bubbles: true })); + return ( + manager.stages.retro.uniforms[window.__qaParameter.name] === before + ); + }), + ); + check( + 'normal style clears rows; reopening restores current shader values', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setStyle('normal'); + const cleared = manager._sliderContainer.children.length === 0; + manager.setStyle('retro'); + return ( + cleared && + Number(manager._sliderContainer.querySelector('input').value) === + manager.stages.retro.uniforms[window.__qaParameter.name] + ); + }), + ); + const hudBefore = await page.evaluate(() => { + document.getElementById('hud-layout-select').focus(); + return window.__godsEyeView.styleManager.hud.visible; + }); + await page.keyboard.press('h'); + check( + 'HUD select keeps its native typing and selection', + await page.evaluate( + (visible) => + document.activeElement?.id === 'hud-layout-select' && + window.__godsEyeView.styleManager.hud.visible === visible, + hudBefore, + ), + ); + const displayBefore = await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setBloomEnabled(true); + manager._bloomSlider.value = '80'; + manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true })); + manager._bloomSlider.focus(); + return { + value: Number(manager._bloomSlider.value), + contrast: manager._bloomStage.uniforms.contrast, + }; + }); + await page.keyboard.press('ArrowRight'); + check( + 'native Display slider updates effect state', + await page.evaluate((before) => { + const manager = window.__godsEyeView.styleManager; + return ( + Number(manager._bloomSlider.value) > before.value && + manager._bloomStage.uniforms.contrast < before.contrast + ); + }, displayBefore), + ); + check( + 'Display buttons toggle current state once', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const before = manager.bloomEnabled; + manager._bloomBtn.click(); + const changed = manager.bloomEnabled !== before; + manager._bloomBtn.click(); + return changed && manager.bloomEnabled === before; + }), + ); + check( + 'Display rebind removes old listeners', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._initUI(); + const before = manager.bloomEnabled; + manager._bloomBtn.click(); + return manager.bloomEnabled !== before; + }), + ); + fs.mkdirSync('qa-shots/visual-input', { recursive: true }); + await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction( + () => + document.getElementById('left-panel-stack')?.dataset.layoutMode === + 'mobile', + ); + await page.evaluate(() => { + const slider = + window.__godsEyeView.styleManager._sliderContainer.querySelector('input'); + slider.focus(); + slider.scrollIntoView({ block: 'center', behavior: 'instant' }); + }); + await page.screenshot({ path: 'qa-shots/visual-input/narrow.png' }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + window.__qaCurrentSlider = manager._sliderContainer.querySelector('input'); + manager._applicationShortcuts.destroy(); + manager._styleParameters.destroy(); + document.activeElement?.blur(); + }); + await page.keyboard.press('3'); + check( + 'destroyed application shortcuts cannot change the selected style', + await page.evaluate( + () => window.__godsEyeView.styleManager.activeStyle === 'retro', + ), + ); + check( + 'destroyed parameter controls cannot write through a retained element', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const before = manager.stages.retro.uniforms[window.__qaParameter.name]; + window.__qaCurrentSlider.value = '0'; + window.__qaCurrentSlider.dispatchEvent( + new Event('input', { bubbles: true }), + ); + return ( + manager._sliderContainer.children.length === 0 && + manager.stages.retro.uniforms[window.__qaParameter.name] === before + ); + }), + ); + check( + 'destroyed Display controls cannot change settings', + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._displayControls.destroy(); + const before = manager.bloomEnabled; + const contrast = manager._bloomStage.uniforms.contrast; + manager._bloomBtn.click(); + manager._bloomSlider.value = '15'; + manager._bloomSlider.dispatchEvent(new Event('input', { bubbles: true })); + return ( + manager.bloomEnabled === before && + manager._bloomStage.uniforms.contrast === contrast + ); + }), + ); + check('runtime has no uncaught browser errors', errors.length === 0); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 994026249..4c5c9dedc 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -39,7 +39,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, - (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), + await puppeteer.executablePath().catch(() => null), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', ].filter(Boolean); diff --git a/scripts/qa-voice-wav.mjs b/scripts/qa-voice-wav.mjs index ead1aec59..b75524e7a 100644 --- a/scripts/qa-voice-wav.mjs +++ b/scripts/qa-voice-wav.mjs @@ -30,7 +30,7 @@ if (fixtureSha256 !== expectedFixtureSha256) { const appOrigin = new URL(appUrl).origin; const browser = await puppeteer.launch({ headless: 'new', - executablePath: puppeteer.executablePath(), + executablePath: await puppeteer.executablePath(), args: [ '--no-sandbox', '--disable-setuid-sandbox', diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index ddad3aa65..37c01156e 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -10,6 +10,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export const CREDENTIALS = Object.freeze([ { name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] }, + { name: 'GOOGLE_MAPS_SERVER_API_KEY', label: 'Google Places / Street View server', keychain: [] }, { name: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] }, { name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] }, { name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] }, diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 51c78654f..7f521edce 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -118,7 +118,7 @@ const CHROME_EXECUTABLE_CANDIDATES = [ // 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; } })(), + await puppeteer.executablePath().catch(() => 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', @@ -3203,10 +3203,25 @@ async function main() { await fl.update(v); await window.__dfSettle(600); } - const bb1 = window.__dfFindBB('aaa097'); - const d1 = bb1 ? window.__dfCarto(bb1.position) : null; + // The floor owner intentionally retains the previous cell near an + // edge. Sample inside a cell, beyond that hysteresis band, so the raw + // coordinate's floor is unambiguously the floor the sprite must use. + // Wait for geometry, not a passing height; a missing clamp still fails. + const interiorLimit = 0.0005 - gf.CELL_HYSTERESIS_DEG - 0.00002; + const sampleDeadline = Date.now() + 8000; + let d1 = null; + let sampleInterior = false; + do { + await window.__dfSettle(250); + const bb1 = window.__dfFindBB('aaa097'); + d1 = bb1 ? window.__dfCarto(bb1.position) : null; + sampleInterior = !!d1 + && Math.abs(d1.lat - cell(d1.lat)) < interiorLimit + && Math.abs(d1.lon - cell(d1.lon)) < interiorLimit; + } while (!sampleInterior && Date.now() < sampleDeadline); return { startCold, + sampleInterior, displayCell, fixCell, sameCellAsFix: displayCell.lat === fixCell.lat && displayCell.lon === fixCell.lon, @@ -3249,6 +3264,10 @@ async function main() { dfCorridor.controlFloor == null, `control cell floor = ${dfCorridor.controlFloor}`); + record('display-floor/corridor: the height sample clears cell-boundary hysteresis', + dfCorridor.sampleInterior === true, + 'the sampled coordinate lies inside one unambiguous floor cell'); + record('display-floor/corridor: the sprite rides the corridor-warmed floor', Number.isFinite(dfCorridor.spriteH) && Number.isFinite(dfCorridor.spriteFloor) && dfCorridor.spriteH >= dfCorridor.spriteFloor + DISPLAY_FLOOR_LIFT_M - 0.5, @@ -3268,9 +3287,11 @@ async function main() { const bbBefore = window.__dfFindBB('aaa097'); if (!bbBefore) return { error: 'aaa097 billboard missing' }; const d0 = window.__dfCarto(bbBefore.position); - // Plant a floor well ABOVE where it currently renders, across the block - // it can move within, so an UNFLOORED tracked entity is unmistakable. - const seeded = d0.h + 40; + // Plant above both the current billboard and this group's 400 m + // identity-probe floor. A poll can refresh the raw render altitude + // after d0 was read; a lower seed makes the negative model-ownership + // assertion impossible even when the clamp correctly stands aside. + const seeded = Math.max(d0.h, 400) + 40; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded); diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs new file mode 100644 index 000000000..54ad48358 --- /dev/null +++ b/scripts/traffic-fixtures.mjs @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs'; +import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js'; +import { tilesForBounds } from '../src/data/tomtomTiles.js'; + +const tile = readFileSync( + new URL( + '../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf', + import.meta.url, + ), +); +const json = (body) => ({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), +}); + +/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */ +export function trafficFixtureResponse(request) { + const url = new URL(request.url()); + if (url.pathname === '/api/tomtom/status') + return json({ hasKey: true, dailyCount: 0 }); + if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname)) + return { status: 200, contentType: 'application/x-protobuf', body: tile }; + if (url.pathname !== '/api/overpass') return null; + const query = new URLSearchParams(request.postData()).get('data') || ''; + if (!query.includes('highway')) return null; + const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/); + if (!match) throw new Error('Missing road fixture viewport'); + const [south, west, north, east] = match.slice(1).map(Number); + const inside = ([lon, lat]) => + lat >= south && lat <= north && lon >= west && lon <= east; + const segments = tilesForBounds({ south, west, north, east }, 12).flatMap( + ({ z, x, y }) => decodeFlowTile(tile, z, x, y), + ); + const elements = segments + .filter((segment) => segment.coords.some(inside)) + .slice(0, 240) + .map((segment, index) => ({ + type: 'way', + id: index + 1, + tags: { highway: 'primary', oneway: 'yes' }, + geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })), + })); + const latitude = (south + north) / 2, + longitude = (west + east) / 2; + elements.push({ + type: 'way', + id: 99999, + tags: { highway: 'residential' }, + geometry: [ + { lat: latitude, lon: longitude }, + { lat: latitude + 0.003, lon: longitude + 0.003 }, + ], + }); + return json({ elements }); +} diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js new file mode 100644 index 000000000..fc5ab0bd3 --- /dev/null +++ b/server/providers/aircraft/adsb-lol.js @@ -0,0 +1,158 @@ +/** + * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. + * + * Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream + * fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its + * cached body as STALE instead of relaying the failure, and it backs off from + * upstream for the Retry-After period (bounded) so a rate limit is never + * hammered mid-cooldown. A failure with nothing cached is relayed as-is. + * + * Why the non-OK case matters: the browser layer treats a relayed 429 as a + * 45 s cooldown during which EVERY refresh (the 15 s poll and any tracking + * resolve) reports "adsb.lol rate limited", which the global status chip + * renders as LOAD FAILED while the map is still fully populated. + * + * @returns {import('vite').Plugin} + */ +export function adsbLolProxy() { + /** @type {string|null} Cached upstream JSON body. */ + let _cache = null; + /** @type {number} Epoch-ms when the cache was populated. */ + let _cacheAt = 0; + /** @type {number} Epoch-ms until which upstream is not contacted. */ + let _cooldownUntil = 0; + /** @type {number} Upstream status that started the current cooldown. */ + let _cooldownStatus = 0; + /** Response cache TTL (ms). */ + const CACHE_MS = 12000; + /** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */ + const RATE_LIMIT_COOLDOWN_MS = 30000; + /** Cooldown after a 5xx (ms). */ + const SERVER_ERROR_COOLDOWN_MS = 15000; + /** Bounds for an upstream-supplied Retry-After (ms). */ + const COOLDOWN_MIN_MS = 5000; + const COOLDOWN_MAX_MS = 120000; + + const clampCooldown = (ms) => + Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); + + /** Cooldown (ms) an upstream failure earns, honouring Retry-After when sane. */ + function cooldownFor(upstream, now) { + const raw = upstream.headers?.get?.('retry-after'); + if (raw) { + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds > 0) + return clampCooldown(seconds * 1000); + const at = Date.parse(raw); + if (Number.isFinite(at) && at > now) return clampCooldown(at - now); + } + return upstream.status === 429 + ? RATE_LIMIT_COOLDOWN_MS + : SERVER_ERROR_COOLDOWN_MS; + } + + function serve(res, status, body, cacheStatus, extra = {}) { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-ADS-B-Cache': cacheStatus, + ...(['HIT', 'STALE'].includes(cacheStatus) + ? { + 'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)), + } + : {}), + ...extra, + }); + res.end(body); + } + + const installMiddleware = (server) => { + server.middlewares.use('/api/adsblol/mil', async (req, res) => { + try { + const now = Date.now(); + if (_cache && now - _cacheAt < CACHE_MS) { + serve(res, 200, _cache, 'HIT'); + return; + } + if (now < _cooldownUntil) { + const retryAfter = String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ); + if (_cache) { + serve(res, 200, _cache, 'STALE', { + 'X-ADS-B-Upstream-Status': String(_cooldownStatus), + 'X-ADS-B-Retry-After-Seconds': retryAfter, + }); + return; + } + serve( + res, + _cooldownStatus || 503, + JSON.stringify({ error: 'adsb.lol upstream cooling down' }), + 'NONE', + { 'Retry-After': retryAfter }, + ); + return; + } + const upstream = await fetch('https://api.adsb.lol/v2/mil', { + headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + }); + if (upstream.ok) { + const body = await upstream.text(); + _cache = body; + _cacheAt = Date.now(); + _cooldownUntil = 0; + _cooldownStatus = 0; + serve(res, 200, body, 'MISS'); + return; + } + if (upstream.status === 429 || upstream.status >= 500) { + const failedAt = Date.now(); + _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); + _cooldownStatus = upstream.status; + console.warn( + `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, + ); + if (_cache) { + // Do not wait for a failing response body before serving usable data. + upstream.body?.cancel().catch(() => {}); + serve(res, 200, _cache, 'STALE', { + 'X-ADS-B-Upstream-Status': String(upstream.status), + 'X-ADS-B-Retry-After-Seconds': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + }); + return; + } + } + const body = await upstream.text(); + serve( + res, + upstream.status, + body, + 'MISS', + _cooldownUntil > Date.now() + ? { + 'Retry-After': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + } + : {}, + ); + } catch (e) { + console.error('[adsb.lol Proxy]', e.message); + if (_cache) { + serve(res, 200, _cache, 'STALE'); + return; + } + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'ADS-B proxy error' })); + } + }); + }; + return { + name: 'adsblol-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js new file mode 100644 index 000000000..400050151 --- /dev/null +++ b/server/providers/aircraft/enrichment.js @@ -0,0 +1,147 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +/** + * adsbdb.com enrichment proxy: callsign → route (airline + origin/destination + * airports) and hex → aircraft type/registration. Free community API — cached + * aggressively: ONE upstream request per new key ever (404s negative-cached), + * persisted to disk so restarts don't re-hammer it. Adapted from skylight + * (MIT) server/src/enrich/routes.ts. + */ +export function adsbdbProxy() { + const TTL_MS = 24 * 3600_000; + const CACHE_PATH = path.join(process.cwd(), '.gev-cache', 'adsbdb.json'); + let cache = { routes: {}, aircraft: {} }; + let dirty = false; + let loaded = false; + const inflight = new Map(); + + async function loadOnce() { + if (loaded) return; + loaded = true; + try { + const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); + cache = { routes: parsed.routes ?? {}, aircraft: parsed.aircraft ?? {} }; + } catch { + /* first run */ + } + setInterval(async () => { + if (!dirty) return; + dirty = false; + try { + await fsp.mkdir(path.dirname(CACHE_PATH), { recursive: true }); + await fsp.writeFile(CACHE_PATH, JSON.stringify(cache), 'utf8'); + } catch { + dirty = true; + } // retry next tick + }, 15_000).unref?.(); + } + + const fresh = (e) => e && Date.now() - e.at < TTL_MS; + + function parseRoute(json) { + const fr = json?.response?.flightroute; + if (!fr?.origin || !fr?.destination) return null; + const airport = (a) => ({ + code: a.iata_code || a.icao_code || '', + name: a.municipality || a.name || '', + lat: Number.isFinite(a.latitude) ? a.latitude : null, + lon: Number.isFinite(a.longitude) ? a.longitude : null, + }); + return { + airline: fr.airline?.name || null, + origin: airport(fr.origin), + destination: airport(fr.destination), + }; + } + + function parseAircraft(json) { + const a = json?.response?.aircraft; + if (!a) return null; + return { + typeCode: a.icao_type || null, // ICAO designator, e.g. "B738" — feeds classifyAircraft + typeName: + a.manufacturer && a.type + ? `${a.manufacturer} ${a.type}` + : a.type || null, + registration: a.registration || null, + }; + } + + function lookup(kind, key) { + const store = kind === 'route' ? cache.routes : cache.aircraft; + if (fresh(store[key])) return Promise.resolve(store[key].data); + const ik = `${kind}:${key}`; + if (!inflight.has(ik)) { + inflight.set( + ik, + (async () => { + try { + const url = + kind === 'route' + ? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}` + : `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`; + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + if (res.ok) { + const data = + kind === 'route' + ? parseRoute(await res.json()) + : parseAircraft(await res.json()); + store[key] = { at: Date.now(), data }; // data may be null — negative cache + dirty = true; + return data; + } + if (res.status === 404) { + store[key] = { at: Date.now(), data: null }; // known-missing — cache the miss + dirty = true; + } + // other statuses: leave uncached so we retry later + return fresh(store[key]) ? store[key].data : null; + } catch { + return fresh(store[key]) ? store[key].data : null; // network error → stale if any + } finally { + inflight.delete(ik); + } + })(), + ); + } + return inflight.get(ik); + } + + const installMiddleware = (server) => { + server.middlewares.use('/api/adsbdb', async (req, res) => { + await loadOnce(); + const send = (status, obj) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + try { + const [, kind, rawKey] = String(req.url || '') + .split('?')[0] + .split('/'); + if (kind === 'route') { + const cs = String(rawKey || '').toUpperCase(); + if (!/^[A-Z0-9]{2,8}$/.test(cs)) + return send(400, { error: 'invalid callsign' }); + const data = await lookup('route', cs); + return send(200, data ? { found: true, ...data } : { found: false }); + } + if (kind === 'type') { + const hex = String(rawKey || '').toLowerCase(); + if (!/^[0-9a-f]{6}$/.test(hex)) + return send(400, { error: 'invalid hex' }); + const data = await lookup('aircraft', hex); + return send(200, data ? { found: true, ...data } : { found: false }); + } + return send(404, { error: 'unknown endpoint' }); + } catch (err) { + console.error('[adsbdb-proxy] request failed'); + return send(500, { error: 'adsbdb proxy error' }); + } + }); + }; + return { + name: 'adsbdb-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js new file mode 100644 index 000000000..612d01e11 --- /dev/null +++ b/server/providers/aircraft/opensky.js @@ -0,0 +1,701 @@ +import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; +import { + coalesceProxyRequest, + readResponseJsonCapped, +} from '../common/http.js'; +import { requiredFiniteQueryNumber } from '../common/query.js'; +// --------------------------------------------------------------------------- +// OpenSky OAuth2 token + response cache state +// --------------------------------------------------------------------------- +/** @type {string|null} Current OAuth2 bearer token. */ +let _openskyToken = null; +/** @type {number} Epoch-ms when the current token expires. */ +let _openskyTokenExpiry = 0; +/** @type {Promise|null} In-flight token refresh promise (coalesces concurrent callers). */ +let _openskyTokenPromise = null; +/** @type {string|null} Cached upstream response body (JSON text). */ +let _openskyCacheBody = null; +/** @type {number} HTTP status of the cached response. */ +let _openskyCacheStatus = 0; +/** @type {number} Epoch-ms when the response was cached. */ +let _openskyCacheTime = 0; +/** @type {{requestedMode:string,usedMode:string,reason:string}|null} Auth metadata for the cached response. */ +let _openskyCacheMeta = null; +/** @type {number|null} Source snapshot epoch from the cached OpenSky body. */ +let _openskyCacheSourceEpochMs = null; +/** TTL for the OpenSky response cache (ms). */ +const OPENSKY_CACHE_MS = 9000; +// --- OpenSky credit governor (field-test fix 2026-07-06) ------------------- +// The global /states/all this proxy fetches costs 4 CREDITS per call against +// OpenSky's ~4000/day authenticated budget — a day with the app open burned +// the whole quota in ~8h and the layer then hard-died until the daily reset +// ("rate limited for 48h" owner report; auth itself was fine). Three levers: +// 1. Adaptive TTL: OpenSky returns X-Rate-Limit-Remaining on success; as the +// budget thins, the proxy stretches its cache TTL so a full day of +// continuous use never exhausts it. +// 2. 429 cooldown: honor X-Rate-Limit-Retry-After-Seconds — no upstream +// attempts until it passes (bounded 30 s … 30 min). +// 3. Serve-stale: while rate-limited/cooling, serve the last-good body (200 + +// X-OpenSky-Stale) so the layer keeps rendering instead of dying. +/** @type {number} Current adaptive TTL (ms) — starts at the base cache TTL. */ +let _openskyTtlMs = OPENSKY_CACHE_MS; +/** @type {number} Epoch-ms before which no upstream fetch is attempted. */ +let _openskyCooldownUntil = 0; +/** + * Picks the cache TTL from the remaining daily credit budget. + * Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the + * later tiers stretch the day: >2400 → ~3 h of full freshness, then 30 s + * (~2.5 h), 90 s (~5 h), 300 s (~8 h) ≈ 18+ h of continuous use per day. + * @param {number} remaining - X-Rate-Limit-Remaining header value. + * @returns {number} TTL in ms. + */ +function openskyAdaptiveTtlMs(remaining) { + if (!Number.isFinite(remaining)) return OPENSKY_CACHE_MS; + if (remaining > 2400) return OPENSKY_CACHE_MS; + if (remaining > 1200) return 30_000; + if (remaining > 400) return 90_000; + return 300_000; +} +/** @type {boolean} Guards duplicate auth-failure warnings in logs. */ +let _openskyAuthWarned = false; +/** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ +let _openskyAuthModeWarned = false; +/** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ +const OPENSKY_AUTH_MODE_DEFAULT = 'oauth'; +/** Set of valid OPENSKY_AUTH_MODE values. */ +const OPENSKY_AUTH_MODE_SET = new Set(['basic', 'oauth', 'auto', 'anon']); +/** Regional civilian fallback cache, keyed by a coarse 0.25° view anchor. */ +const _adsbLolPointCache = new Map(); +/** Per-anchor single-flight map for concurrent regional fallback requests. */ +const _adsbLolPointInFlight = new Map(); +const ADSBLOL_POINT_CACHE_MS = 12000; +const ADSBLOL_POINT_CACHE_MAX = 80; +const ADSBLOL_POINT_RADIUS_NM = 250; +const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// A 200 response can still contain an old OpenSky snapshot. Past this point +// the viewport-scoped adsb.lol source is more honest and keeps local motion +// current instead of coasting a stale worldwide frame indefinitely. +const OPENSKY_SOURCE_STALE_MS = 120_000; + +/** + * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. + * + * Uses the client_credentials grant against the OpenSky Keycloak realm. + * Concurrent callers share a single in-flight refresh promise so only + * one token request is issued at a time. + * + * @returns {Promise} Bearer token string, or null if unavailable. + */ +export async function getOpenSkyToken() { + const now = Date.now(); + // Return cached token if still valid (with 60 s safety margin) + if (_openskyToken && now < _openskyTokenExpiry - 60000) return _openskyToken; + + // Coalesce concurrent refresh requests — if a refresh is already in-flight, + // return the same promise instead of issuing a duplicate token request + if (_openskyTokenPromise) return _openskyTokenPromise; + + const clientId = process.env.OPENSKY_CLIENT_ID; + const clientSecret = process.env.OPENSKY_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + + // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise + _openskyTokenPromise = (async () => { + try { + const res = await fetch( + 'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`, + }, + ); + + let data = null; + try { + data = await res.json(); + } catch { + data = null; + } + + const accessToken = data?.access_token; + const expiresIn = Number(data?.expires_in); + if (!res.ok || !accessToken) { + if (!_openskyAuthWarned) { + const detail = + data?.error_description || data?.error || `HTTP ${res.status}`; + console.warn('[OpenSky] OAuth client_credentials failed:', detail); + _openskyAuthWarned = true; + } + _openskyToken = null; + _openskyTokenExpiry = 0; + return null; + } + + _openskyToken = accessToken; + // Default to 1800 s (30 min) if expires_in is missing or non-finite + _openskyTokenExpiry = + Date.now() + (Number.isFinite(expiresIn) ? expiresIn : 1800) * 1000; + console.log( + '[OpenSky] OAuth token refreshed, expires in', + Number.isFinite(expiresIn) ? expiresIn : 1800, + 's', + ); + _openskyAuthWarned = false; + return _openskyToken; + } catch (err) { + if (!_openskyAuthWarned) { + console.warn( + '[OpenSky] OAuth token request failed:', + err?.message || String(err), + ); + _openskyAuthWarned = true; + } + _openskyToken = null; + _openskyTokenExpiry = 0; + return null; + } finally { + // Clear the shared promise so the next caller can start a fresh refresh + _openskyTokenPromise = null; + } + })(); + + return _openskyTokenPromise; +} + +/** + * Validate and normalize the OPENSKY_AUTH_MODE env value. + * + * @param {string} value - Raw env value (e.g. 'basic', 'oauth', 'auto', 'anon'). + * @returns {string} One of the valid mode strings, or the default ('oauth'). + */ +function normalizeOpenSkyAuthMode(value) { + const raw = String(value || '') + .trim() + .toLowerCase(); + if (!raw) return OPENSKY_AUTH_MODE_DEFAULT; + if (OPENSKY_AUTH_MODE_SET.has(raw)) return raw; + if (!_openskyAuthModeWarned) { + console.warn( + `[OpenSky] Invalid OPENSKY_AUTH_MODE="${raw}", defaulting to "${OPENSKY_AUTH_MODE_DEFAULT}"`, + ); + _openskyAuthModeWarned = true; + } + return OPENSKY_AUTH_MODE_DEFAULT; +} + +/** + * Build standard response headers for OpenSky proxy responses. + * + * Includes diagnostic X-OpenSky-* headers so the client can inspect + * cache hit/miss status and which auth mode was actually used. + * + * @param {object} opts + * @param {string} opts.cacheStatus - 'HIT', 'MISS', or 'STALE'. + * @param {string} opts.requestedMode - The auth mode the config requested. + * @param {string} opts.usedMode - The auth mode actually used for the upstream call. + * @param {string} opts.reason - Human-readable reason string for diagnostics. + * @returns {Record} Header object. + */ +function buildOpenSkyHeaders({ + cacheStatus, + requestedMode, + usedMode, + reason, + staleSeconds, + retryAfterSeconds, +}) { + const headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-OpenSky-Cache': cacheStatus, + 'X-OpenSky-Auth': usedMode, + 'X-OpenSky-Auth-Mode-Requested': requestedMode, + 'X-OpenSky-Auth-Mode-Used': usedMode, + 'X-OpenSky-Auth-Reason': reason, + }; + // Credit-governor extras (field-test fix 2026-07-06): the client can show a + // STALE cue / countdown without parsing the body. + if (Number.isFinite(staleSeconds)) + headers['X-OpenSky-Stale-Seconds'] = String(Math.round(staleSeconds)); + if (Number.isFinite(retryAfterSeconds)) + headers['X-OpenSky-Retry-After-Seconds'] = String( + Math.round(retryAfterSeconds), + ); + return headers; +} + +export function adsbLolFallbackAnchor(req) { + const incoming = new URL(req?.url || '', 'http://localhost'); + const latitude = requiredFiniteQueryNumber(incoming.searchParams, 'lat'); + const longitude = requiredFiniteQueryNumber(incoming.searchParams, 'lon'); + if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) + return null; + if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) + return null; + return { latitude, longitude }; +} + +async function fetchAdsbLolPointFallback(req) { + const anchor = adsbLolFallbackAnchor(req); + if (!anchor) return null; + const roundedLat = Math.round(anchor.latitude * 4) / 4; + const roundedLon = Math.round(anchor.longitude * 4) / 4; + const cacheKey = `${roundedLat.toFixed(2)},${roundedLon.toFixed(2)}`; + const cached = _adsbLolPointCache.get(cacheKey); + const now = Date.now(); + if (cached && now - cached.cachedAt < ADSBLOL_POINT_CACHE_MS) { + return { ...cached, cacheStatus: 'HIT' }; + } + + const request = coalesceProxyRequest( + _adsbLolPointInFlight, + cacheKey, + async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + try { + const upstream = await fetch( + `https://api.adsb.lol/v2/lat/${roundedLat}/lon/${roundedLon}/dist/${ADSBLOL_POINT_RADIUS_NM}`, + { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-adsblol-regional-fallback/1.0', + }, + signal: controller.signal, + }, + ); + if (!upstream.ok) throw new Error(`upstream HTTP ${upstream.status}`); + const payload = await readResponseJsonCapped( + upstream, + ADSBLOL_POINT_MAX_RESPONSE_BYTES, + ); + const normalized = normalizeAdsbLolPointResponse(payload); + const record = { + body: JSON.stringify(normalized), + cachedAt: Date.now(), + count: normalized.states.length, + }; + _adsbLolPointCache.delete(cacheKey); + _adsbLolPointCache.set(cacheKey, record); + while (_adsbLolPointCache.size > ADSBLOL_POINT_CACHE_MAX) { + _adsbLolPointCache.delete(_adsbLolPointCache.keys().next().value); + } + return record; + } finally { + clearTimeout(timeoutId); + } + }, + ); + try { + const record = await request.promise; + return { ...record, cacheStatus: request.shared ? 'INFLIGHT' : 'MISS' }; + } catch (error) { + if (!request.shared && error?.name !== 'AbortError') { + console.warn('[adsb.lol Flights Fallback]', error?.message || error); + } + return cached ? { ...cached, cacheStatus: 'STALE' } : null; + } +} + +async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { + const fallback = await fetchAdsbLolPointFallback(req); + if (!fallback) return false; + res.writeHead(200, { + ...buildOpenSkyHeaders({ + cacheStatus: fallback.cacheStatus, + requestedMode, + usedMode: 'adsblol-regional', + reason, + }), + 'X-Flight-Source': 'adsb.lol', + 'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`, + 'X-Flight-Count': String(fallback.count), + }); + res.end(fallback.body); + return true; +} + +function openSkySourceEpochMs(body) { + try { + const seconds = Number(JSON.parse(body)?.time); + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null; + } catch { + return null; + } +} + +function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { + return ( + Number.isFinite(sourceEpochMs) && + now - sourceEpochMs > OPENSKY_SOURCE_STALE_MS + ); +} + +/** + * Vite plugin: OpenSky Network proxy with multi-mode auth and response caching. + * + * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: + * - 'oauth' (default) — client_credentials bearer token + * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD + * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'anon' — no credentials + * + * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On + * upstream failure the proxy serves a stale cached response if available, or + * a bounded 250 nm adsb.lol point snapshot around the current view anchor. + * + * @returns {import('vite').Plugin} + */ +export function openSkyProxy() { + const installMiddleware = (server) => { + server.middlewares.use('/api/opensky', async (req, res) => { + try { + const requestedMode = normalizeOpenSkyAuthMode( + process.env.OPENSKY_AUTH_MODE, + ); + const now = Date.now(); + const inCooldown = now < _openskyCooldownUntil; + // Fresh-enough cache (adaptive TTL) OR any cache during a 429 + // cooldown: serve it without touching upstream. Stale-during-cooldown + // is deliberate (credit governor): last-good planes beat a dead layer. + if ( + _openskyCacheBody && + (now - _openskyCacheTime < _openskyTtlMs || inCooldown) + ) { + if ( + openSkySourceIsStale(_openskyCacheSourceEpochMs, now) && + (await serveAdsbLolPointFallback( + req, + res, + requestedMode, + 'opensky_snapshot_stale_regional_fallback', + )) + ) { + return; + } + const cachedMeta = _openskyCacheMeta || { + requestedMode, + usedMode: 'unknown', + reason: 'cached', + }; + const isStale = now - _openskyCacheTime >= _openskyTtlMs; + res.writeHead( + _openskyCacheStatus || 200, + buildOpenSkyHeaders({ + cacheStatus: isStale ? 'STALE' : 'HIT', + requestedMode: cachedMeta.requestedMode || requestedMode, + usedMode: cachedMeta.usedMode || 'unknown', + reason: isStale + ? 'rate_limited_serving_stale' + : cachedMeta.reason || 'cached', + staleSeconds: isStale + ? (now - _openskyCacheTime) / 1000 + : undefined, + retryAfterSeconds: inCooldown + ? (_openskyCooldownUntil - now) / 1000 + : undefined, + }), + ); + res.end(_openskyCacheBody); + return; + } + // Cooling down with nothing cached (cold start into a rate limit): + // synthesize the 429 locally — hammering upstream mid-cooldown can't + // succeed and just burns goodwill. + if (inCooldown) { + if ( + await serveAdsbLolPointFallback( + req, + res, + requestedMode, + 'opensky_cooldown_regional_fallback', + ) + ) + return; + res.writeHead( + 429, + buildOpenSkyHeaders({ + cacheStatus: 'COOLDOWN', + requestedMode, + usedMode: 'none', + reason: 'rate_limited', + retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, + }), + ); + res.end( + JSON.stringify({ + error: 'OpenSky rate limited; proxy cooling down.', + }), + ); + return; + } + + const basicUser = process.env.OPENSKY_USERNAME || ''; + const basicPass = process.env.OPENSKY_PASSWORD || ''; + const hasBasicCreds = Boolean(basicUser && basicPass); + const headers = { Accept: 'application/json' }; + let usedMode = 'anon'; + let reason = 'forced_anonymous'; + + if (requestedMode === 'basic') { + if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'basic_credentials'; + } else { + reason = 'missing_basic_creds'; + } + } else if (requestedMode === 'oauth') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else { + reason = 'oauth_invalid_or_missing'; + } + } else if (requestedMode === 'auto') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'oauth_unavailable_fallback_basic'; + } else { + reason = 'missing_oauth_and_basic_creds'; + } + } + + let upstream = await fetch( + 'https://opensky-network.org/api/states/all?extended=1', + { headers }, + ); + // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials + if ( + (upstream.status === 401 || upstream.status === 403) && + requestedMode === 'auto' && + usedMode === 'oauth' && + hasBasicCreds + ) { + const retryHeaders = { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, + }; + upstream = await fetch( + 'https://opensky-network.org/api/states/all?extended=1', + { headers: retryHeaders }, + ); + usedMode = 'basic'; + reason = 'oauth_rejected_fallback_basic'; + } + + let body = await upstream.text(); + const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; + if ( + upstream.ok && + openSkySourceIsStale(sourceEpochMs, now) && + (await serveAdsbLolPointFallback( + req, + res, + requestedMode, + 'opensky_snapshot_stale_regional_fallback', + )) + ) { + // Keep the last global snapshot available as a fail-soft cache, + // but do not label or render it as a fresh live result. + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { requestedMode, usedMode, reason }; + return; + } + if (upstream.status === 429) { + reason = 'rate_limited'; + // Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min; + // 2 min when the header is absent) — no upstream attempts until then. + const retryAfterSec = Number( + upstream.headers.get('x-rate-limit-retry-after-seconds'), + ); + const cooldownMs = Math.min( + Math.max( + Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, + 30_000, + ), + 30 * 60_000, + ); + _openskyCooldownUntil = now + cooldownMs; + // Serve the last-good body instead of the 429 when we have one — + // the layer keeps rendering (STALE-cued) instead of dying. + if (_openskyCacheBody && _openskyCacheStatus === 200) { + res.writeHead( + 200, + buildOpenSkyHeaders({ + cacheStatus: 'STALE', + requestedMode, + usedMode, + reason: 'rate_limited_serving_stale', + staleSeconds: (now - _openskyCacheTime) / 1000, + retryAfterSeconds: cooldownMs / 1000, + }), + ); + res.end(_openskyCacheBody); + return; + } + } + + if (!upstream.ok && !_openskyCacheBody) { + const servedFallback = await serveAdsbLolPointFallback( + req, + res, + requestedMode, + `opensky_http_${upstream.status}_regional_fallback`, + ); + if (servedFallback) return; + } + + if (upstream.status === 401 || upstream.status === 403) { + if (requestedMode === 'basic' && !hasBasicCreds) { + body = JSON.stringify({ + error: + 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', + }); + reason = 'missing_basic_creds'; + } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { + body = JSON.stringify({ + error: + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + }); + reason = 'oauth_invalid_or_missing'; + } else if (usedMode === 'basic') { + body = JSON.stringify({ + error: 'OpenSky auth invalid. Username/password were rejected.', + }); + reason = 'basic_invalid_credentials'; + } else if (usedMode === 'oauth') { + body = JSON.stringify({ + error: + 'OpenSky auth invalid. OAuth client credentials were rejected.', + }); + reason = 'oauth_invalid_credentials'; + } else if (requestedMode === 'auto' && !hasBasicCreds) { + body = JSON.stringify({ + error: + 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + }); + reason = 'missing_oauth_and_basic_creds'; + } else { + body = JSON.stringify({ + error: 'OpenSky auth required.', + }); + reason = 'auth_required'; + } + } + + // Refine the reason string to reflect the actual outcome + if (upstream.ok && reason === 'forced_anonymous') { + reason = 'anonymous_ok'; + } else if ( + upstream.ok && + usedMode === 'basic' && + reason === 'basic_credentials' + ) { + reason = 'basic_ok'; + } else if ( + upstream.ok && + usedMode === 'oauth' && + reason === 'oauth_token' + ) { + reason = 'oauth_ok'; + } + + // Only cache successful responses — error responses (401/403/429/5xx) + // should not be served from cache on subsequent requests + if (upstream.ok) { + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { + requestedMode, + usedMode, + reason, + }; + // Credit governor: adapt the cache TTL to the remaining daily + // budget so a continuously-open app stretches its polls instead of + // exhausting the quota mid-day. Success also clears any cooldown. + const remaining = Number( + upstream.headers.get('x-rate-limit-remaining'), + ); + _openskyTtlMs = openskyAdaptiveTtlMs(remaining); + _openskyCooldownUntil = 0; + } + + res.writeHead( + upstream.status, + buildOpenSkyHeaders({ + cacheStatus: 'MISS', + requestedMode, + usedMode, + reason, + }), + ); + res.end(body); + } catch (e) { + console.error('[OpenSky Proxy]', e.message); + if (_openskyCacheBody) { + const cachedMeta = _openskyCacheMeta || { + requestedMode: normalizeOpenSkyAuthMode( + process.env.OPENSKY_AUTH_MODE, + ), + usedMode: 'unknown', + reason: 'cached_stale', + }; + res.writeHead( + _openskyCacheStatus || 200, + buildOpenSkyHeaders({ + cacheStatus: 'STALE', + requestedMode: + cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, + usedMode: cachedMeta.usedMode || 'unknown', + reason: cachedMeta.reason || 'cached_stale', + }), + ); + res.end(_openskyCacheBody); + return; + } + const requestedMode = normalizeOpenSkyAuthMode( + process.env.OPENSKY_AUTH_MODE, + ); + if ( + await serveAdsbLolPointFallback( + req, + res, + requestedMode, + 'opensky_proxy_error_regional_fallback', + ) + ) + return; + res.writeHead( + 502, + buildOpenSkyHeaders({ + cacheStatus: 'MISS', + requestedMode, + usedMode: 'error', + reason: 'proxy_error', + }), + ); + res.end(JSON.stringify({ error: 'OpenSky proxy error' })); + } + }); + }; + return { + name: 'opensky-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/aircraft/tracks.js b/server/providers/aircraft/tracks.js new file mode 100644 index 000000000..7d9501628 --- /dev/null +++ b/server/providers/aircraft/tracks.js @@ -0,0 +1,128 @@ +import { getOpenSkyToken } from './opensky.js'; +import { readCappedResponseText } from '../common/http.js'; +/** + * Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2). + * + * /api/opensky-track?icao24= — OpenSky GET /tracks/all (experimental; + * own credit bucket, 4 credits per call on the free tier). OAuth via the + * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the + * client can fall back to its accumulated trail silently. + * /api/adsblol/trace?hex= — adsb.lol tar1090 readsb trace + * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h + * of real history per aircraft. Treat as best-effort; data is ODbL — + * credit "adsb.lol (ODbL)" in the UI. + */ +export function trackBackfillProxies() { + const TRACK_CACHE_MS = 60000; + const TRACK_CACHE_MAX = 200; + const RESPONSE_CAP_BYTES = 5 * 1024 * 1024; + /** @type {Map} */ + const cache = new Map(); + + function cachePut(key, entry) { + cache.set(key, entry); + if (cache.size > TRACK_CACHE_MAX) { + const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at)[0]; + if (oldest) cache.delete(oldest[0]); + } + } + + async function proxyJson(res, key, upstreamUrl, headers = {}) { + const cached = cache.get(key); + if (cached && Date.now() - cached.at < TRACK_CACHE_MS) { + res.statusCode = cached.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(cached.body); + return; + } + const upstream = await fetch(upstreamUrl, { + headers, + signal: AbortSignal.timeout(12000), + }); + const { tooLarge, text } = await readCappedResponseText( + upstream, + RESPONSE_CAP_BYTES, + ); + let body; + if (tooLarge) { + body = JSON.stringify({ error: 'Upstream track response too large' }); + } else if (!upstream.ok) { + // Sanitize upstream error surface; status code is signal enough + body = JSON.stringify({ error: `Track source HTTP ${upstream.status}` }); + } else { + body = text; + } + cachePut(key, { at: Date.now(), status: upstream.status, body }); + res.statusCode = upstream.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(body); + } + + function install(middlewares) { + middlewares.use('/api/opensky-track', async (req, res) => { + try { + const incoming = new URL(req.url || '', 'http://localhost'); + const icao24 = String(incoming.searchParams.get('icao24') || '') + .trim() + .toLowerCase(); + if (!/^[0-9a-f]{6}$/.test(icao24)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ error: 'icao24 must be a 6-char hex string' }), + ); + return; + } + const token = await getOpenSkyToken(); + await proxyJson( + res, + `osky:${icao24}`, + `https://opensky-network.org/api/tracks/all?icao24=${icao24}&time=0`, + token ? { Authorization: `Bearer ${token}` } : {}, + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'OpenSky track fetch failed' })); + } + }); + + middlewares.use('/api/adsblol/trace', async (req, res) => { + try { + const incoming = new URL(req.url || '', 'http://localhost'); + const hex = String(incoming.searchParams.get('hex') || '') + .trim() + .toLowerCase(); + if (!/^[0-9a-f~]{6,7}$/.test(hex)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ error: 'hex must be a 6-7 char hex string' }), + ); + return; + } + await proxyJson( + res, + `lol:${hex}`, + `https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`, + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'adsb.lol trace fetch failed' })); + } + }); + } + + return { + name: 'track-backfill-proxies', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} diff --git a/server/providers/cctv.js b/server/providers/cctv.js new file mode 100644 index 000000000..e1c2d8f85 --- /dev/null +++ b/server/providers/cctv.js @@ -0,0 +1,416 @@ +import { createCctvCatalog } from './cctv/catalog.js'; +import { + normalizeFeedType, + isVideoFeedType, + toFiniteNumber, +} from './cctv/normalize.js'; +import { + buildSyntheticCctvSvg, + proxyMediaResponse, + fetchCctvImageFromUpstream, + fetchTxdotSnapshot, + fetchCctvMediaUpstream, +} from './cctv/media.js'; +import { + CCTV_FRAME_FETCH_TIMEOUT_MS, + CCTV_MAX_SOURCES_CEILING, +} from './cctv/constants.js'; +import { googleServerApiKey } from './places/google-key.js'; +export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; +/** + * Vite plugin: CCTV camera proxy with source registry, frame/media serving, + * fallback chain (upstream -> Street View -> synthetic SVG), and health tracking. + * + * Endpoints: + * GET /api/cctv/sources — list all registered camera sources + * GET /api/cctv/health — per-camera health/status report + * GET /api/cctv/stream/:id — stream info (feedType, URLs) for a camera + * GET /api/cctv/media/:id — proxy live video/image media from upstream + * GET /api/cctv/frame/:id — single frame with fallback chain + * + * @returns {import('vite').Plugin} + */ +export function cctvProxy({ sourceRoot = process.cwd() } = {}) { + const getCctvSources = createCctvCatalog({ sourceRoot }); + /** @type {Map} */ + const health = new Map(); + /** Cap on health map entries to prevent unbounded growth. Sized to the + * CCTV_MAX_SOURCES ceiling so health/status observability is never evicted + * for any catalog the proxy can actually serve. */ + const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING; + + /** Update the health entry for a camera, evicting the oldest entry if at capacity. */ + const setHealth = (cameraId, patch) => { + // Evict oldest entries if the health map grows beyond the cap + if (!health.has(cameraId) && health.size >= HEALTH_MAX_ENTRIES) { + const oldest = health.keys().next().value; + health.delete(oldest); + } + const prev = health.get(cameraId) || {}; + health.set(cameraId, { + id: cameraId, + status: patch.status || prev.status || 'unknown', + sourceKind: patch.sourceKind || prev.sourceKind || 'unknown', + label: patch.label || prev.label || '', + message: patch.message || prev.message || '', + updatedAt: Date.now(), + }); + }; + + /** Snapshot all camera health entries as an array. */ + const listHealth = () => Array.from(health.values()); + + /** Build a JSON payload describing stream info (feedType, URLs) for a camera. */ + const buildStreamPayload = (source, cameraId) => { + const feedType = normalizeFeedType(source?.feedType || 'image'); + return { + id: cameraId, + feedType, + mediaUrl: isVideoFeedType(feedType) + ? `/api/cctv/media/${encodeURIComponent(cameraId)}` + : null, + frameUrl: `/api/cctv/frame/${encodeURIComponent(cameraId)}`, + provider: source?.provider || '', + sourceKind: + source?.sourceKind || (source?.url ? 'configured' : 'fallback'), + }; + }; + + /** + * Fetch a Google Street View static image as a fallback frame. Server-side + * call, never reaches the browser — prefers GOOGLE_MAPS_SERVER_API_KEY + * (#33: a key scoped to Street View Static/Places, restricted by server IP + * rather than HTTP referrer) and falls back to the browser-exposed + * GOOGLE_MAPS_API_KEY for setups that haven't split the two yet. + */ + const streetViewFallback = async ({ lat, lon, heading, fov, pitch }) => { + const streetViewKey = googleServerApiKey(); + if (!streetViewKey || !Number.isFinite(lat) || !Number.isFinite(lon)) + return null; + try { + const sv = new URL('https://maps.googleapis.com/maps/api/streetview'); + sv.searchParams.set('size', '960x540'); + sv.searchParams.set('location', `${lat},${lon}`); + sv.searchParams.set( + 'heading', + String(Number.isFinite(heading) ? heading : 0), + ); + sv.searchParams.set( + 'fov', + String(Number.isFinite(fov) ? Math.max(20, Math.min(120, fov)) : 80), + ); + sv.searchParams.set( + 'pitch', + String(Number.isFinite(pitch) ? Math.max(-40, Math.min(20, pitch)) : 0), + ); + sv.searchParams.set('source', 'outdoor'); + sv.searchParams.set('return_error_code', 'true'); + sv.searchParams.set('key', streetViewKey); + + const svResp = await fetch(sv.toString(), { + headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' }, + signal: AbortSignal.timeout(CCTV_FRAME_FETCH_TIMEOUT_MS), + }); + const svType = svResp.headers.get('content-type') || ''; + if (!svResp.ok || !svType.startsWith('image/')) return null; + + return { + ok: true, + body: Buffer.from(await svResp.arrayBuffer()), + contentType: svType, + }; + } catch { + return null; + } + }; + + const installMiddleware = (server) => { + server.middlewares.use('/api/cctv', async (req, res) => { + try { + const sources = await getCctvSources(); + const sourceById = new Map( + sources.map((source) => [source.id, source]), + ); + const url = new URL(req.url || '/', 'http://localhost'); + + if (url.pathname === '/sources') { + const body = { + sources: sources.map((source) => ({ + id: source.id, + name: source.name, + city: source.city, + cityId: source.cityId, + provider: source.provider, + lat: source.lat, + lon: source.lon, + headingDeg: source.headingDeg, + headingConfidence: source.headingConfidence || '', + pitchDeg: source.pitchDeg, + fovDeg: source.fovDeg, + rangeM: source.rangeM, + mountHeightM: source.mountHeightM, + groundElevationM: source.groundElevationM, + feedType: normalizeFeedType(source.feedType), + sourceKind: + source.sourceKind || (source.url ? 'configured' : 'fallback'), + poseSource: source.poseSource, + license: source.license, + credit: source.credit || '', + code: source.code || '', + groundHeights: source.groundHeights || null, + })), + }; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); + return; + } + + if (url.pathname === '/health') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ cameras: listHealth() })); + return; + } + + if (url.pathname.startsWith('/stream/')) { + const cameraId = + decodeURIComponent(url.pathname.replace('/stream/', '').trim()) || + 'camera'; + const source = sourceById.get(cameraId); + const payload = buildStreamPayload(source, cameraId); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(payload)); + return; + } + + if (url.pathname.startsWith('/media/')) { + const cameraId = + decodeURIComponent(url.pathname.replace('/media/', '').trim()) || + 'camera'; + const source = sourceById.get(cameraId); + const mediaUrl = source?.url || ''; + const feedType = normalizeFeedType(source?.feedType || 'image'); + + if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) { + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'fallback', + label: source?.provider || 'No upstream URL', + message: 'No stream URL configured', + }); + res.writeHead(404, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'No media URL configured for this camera', + }), + ); + return; + } + + try { + const upstreamHeaders = { + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }; + const requestRange = req.headers?.range; + if (requestRange) upstreamHeaders.Range = requestRange; + const upstream = await fetchCctvMediaUpstream(mediaUrl, { + headers: upstreamHeaders, + }); + const contentType = upstream.headers.get('content-type') || ''; + if (!upstream.ok) { + try { + await upstream.body?.cancel(); + } catch { + /* already closed */ + } + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'upstream', + label: source?.provider || 'Configured source', + message: `Upstream HTTP ${upstream.status}`, + }); + res.writeHead(upstream.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: `Upstream returned ${upstream.status}`, + }), + ); + return; + } + + if ( + isVideoFeedType(feedType) && + !( + contentType.startsWith('video/') || + contentType.includes('mpegurl') + ) + ) { + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'upstream', + label: source?.provider || 'Configured source', + message: `Unexpected media type ${contentType || 'unknown'}`, + }); + } else { + setHealth(cameraId, { + status: 'ok', + sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', + label: source?.provider || 'Configured source', + message: isVideoFeedType(feedType) + ? 'Live stream connected' + : 'Snapshot feed connected', + }); + } + + await proxyMediaResponse(res, upstream, { + sourceHeader: isVideoFeedType(feedType) + ? 'live-media' + : 'upstream-image', + }); + return; + } catch (error) { + const timedOut = + error?.name === 'AbortError' || error?.name === 'TimeoutError'; + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'upstream', + label: source?.provider || 'Configured source', + message: error?.message || 'Media fetch failed', + }); + res.writeHead(timedOut ? 504 : 502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: timedOut + ? 'Upstream media timeout' + : 'Media proxy failed', + }), + ); + return; + } + } + + if (!url.pathname.startsWith('/frame/')) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + + const cameraId = + decodeURIComponent(url.pathname.replace('/frame/', '').trim()) || + 'camera'; + const source = sourceById.get(cameraId); + const label = url.searchParams.get('label') || source?.name || cameraId; + const city = url.searchParams.get('city') || source?.city || ''; + const lat = Number(url.searchParams.get('lat') || source?.lat); + const lon = Number(url.searchParams.get('lon') || source?.lon); + const heading = Number( + url.searchParams.get('heading') || source?.headingDeg, + ); + const fov = Number(url.searchParams.get('fov') || source?.fovDeg); + const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg); + + // Only use server-registered upstream URLs — never accept client-supplied URLs + // (prevents SSRF via ?upstream= query parameter) + const upstreamCandidate = + source?.snapshotUrl || + (!isVideoFeedType(normalizeFeedType(source?.feedType)) + ? source?.url + : ''); + + const upstreamImage = + source?.sourceKind === 'txdot-its' + ? await fetchTxdotSnapshot(upstreamCandidate) + : await fetchCctvImageFromUpstream(upstreamCandidate); + if (upstreamImage?.ok) { + setHealth(cameraId, { + status: 'ok', + sourceKind: 'snapshot', + label: source?.provider || 'Configured source', + message: 'Upstream snapshot active', + }); + res.writeHead(200, { + 'Content-Type': upstreamImage.contentType, + 'Cache-Control': 'no-store', + 'X-CCTV-Source': 'upstream-image', + }); + res.end(upstreamImage.body); + return; + } + + const sv = await streetViewFallback({ + lat, + lon, + heading, + fov, + pitch, + }); + if (sv?.ok) { + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'streetview', + label: 'Google Street View', + message: 'Fallback Street View frame', + }); + res.writeHead(200, { + 'Content-Type': sv.contentType, + 'Cache-Control': 'no-store', + 'X-CCTV-Source': 'streetview', + }); + res.end(sv.body); + return; + } + + const svg = buildSyntheticCctvSvg({ + cameraId, + label, + city, + status: source?.url + ? 'UPSTREAM UNAVAILABLE' + : 'NO UPSTREAM CONFIGURED', + }); + + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'synthetic', + label: source?.provider || 'Synthetic fallback', + message: source?.url + ? 'Upstream unavailable' + : 'No source configured', + }); + + res.writeHead(200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-store', + 'X-CCTV-Source': 'synthetic', + }); + res.end(svg); + } catch (error) { + console.error('[CCTV Proxy]', error?.message || String(error)); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'CCTV proxy error' })); + } + }); + }; + return { + name: 'cctv-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/cctv/cap.js b/server/providers/cctv/cap.js new file mode 100644 index 000000000..f0659e255 --- /dev/null +++ b/server/providers/cctv/cap.js @@ -0,0 +1,82 @@ +import { + CCTV_MAX_SOURCES_CEILING, + DEFAULT_CCTV_MAX_SOURCES, +} from './constants.js'; + +/** + * Resolve the catalog-wide source cap from an env value. + * + * @param {string|number|undefined} raw - CCTV_MAX_SOURCES as configured. + * @returns {number} Cap bounded to [8, CCTV_MAX_SOURCES_CEILING]. + */ +export function resolveCatalogCap(raw) { + const parsed = Number(raw || DEFAULT_CCTV_MAX_SOURCES); + if (!Number.isFinite(parsed)) return DEFAULT_CCTV_MAX_SOURCES; + return Math.max(8, Math.min(CCTV_MAX_SOURCES_CEILING, Math.floor(parsed))); +} + +/** + * Merge source packs under one cap without starving any of them. + * + * Every pack arrives already ordered by its own priority (nearest-to-anchor + * first) and already trimmed to its own per-pack cap. When the packs together + * exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from + * each pack per turn, in each pack's own order — so a lowered + * CCTV_MAX_SOURCES thins every region a little instead of deleting whichever + * pack happened to be appended last. Duplicate ids resolve last-pack-wins so + * file/env overrides keep replacing live records; the record is then counted + * against the pack that won it. + * + * @param {Array<{name:string, sources:Array}>} packs - Normalized + * packs in merge order (earlier packs lose duplicate ids to later ones). + * @param {number} maxCount - Catalog cap. + * @returns {{sources:Array, packs:Array<{name:string, offered:number, kept:number}>}} + */ +export function allocateSourceCap(packs, maxCount) { + const owner = new Map(); + for (const pack of packs) { + for (const source of pack.sources) { + if (!source?.id) continue; + owner.set(source.id, { pack: pack.name, source }); + } + } + const lanes = packs.map((pack) => ({ + name: pack.name, + // Only the record that WON its id (the last occurrence anywhere, so also + // the last within this pack) takes a lane slot; earlier duplicates never + // count against the cap. + queue: pack.sources.filter( + (source) => source?.id && owner.get(source.id)?.source === source, + ), + next: 0, + kept: 0, + })); + const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0); + const limit = Math.min(total, Math.max(0, Math.floor(maxCount))); + + const sources = []; + if (limit >= total) { + for (const lane of lanes) { + sources.push(...lane.queue); + lane.kept = lane.queue.length; + } + } else { + while (sources.length < limit) { + for (const lane of lanes) { + if (sources.length >= limit) break; + if (lane.next >= lane.queue.length) continue; + sources.push(lane.queue[lane.next]); + lane.next += 1; + lane.kept += 1; + } + } + } + return { + sources, + packs: lanes.map((lane) => ({ + name: lane.name, + offered: lane.queue.length, + kept: lane.kept, + })), + }; +} diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js new file mode 100644 index 000000000..68c5f974b --- /dev/null +++ b/server/providers/cctv/catalog.js @@ -0,0 +1,249 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js'; +import { allocateSourceCap, resolveCatalogCap } from './cap.js'; +import { loadGroundHeights, joinGroundHeights } from './groundHeights.js'; +import { normalizeSourceItem } from './normalize.js'; +import { + loadAustinSourcesFromOpenData, + loadCaltransSourcesFromOpenData, + loadTflSourcesFromOpenData, + loadOntarioSourcesFromOpenData, + loadFintrafficSourcesFromOpenData, + loadDriveBcSourcesFromOpenData, + loadTxdotSourcesFromOpenData, + loadTallinnSourcesFromCatalog, + loadTarkteeSourcesFromDatex, + loadWarendorfSourcesFromCatalog, + loadNswSourcesFromOpenData, +} from './sources.js'; + +/** Env kill switch: unset or anything but "0" means enabled. */ +const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; + +/** + * Live open-data packs, in merge order. Adding a region is one entry here + * plus its loader in sources.js; the catalog cap is shared across entries + * round-robin (cap.js), so a new pack never silently evicts an older one. + * Each pack fails independently (allSettled) and is gated by its own env + * kill switch. + */ +const LIVE_PACKS = [ + { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, + { + name: 'caltrans', + enabled: () => true, + load: loadCaltransSourcesFromOpenData, + }, + { + name: 'tfl', + enabled: () => envEnabled('CCTV_TFL_ENABLED'), + load: loadTflSourcesFromOpenData, + }, + { + name: 'ontario', + enabled: () => envEnabled('CCTV_ONTARIO_ENABLED'), + load: loadOntarioSourcesFromOpenData, + }, + { + name: 'fintraffic', + enabled: () => envEnabled('CCTV_FINTRAFFIC_ENABLED'), + load: loadFintrafficSourcesFromOpenData, + }, + { + name: 'drivebc', + enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'), + load: loadDriveBcSourcesFromOpenData, + }, + { + name: 'txdot', + enabled: () => envEnabled('CCTV_TXDOT_ENABLED'), + load: loadTxdotSourcesFromOpenData, + }, + { + name: 'tallinn', + enabled: () => envEnabled('CCTV_TALLINN_ENABLED'), + load: loadTallinnSourcesFromCatalog, + }, + { + name: 'tarktee', + enabled: () => envEnabled('CCTV_TARKTEE_ENABLED'), + load: loadTarkteeSourcesFromDatex, + }, + { + name: 'warendorf', + enabled: () => envEnabled('CCTV_WARENDORF_ENABLED'), + load: loadWarendorfSourcesFromCatalog, + }, + { + name: 'nsw', + enabled: () => envEnabled('CCTV_NSW_ENABLED'), + load: loadNswSourcesFromOpenData, + }, +]; +/** + * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). + * + * @returns {Array} Array of raw source objects, or [] on error. + */ +function loadSourcesFromFile(sourceRoot) { + const sourceFile = process.env.CCTV_SOURCES_FILE || DEFAULT_CCTV_SOURCE_FILE; + const resolved = path.isAbsolute(sourceFile) + ? sourceFile + : path.resolve(sourceRoot, sourceFile); + try { + if (!fs.existsSync(resolved)) return []; + const raw = fs.readFileSync(resolved, 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.warn( + '[CCTV] failed to read source file:', + resolved, + error?.message || error, + ); + return []; + } +} + +/** + * Load CCTV sources from the CCTV_SOURCES_JSON env variable (inline JSON). + * + * @returns {Array} Array of raw source objects, or [] if unset/invalid. + */ +function loadSourcesFromEnv() { + const raw = process.env.CCTV_SOURCES_JSON; + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** Create an independent catalog rooted in the consuming application. */ +export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { + /** @type {Array} Cached merged + normalized CCTV source list. */ + let _cctvSourceCache = []; + /** @type {number} Epoch-ms when the source cache was last refreshed. */ + let _cctvSourceCacheAt = 0; + /** @type {Promise>|null} In-flight refresh, shared by concurrent + * callers so a post-TTL burst launches ONE refetch, not one per request. */ + let _cctvSourceInflight = null; + + /** + * Assemble and cache the merged CCTV source list. + * + * Merges every source pack (live open-data packs, local file, env + * variable), deduplicates by ID, shares the catalog cap fairly across + * packs, and caches for CCTV_SOURCE_CACHE_MS. + * + * @returns {Promise>} Deduplicated, capped source list. + */ + async function getCctvSources() { + const now = Date.now(); + if ( + _cctvSourceCache.length && + now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS + ) { + return _cctvSourceCache; + } + // Single-flight: a burst of requests arriving past the TTL shares ONE refresh + // instead of each launching the full multi-provider refetch. The `.finally` + // clears the ref so the next post-TTL cycle starts fresh. + if (_cctvSourceInflight) return _cctvSourceInflight; + _cctvSourceInflight = refreshCctvSources().finally(() => { + _cctvSourceInflight = null; + }); + return _cctvSourceInflight; + } + + /** + * Assemble and cache the merged CCTV source list from file/env + live packs. + * Always resolves (loaders self-catch to []); on a fully-empty refresh with a + * good prior catalog it serves stale rather than blanking the CCTV layer. + * + * @returns {Promise>} Deduplicated, capped source list. + */ + async function refreshCctvSources() { + const fromFile = loadSourcesFromFile(sourceRoot); + const fromEnv = loadSourcesFromEnv(); + + const forceAustin = + String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1'; + const preferAustin = + String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0'; + // Live open-data packs load unless a file/env pack is configured and live + // packs aren't forced — the same gate that governed the Austin-only fetch + // now governs every entry in LIVE_PACKS. + const needsLiveSources = + forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin); + const liveResults = needsLiveSources + ? await Promise.allSettled( + // Invoked inside the promise so a loader that throws synchronously + // (a file-based pack on a malformed row) is isolated like any other + // failed pack instead of rejecting the whole refresh. + LIVE_PACKS.map((pack) => + Promise.resolve().then(() => + pack.enabled() ? pack.load({ sourceRoot }) : [], + ), + ), + ) + : []; + // Live packs first so file/env overrides win on duplicate IDs; each pack + // keeps its own priority order and the catalog cap is shared fairly. + const normalizePack = (name, items) => ({ + name, + sources: items + .filter((item) => item && typeof item === 'object') + .map((item) => normalizeSourceItem(item)) + .filter((item) => item.id), + }); + const packs = [ + ...LIVE_PACKS.map((pack, index) => + normalizePack( + pack.name, + liveResults[index]?.status === 'fulfilled' + ? liveResults[index].value + : [], + ), + ), + normalizePack('file', fromFile), + normalizePack('env', fromEnv), + ]; + const maxCount = resolveCatalogCap(process.env.CCTV_MAX_SOURCES); + const allocation = allocateSourceCap(packs, maxCount); + // Shipped ground heights (src/data/local_data/cctv_ground_heights/, produced by + // scripts/precompute-cctv-heights.mjs) ride along on the served source so + // the client can place a camera and its monitor plane with zero sampling. + const capped = joinGroundHeights( + allocation.sources, + loadGroundHeights(sourceRoot), + ); + const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered); + if (trimmed.length) { + const detail = trimmed + .map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`) + .join(', '); + console.warn( + `[CCTV] source catalog exceeds cap ${maxCount}; shared round-robin across packs (${detail}). Raise CCTV_MAX_SOURCES or lower a per-pack cap to change the mix.`, + ); + } + if (capped.length > 0 || _cctvSourceCache.length === 0) { + _cctvSourceCache = capped; + } else { + // Every source came back empty (all live packs timed out / upstream outage) + // but a good catalog is already cached — serve it stale rather than blanking + // every CCTV route. Advancing the timestamp waits one TTL before retrying, + // which (with single-flight) bounds load on a persistently-down upstream. + console.warn( + `[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`, + ); + } + _cctvSourceCacheAt = Date.now(); + return _cctvSourceCache; + } + + return getCctvSources; +} diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js new file mode 100644 index 000000000..4c149eb5a --- /dev/null +++ b/server/providers/cctv/constants.js @@ -0,0 +1,241 @@ +export const DEFAULT_CCTV_SOURCE_FILE = 'config/cctv_sources.austin.json'; +/** Austin Open Data portal endpoint for traffic camera records. */ +export const DEFAULT_AUSTIN_ROWS_URL = + 'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD'; +/** Default cap on Austin cameras after distance-based prioritization. */ +export const DEFAULT_AUSTIN_MAX_SOURCES = 250; +/** + * Catalog-wide safety ceiling on served cameras. Each pack already caps + * itself (nearest-to-anchor first); this bound only matters when the packs + * together exceed it, and it is then filled round-robin across packs (see + * cap.js) so no region is silently dropped. Sized above the sum of the + * default per-pack caps so a default install never trims. + */ +export const DEFAULT_CCTV_MAX_SOURCES = 4000; +/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */ +export const CCTV_MAX_SOURCES_CEILING = 5000; +/** Reference point for Austin camera prioritization (Congress & 6th). */ +export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 }; +/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */ +/** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */ +export const TXDOT_ORIGIN = 'https://its.txdot.gov'; +export const TXDOT_CCTV_STATUS_URL = (district) => + `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`; +/** Per-camera frame. Returns JSON `{snippet:}`, not an image body; + * media.js decodes it (fetchTxdotSnapshot) and only for this origin. */ +export const TXDOT_CCTV_SNAPSHOT_URL = `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvSnapshotByIcdId`; +/** Valid TxDOT district codes (the ITS map's own districtCodes list). */ +export const TXDOT_DISTRICTS = new Set([ + 'ABL', + 'AMA', + 'ATL', + 'AUS', + 'BMT', + 'BWD', + 'BRY', + 'CHS', + 'CRP', + 'DAL', + 'ELP', + 'FTW', + 'HOU', + 'LRD', + 'LBB', + 'LFK', + 'ODA', + 'PAR', + 'PHR', + 'SJT', + 'SAT', + 'TYL', + 'WAC', + 'WFS', + 'YKM', +]); +/** Districts fetched by default: Austin (the reference camera city) and San + * Antonio. A statewide default was rejected: per-pack prioritization is + * nearest-to-any-anchor, so Dallas's dense core would take most slots. + * CCTV_TXDOT_DISTRICTS opens up the rest ("AUS,SAT,HOU,DAL,FTW" for the five + * big metros, or any of the 25 codes). */ +export const DEFAULT_TXDOT_DISTRICTS = 'AUS,SAT'; +export const DEFAULT_TXDOT_MAX_SOURCES = 500; +/** Prioritization anchors: downtown cores of the metro districts a user can + * select. Cameras rank by distance to the NEAREST anchor, so a widened + * CCTV_TXDOT_DISTRICTS still ranks sensibly instead of against Austin alone. */ +export const TXDOT_ANCHORS = [ + { lat: 30.2672, lon: -97.7431 }, // Austin + { lat: 29.4241, lon: -98.4936 }, // San Antonio + { lat: 29.7604, lon: -95.3698 }, // Houston + { lat: 32.7767, lon: -96.797 }, // Dallas + { lat: 32.7555, lon: -97.3308 }, // Fort Worth +]; +/** Ground-elevation priors in metres, by district. The TxDOT payload carries + * no elevation, and on a keyless (no-tileset) stack the client's ground snap + * never fires, so this prior is the only height a camera gets there. Texas + * spans sea level (Houston) to ~1,140 m (El Paso). */ +export const TXDOT_DISTRICT_ELEVATION_M = Object.freeze({ + ABL: 520, + AMA: 1099, + ATL: 105, + AUS: 149, + BMT: 5, + BWD: 425, + BRY: 111, + CHS: 250, + CRP: 7, + DAL: 131, + ELP: 1140, + FTW: 199, + HOU: 15, + LRD: 132, + LBB: 992, + LFK: 91, + ODA: 890, + PAR: 185, + PHR: 30, + SJT: 585, + SAT: 198, + TYL: 165, + WAC: 143, + WFS: 289, + YKM: 70, +}); +export const TXDOT_DEFAULT_ELEVATION_M = 150; +export const CALTRANS_CCTV_URL = (district) => + `https://cwwp2.dot.ca.gov/data/d${district}/cctv/cctvStatusD${String(district).padStart(2, '0')}.json`; +/** Districts fetched by default: SF Bay (4), LA (7), San Diego (11), Sacramento (3). */ +export const DEFAULT_CALTRANS_DISTRICTS = '4,7,11,3'; +export const DEFAULT_CALTRANS_MAX_SOURCES = 300; +/** Prioritization anchors: downtown cores of the four default metros. */ +export const CALTRANS_ANCHORS = [ + { lat: 37.7793, lon: -122.4193 }, // San Francisco + { lat: 34.0537, lon: -118.2428 }, // Los Angeles + { lat: 32.7157, lon: -117.1611 }, // San Diego + { lat: 38.5816, lon: -121.4944 }, // Sacramento +]; +/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */ +export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam'; +export const TFL_IMAGE_ORIGIN = + 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'; +export const DEFAULT_TFL_MAX_SOURCES = 250; +export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 }; +/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */ +export const ONTARIO_511_CAMERAS_URL = + 'https://511on.ca/api/v2/get/cameras?format=json&lang=en'; +export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/'; +export const DEFAULT_ONTARIO_MAX_SOURCES = 1000; +export const ONTARIO_ANCHORS = [ + { lat: 43.4516, lon: -80.4925 }, // Kitchener + { lat: 43.6532, lon: -79.3832 }, // Toronto + { lat: 45.4215, lon: -75.6972 }, // Ottawa + { lat: 43.2557, lon: -79.8711 }, // Hamilton + { lat: 42.9849, lon: -81.2453 }, // London, Ontario + { lat: 42.3149, lon: -83.0364 }, // Windsor +]; +/** Fintraffic road weather cameras (Digitraffic): one keyless GeoJSON list + * covering all of Finland. Each STATION carries N presets (fixed camera views) + * that share the station position; one preset is one camera here. */ +export const FINTRAFFIC_STATIONS_URL = + 'https://tie.digitraffic.fi/api/weathercam/v1/stations'; +/** Frames: `.jpg`. Preset ids are synthesized into this + * origin rather than read from the payload, so no upstream field can steer the + * frame proxy off-host. */ +export const FINTRAFFIC_IMAGE_ORIGIN = 'https://weathercam.digitraffic.fi/'; +/** Digitraffic asks every client to identify itself on API calls. */ +export const DIGITRAFFIC_USER = 'gods-eye-view'; +export const DEFAULT_FINTRAFFIC_MAX_SOURCES = 300; +/** Ground-elevation prior, in metres, for stations that report no altitude. + * 228 of 809 stations carry a real metre value (median 94 m); the rest report + * 0, which means "not reported" rather than sea level — Kouvola (~80 m of real + * elevation) reports 0. The observed median stands in for those. */ +export const FINTRAFFIC_GROUND_ELEVATION_M = 90; +/** Prioritization anchors: the population centres strung along Finland's main + * road spine (vt1 Turku, vt3 Tampere, vt4 Jyväskylä–Oulu–Rovaniemi, vt5 + * Kuopio), so a cap keeps national coverage rather than just the capital. */ +export const FINLAND_ANCHORS = [ + { lat: 60.1699, lon: 24.9384 }, // Helsinki + { lat: 60.4518, lon: 22.2666 }, // Turku + { lat: 61.4978, lon: 23.761 }, // Tampere + { lat: 62.2426, lon: 25.7473 }, // Jyväskylä + { lat: 62.8924, lon: 27.677 }, // Kuopio + { lat: 65.0121, lon: 25.4651 }, // Oulu + { lat: 66.5039, lon: 25.7294 }, // Rovaniemi +]; +/** Global cap on total CCTV sources served by the proxy: the default per-pack + * caps summed (Austin 250 + Caltrans 300 + TfL 250 + DriveBC 250). */ +/** DriveBC highway cameras (British Columbia): the keyless camera list served by + * the DriveBC.ca site (github.com/bcgov/DriveBC.ca). The DataBC HighwayCams CSV + * lists the same cameras but still carries retired images.drivebc.ca frame URLs, + * so frames are built from the numeric camera id on the current image host. */ +export const DRIVEBC_WEBCAMS_URL = 'https://www.drivebc.ca/api/webcams/'; +export const DRIVEBC_IMAGE_URL = (id) => + `https://www.drivebc.ca/images/${id}.jpg`; +export const DEFAULT_DRIVEBC_MAX_SOURCES = 250; +/** Prioritization anchors: downtown Vancouver and Victoria. */ +export const DRIVEBC_ANCHORS = [ + { lat: 49.2827, lon: -123.1207 }, // Vancouver + { lat: 48.4284, lon: -123.3656 }, // Victoria +]; +/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */ +export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json'; +export const DEFAULT_TALLINN_MAX_SOURCES = 255; +export const TALLINN_IMAGE_ORIGIN = 'https://ristmikud.tallinn.ee/'; +export const TALLINN_CENTER = { lat: 59.437, lon: 24.753 }; +/** Transpordiamet / Tarktee road-weather cameras: keyless DATEX2 feeds. */ +export const TARKTEE_LOCATIONS_URL = + 'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations'; +export const TARKTEE_IMAGES_URL = + 'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages'; +export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/'; +export const DEFAULT_TARKTEE_MAX_SOURCES = 179; +export const TARKTEE_ANCHORS = [ + { lat: 59.437, lon: 24.753 }, // Tallinn + { lat: 58.378, lon: 26.729 }, // Tartu + { lat: 58.3859, lon: 24.4971 }, // Pärnu + { lat: 59.3797, lon: 28.1791 }, // Narva +]; +/** Warendorf (Germany): the Marktplatz municipal webcam from a curated catalog file. */ +export const DEFAULT_WARENDORF_SOURCE_FILE = + 'config/cctv_sources.warendorf.json'; +export const WARENDORF_IMAGE_ORIGINS = Object.freeze([ + 'http://webcam.warendorf.de/', + 'https://www.kreis-warendorf.de/', +]); +/** Live Traffic NSW (Transport for NSW): keyless public camera catalog. */ +export const NSW_CAMERAS_URL = + 'https://data.livetraffic.com/cameras/traffic-cam.json'; +export const NSW_IMAGE_ORIGIN = 'https://webcams.transport.nsw.gov.au/'; +export const DEFAULT_NSW_MAX_SOURCES = 250; +export const SYDNEY_CENTER = { lat: -33.8688, lon: 151.2093 }; +/** + * The NSW webcam host answers non-browser clients with HTTP 200 and a short + * HTML body instead of the frame (verified 2026-09-13), so the proxy + * identifies as a browser for that one host. See media.js. + */ +export const NSW_IMAGE_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; +/** + * Longest NSW `view` sentence still usable as a label. NSW occasionally + * repurposes `view` for a multi-paragraph works notice; real descriptions top + * out around 120 characters. + */ +export const NSW_MAX_VIEW_LABEL = 140; +/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ +export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; +/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one + * stalled upstream can't leave getCctvSources (and thus every CCTV route) + * pending forever — a hung fetch aborts, the loader returns [], and + * serve-stale/other packs take over. */ +export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000; +/** Individual CCTV image fetches must settle before the active 10-second + * client refresh cadence. A bounded miss can fall through to Street View or + * the synthetic frame instead of leaving the browser preview pending. */ +export const CCTV_FRAME_FETCH_TIMEOUT_MS = 8 * 1000; + +/** Maximum buffered snapshot size. */ +export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024; + +/** Deadline for upstream response headers; live bodies keep streaming afterward. */ +export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000; +/** Declared size ceiling for fixed media responses. */ +export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024; diff --git a/server/providers/cctv/groundHeights.js b/server/providers/cctv/groundHeights.js new file mode 100644 index 000000000..c73b6e840 --- /dev/null +++ b/server/providers/cctv/groundHeights.js @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { poseHash } from '../../../src/data/cctvFootprint.js'; + +/** Sidecar written by scripts/precompute-cctv-heights.mjs. */ +export const DEFAULT_GROUND_HEIGHTS_FILE = + 'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json'; + +/** @type {{path:string, mtimeMs:number, cameras:object}|null} */ +let _cache = null; + +/** + * Load the precomputed ground-height sidecar (heights under each camera's mount + * and monitor-plane support points, aligned to the Google 3D Tiles surface). Cached by file mtime so a + * catalog refresh re-reads only when the file changed. Missing or malformed + * files mean "no shipped heights", never an error. + * @param {string} sourceRoot + * @returns {Record} camera id → sidecar entry + */ +export function loadGroundHeights(sourceRoot = process.cwd()) { + const file = + process.env.CCTV_GROUND_HEIGHTS_FILE || DEFAULT_GROUND_HEIGHTS_FILE; + const resolved = path.isAbsolute(file) + ? file + : path.resolve(sourceRoot, file); + try { + const stat = fs.statSync(resolved); + if (_cache && _cache.path === resolved && _cache.mtimeMs === stat.mtimeMs) { + return _cache.cameras; + } + const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8')); + const cameras = + parsed && + typeof parsed === 'object' && + parsed.cameras && + typeof parsed.cameras === 'object' + ? parsed.cameras + : {}; + _cache = { path: resolved, mtimeMs: stat.mtimeMs, cameras }; + return cameras; + } catch { + _cache = null; + return {}; + } +} + +/** + * Attach a shipped entry to each served source whose id has one AND whose + * nominal pose still hashes to what the entry was sampled for. A pack whose + * feed moved a camera or changed its pose simply loses the shipped value and + * falls back to runtime placement. + * @param {Array} sources - Normalized served sources. + * @param {Record} entries - Sidecar cameras map. + * @returns {Array} The same sources, `groundHeights` attached where valid. + */ +export function joinGroundHeights(sources, entries) { + if (!entries || typeof entries !== 'object') return sources; + for (const source of sources) { + const entry = entries[source.id]; + if (!entry || entry.status !== 'ok' || !Number.isFinite(entry.mountGroundM)) + continue; + if (entry.poseHash !== poseHash(source)) continue; + const supports = {}; + for (const [key, value] of Object.entries(entry.supports || {})) { + if (Number.isFinite(value)) supports[key] = value; + } + source.groundHeights = { + poseHash: entry.poseHash, + mountGroundM: entry.mountGroundM, + supports, + }; + } + return sources; +} diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js new file mode 100644 index 000000000..87e982f3b --- /dev/null +++ b/server/providers/cctv/media.js @@ -0,0 +1,453 @@ +import { Readable } from 'node:stream'; +import { hashSeed, escapeXml } from './normalize.js'; +import { + CCTV_FRAME_FETCH_TIMEOUT_MS, + CCTV_FRAME_MAX_BODY_BYTES, + CCTV_MEDIA_FETCH_TIMEOUT_MS, + CCTV_MEDIA_MAX_BODY_BYTES, + NSW_IMAGE_ORIGIN, + NSW_IMAGE_USER_AGENT, +} from './constants.js'; +/** + * Generate a synthetic SVG billboard image for a CCTV camera placeholder. + * + * Produces a 960x540 SVG with a deterministic gradient (hue derived from + * camera ID hash), scanline overlay, HUD-style grid, and text labels + * showing camera name, city, ID, status, and current timestamp. Used + * when no upstream image or Street View fallback is available. + * + * @param {object} opts + * @param {string} opts.cameraId + * @param {string} opts.label + * @param {string} [opts.city] + * @param {string} [opts.status] + * @returns {string} SVG markup string. + */ +export function buildSyntheticCctvSvg({ cameraId, label, city, status }) { + const seed = hashSeed(`${cameraId}:${label}:${city}`); + const hue = seed % 360; + const hue2 = (hue + 46) % 360; + const now = new Date(); + const ts = now.toISOString().replace('T', ' ').replace('Z', 'Z').slice(0, 20); + const safeLabel = escapeXml(label); + const safeCity = escapeXml(city || 'GLOBAL GRID'); + const safeId = escapeXml(cameraId); + const safeStatus = escapeXml(status || 'SYNTHETIC'); + + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CCTV FEED PLACEHOLDER + ${safeLabel} · ${safeCity} + ${safeId} + ${escapeXml(ts)} + ${safeStatus} + +`.trim(); +} + +/** + * Coerce a fetch() response body to a Node.js Readable stream. + * + * Handles both Node-native streams (.pipe) and web ReadableStreams (.getReader). + * + * @param {ReadableStream|NodeJS.ReadableStream|null} body + * @returns {import('stream').Readable|null} + */ +export function toReadable(body) { + if (!body) return null; + if (typeof body.pipe === 'function') return body; + if (typeof body.getReader === 'function') { + return Readable.fromWeb(body); + } + return null; +} + +/** + * Pipe an upstream fetch Response (image or video) to the client HTTP response. + * + * Forwards Content-Type, Content-Length, Content-Range, Accept-Ranges, and + * Cache-Control headers from the upstream. Falls back to buffered arrayBuffer + * if the body is not streamable. + * + * @param {import('http').ServerResponse} res + * @param {Response} upstream - fetch() Response object. + * @param {object} [opts] + * @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header. + */ +export async function proxyMediaResponse( + res, + upstream, + { sourceHeader = 'upstream' } = {}, +) { + const contentType = + upstream.headers.get('content-type') || 'application/octet-stream'; + const cacheControl = upstream.headers.get('cache-control') || 'no-store'; + const contentLength = upstream.headers.get('content-length'); + const contentRange = upstream.headers.get('content-range'); + const acceptRanges = upstream.headers.get('accept-ranges'); + const headers = { + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + 'X-CCTV-Source': sourceHeader, + }; + if (contentLength) headers['Content-Length'] = contentLength; + if (contentRange) headers['Content-Range'] = contentRange; + if (acceptRanges) headers['Accept-Ranges'] = acceptRanges; + + // Cheap defense: reject an upstream that DECLARES an oversized fixed body. + // Live MJPEG/HLS streams are unbounded by design and send no content-length, + // so they pipe normally (piping streams to the client, never buffering). + if ( + Number.isFinite(Number(contentLength)) && + Number(contentLength) > CCTV_MEDIA_MAX_BODY_BYTES + ) { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Upstream media exceeds size cap' })); + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } + return; + } + + res.writeHead(upstream.status, headers); + + const stream = toReadable(upstream.body); + if (!stream) { + const buf = Buffer.from(await upstream.arrayBuffer()); + res.end(buf); + return; + } + + stream.on('error', () => { + if (!res.writableEnded) res.end(); + }); + stream.pipe(res); +} + +/** Read a snapshot incrementally, retaining at most maxBytes of owned chunks. */ +async function readCappedResponseBytes(upstream, maxBytes) { + const declared = Number(upstream.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } + return null; + } + if (!upstream.body) return null; + const chunks = []; + let total = 0; + // Every retained chunk is an OWNED copy: a chunk can be a small view over a + // much larger backing ArrayBuffer, and keeping the view would retain that + // whole allocation while the byte accounting only counted the view. + const keep = (chunk) => { + total += chunk.byteLength; + if (total > maxBytes) return false; + chunks.push(Buffer.from(chunk)); + return true; + }; + if (typeof upstream.body[Symbol.asyncIterator] === 'function') { + for await (const chunk of upstream.body) { + if (!keep(chunk)) { + try { + await upstream.body.cancel(); + } catch { + /* no-op */ + } + return null; + } + } + return Buffer.concat(chunks, total); + } + // No async iterator: stream through a reader so the cap still applies while + // reading. A body that cannot be streamed at all is refused rather than + // buffered uncapped — the helper's whole contract is the cap. + const reader = + typeof upstream.body.getReader === 'function' + ? upstream.body.getReader() + : null; + if (!reader) return null; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!keep(value)) { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + return null; + } + } + return Buffer.concat(chunks, total); + } finally { + reader.releaseLock(); + } +} + +/** Open registered media within a header deadline; leave timely live bodies running. */ +export async function fetchCctvMediaUpstream( + url, + { + headers = {}, + fetchImpl = fetch, + timeoutMs = CCTV_MEDIA_FETCH_TIMEOUT_MS, + } = {}, +) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { headers, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetch and decode a TxDOT ITS / TransGuide snapshot. + * + * TxDOT returns JSON with a base64-encoded JPEG in `snippet`, rather than + * returning image/jpeg directly. + */ +export async function fetchTxdotSnapshot( + url, + { + fetchImpl = fetch, + timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, + maxBytes = CCTV_FRAME_MAX_BODY_BYTES, + } = {}, +) { + if (!url) return null; + let parsed; + try { + parsed = new URL(url); + } catch { + return null; + } + if ( + parsed.origin !== 'https://its.txdot.gov' || + parsed.pathname !== '/its/DistrictIts/GetCctvSnapshotByIcdId' + ) { + return null; + } + // Base64 inflates by 4/3; the JSON envelope adds a few bytes of framing. + const maxEnvelopeBytes = Math.ceil((maxBytes * 4) / 3) + 4096; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + // The snapshot endpoint answers directly; a redirect is not followed, so + // the origin/path pin above holds for the request that is actually made. + const upstream = await fetchImpl(parsed.toString(), { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }, + signal: controller.signal, + redirect: 'manual', + }); + if (!upstream.ok) return null; + const envelope = await readCappedResponseBytes(upstream, maxEnvelopeBytes); + if (!envelope) return null; + let payload; + try { + payload = JSON.parse(envelope.toString('utf8')); + } catch { + return null; + } + let snippet = + typeof payload?.snippet === 'string' ? payload.snippet.trim() : ''; + if (!snippet) return null; + snippet = snippet.replace(/^data:image\/jpeg;base64,/i, ''); + // Canonical base64 only (4-char groups, padding only at the end): + // Buffer.from() silently skips junk, which would let a non-image body + // decode into "something". + if ( + snippet.length > maxEnvelopeBytes || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + snippet, + ) + ) { + return null; + } + const body = Buffer.from(snippet, 'base64'); + if (body.length < 4 || body.length > maxBytes) return null; + if (body[0] !== 0xff || body[1] !== 0xd8 || body[2] !== 0xff) return null; + return { ok: true, body, contentType: 'image/jpeg' }; + } catch { + return null; + } finally { + clearTimeout(timeoutId); + controller.abort(); + } +} + +/** Redirect hops the frame path will follow, and only within the same host. */ +const MAX_SAME_HOST_REDIRECTS = 2; + +/** + * Fetch a registered frame URL following redirects ONLY within the original + * origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops). + * Default redirect-following would let an upstream steer a host-pinned + * request, and its host-specific headers, to any origin, another port, or a + * plaintext downgrade. + * + * @param {string} url + * @param {object} init - fetch init (headers, signal). + * @param {typeof fetch} fetchImpl + * @returns {Promise} Final response, or null on an off-host or + * over-long redirect chain. + */ +export async function fetchWithinHost(url, init, fetchImpl = fetch) { + let current; + try { + current = new URL(url); + } catch { + return null; + } + const origin = current.origin; + for (let hop = 0; hop <= MAX_SAME_HOST_REDIRECTS; hop++) { + const upstream = await fetchImpl(current.toString(), { + ...init, + redirect: 'manual', + }); + // Anything that is not a 3xx (including a test double with no status) is + // the final answer. + const status = Number(upstream?.status); + if (!(status >= 300 && status < 400)) return upstream; + const location = upstream.headers.get('location'); + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } + if (!location || hop === MAX_SAME_HOST_REDIRECTS) return null; + let next; + try { + next = new URL(location, current); + } catch { + return null; + } + if (next.origin !== origin) return null; + current = next; + } + return null; +} + +/** + * Image hosts that only serve frames to browser-identified clients, keyed by + * exact hostname. Every other upstream sees the proxy's own identifying + * User-Agent. Keyed on host, not on a URL substring, so a look-alike host or a + * path that merely mentions the host never inherits the header. + */ +const CCTV_IMAGE_USER_AGENT_BY_HOST = Object.freeze({ + [new URL(NSW_IMAGE_ORIGIN).hostname]: NSW_IMAGE_USER_AGENT, +}); + +/** + * User-Agent for one upstream frame request. + * + * @param {string} url + * @returns {string} + */ +export function cctvUpstreamUserAgent(url) { + try { + return ( + CCTV_IMAGE_USER_AGENT_BY_HOST[new URL(url).hostname] || + 'gods-eye-view-cctv-proxy/1.0' + ); + } catch { + return 'gods-eye-view-cctv-proxy/1.0'; + } +} + +/** + * Fetch one upstream CCTV image within the frame-refresh budget. + * + * A timeout is treated like every other upstream miss so the caller can + * continue through the Street View and synthetic fallback chain. `fetchImpl` + * and `timeoutMs` are injectable only to keep the timeout contract unit-testable. + * + * @param {string} url - Server-registered upstream image URL. + * @param {object} [options] + * @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation. + * @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout. + * @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap. + * @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>} + */ +export async function fetchCctvImageFromUpstream( + url, + { + fetchImpl = fetch, + timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, + maxBytes = CCTV_FRAME_MAX_BODY_BYTES, + } = {}, +) { + if (!url || !/^https?:\/\//i.test(url)) return null; + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort( + new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError'), + ); + }, timeoutMs); + try { + const upstream = await fetchWithinHost( + url, + { + headers: { 'User-Agent': cctvUpstreamUserAgent(url) }, + signal: controller.signal, + }, + fetchImpl, + ); + if (!upstream) return null; + const contentType = upstream.headers.get('content-type') || ''; + if (!upstream.ok || !contentType.startsWith('image/')) { + controller.abort(); + return null; + } + const body = await readCappedResponseBytes(upstream, maxBytes); + if (!body) return null; + return { ok: true, body, contentType }; + } catch { + return null; + } finally { + clearTimeout(timeoutId); + controller.abort(); + } +} diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js new file mode 100644 index 000000000..9677ca661 --- /dev/null +++ b/server/providers/cctv/normalize.js @@ -0,0 +1,533 @@ +import { directionToHeading } from '../../../src/data/directionText.js'; +import { haversineKm } from '../common/geo.js'; +/** + * FNV-1a 32-bit hash of a string, used to derive deterministic pseudo-random + * values (e.g. hue for synthetic SVG billboards, fallback heading angles). + * + * @param {string} text + * @returns {number} Unsigned 32-bit hash. + */ +export function hashSeed(text) { + let h = 2166136261 >>> 0; // FNV offset basis + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); // FNV prime + } + return h >>> 0; +} + +/** + * Escape special XML/HTML characters for safe embedding in SVG text nodes. + * + * @param {string} text + * @returns {string} + */ +export function escapeXml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Canonicalize a CCTV feed type string to one of: + * 'image', 'mjpeg', 'mp4', 'webm', 'hls', or pass-through. + * + * @param {string} value - Raw feed type (e.g. 'jpeg', 'mjpg', 'video', 'stream'). + * @returns {string} Normalized feed type. + */ +export function normalizeFeedType(value) { + const raw = String(value || '') + .trim() + .toLowerCase(); + if (!raw) return 'image'; + if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image'; + if (raw === 'mjpg') return 'mjpeg'; + if (raw === 'video') return 'mp4'; + if (raw === 'stream') return 'hls'; + return raw; +} + +/** + * Check whether a normalized feed type represents streaming video. + * + * @param {string} feedType + * @returns {boolean} + */ +export function isVideoFeedType(feedType) { + return feedType === 'mp4' || feedType === 'webm' || feedType === 'hls'; +} + +/** + * Coerce a value to a finite number, returning fallback if NaN/Infinity. + * + * @param {*} value + * @param {number} [fallback=NaN] + * @returns {number} + */ +export function toFiniteNumber(value, fallback = NaN) { + const num = Number(value); + return Number.isFinite(num) ? num : fallback; +} + +/** + * Normalize a column/field name to a lowercase snake_case key. + * + * @param {string} text + * @returns {string} + */ +export function normalizeKey(text) { + return String(text || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +/** + * Parse a WKT POINT string (e.g. "POINT(-97.74 30.27)") into lat/lon. + * + * WKT uses (lon lat) order; returned object uses {lat, lon}. + * + * @param {string} value + * @returns {{lat:number, lon:number}} + */ +export function parsePointString(value) { + const match = String(value || '').match( + /POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i, + ); + if (!match) return { lat: NaN, lon: NaN }; + return { + lon: toFiniteNumber(match[1]), + lat: toFiniteNumber(match[2]), + }; +} + +/** + * Extract lat/lon from a variety of coordinate representations. + * + * Handles WKT POINT strings, and objects with latitude/lat/y or + * longitude/lon/lng/x properties (various casing). + * + * @param {string|object|null} value + * @returns {{lat:number, lon:number}} + */ +export function coerceLatLon(value) { + if (!value) return { lat: NaN, lon: NaN }; + + if (typeof value === 'string') { + return parsePointString(value); + } + + if (typeof value !== 'object') { + return { lat: NaN, lon: NaN }; + } + + const lat = toFiniteNumber( + value.latitude ?? value.lat ?? value.y ?? value.Latitude ?? value.Lat, + NaN, + ); + const lon = toFiniteNumber( + value.longitude ?? + value.lon ?? + value.lng ?? + value.x ?? + value.Longitude ?? + value.Lon, + NaN, + ); + return { lat, lon }; +} + +/** + * Extract geographic coordinates from an Austin Open Data camera record. + * + * Tries several candidate fields (location, coordinates, the_geom, + * point, geocoded_column) via coerceLatLon, then falls back to + * explicit latitude/longitude scalar fields. + * + * @param {object} record - Flattened camera record. + * @returns {{lat:number, lon:number}} + */ +export function extractAustinCoords(record) { + const candidates = [ + record.location, + record.coordinates, + record.the_geom, + record.point, + record.geocoded_column, + ]; + for (const candidate of candidates) { + const parsed = coerceLatLon(candidate); + if (Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon)) + return parsed; + } + + const lat = toFiniteNumber( + record.latitude ?? + record.lat ?? + record.camera_latitude ?? + record.location_latitude, + NaN, + ); + const lon = toFiniteNumber( + record.longitude ?? + record.lon ?? + record.lng ?? + record.camera_longitude ?? + record.location_longitude, + NaN, + ); + return { lat, lon }; +} + +/** + * Extract a numeric camera ID from an Austin Open Data record. + * + * Tries well-known field names first, then scans any field whose key + * contains "camera"/"cam"/"device" + "id". + * + * @param {object} record - Flattened camera record. + * @returns {string} Numeric ID string, or '' if none found. + */ +export function extractAustinCameraId(record) { + const preferredKeys = [ + 'camera_id', + 'cameraid', + 'cam_id', + 'device_id', + 'intersection_id', + 'id', + ]; + for (const key of preferredKeys) { + const value = record[key]; + if (value == null) continue; + const asText = String(value).trim(); + if (!asText) continue; + if (/^\d+$/.test(asText)) return asText; + } + + for (const [key, value] of Object.entries(record)) { + if (!/camera|cam|device/.test(key)) continue; + if (!/id/.test(key)) continue; + const asText = String(value || '').trim(); + if (!asText) continue; + if (/^\d+$/.test(asText)) return asText; + } + + return ''; +} + +/** + * Extract a human-readable camera name from an Austin record. + * + * @param {object} record - Flattened camera record. + * @param {string} cameraId - Fallback identifier if no name field found. + * @returns {string} + */ +export function extractAustinName(record, cameraId) { + const preferredKeys = [ + 'camera_name', + 'location_name', + 'intersection_name', + 'location', + 'cross_street', + 'description', + 'name', + ]; + for (const key of preferredKeys) { + const value = record[key]; + if (typeof value !== 'string') continue; + const text = value.trim(); + if (text) return text; + } + return `Austin Camera ${cameraId}`; +} + +/** + * Extract camera heading (compass bearing) from an Austin record. + * + * Tries explicit numeric heading fields first, then direction-keyword + * fields, then infers from the camera name/description text. + * + * @param {object} record - Flattened camera record. + * @returns {number} Heading in degrees [0..360), or NaN if unknown. + */ +export function extractAustinHeading(record) { + const direct = toFiniteNumber( + record.heading_deg ?? record.heading ?? record.bearing, + NaN, + ); + if (Number.isFinite(direct)) return ((direct % 360) + 360) % 360; + + // Dedicated direction fields: bare cardinal words ("West") are real facings. + const directionKeys = [ + 'direction', + 'travel_direction', + 'facing', + 'facing_direction', + ]; + for (const key of directionKeys) { + const heading = directionToHeading(record[key], true); + if (Number.isFinite(heading)) return heading; + } + + // Free-form name/intersection text: only explicit travel forms ("WESTBOUND"/ + // "WB") count — a bare "West" here is a street name ("5TH ST / WEST AVE"), not + // a facing, and must not promote the camera to a false high-confidence heading. + const nameProbe = [ + record.camera_name, + record.location_name, + record.intersection_name, + record.location, + record.cross_street, + record.description, + record.name, + ] + .filter(Boolean) + .join(' '); + const inferred = directionToHeading(nameProbe); + if (Number.isFinite(inferred)) return inferred; + + return NaN; +} + +/** + * Bounding-box sanity check: is this coordinate plausibly in the Austin metro area? + * + * @param {number} lat + * @param {number} lon + * @returns {boolean} + */ +export function isLikelyAustinCoordinate(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.4; +} + +/** + * Bounding-box sanity check: is this coordinate plausibly on the Finnish road + * network? Generous around the observed catalog extent (59.86..70.09 N, + * 19.62..31.28 E) so a real new station is never dropped, tight enough that a + * swapped lat/lon or a null island record is. + * + * @param {number} lat + * @param {number} lon + * @returns {boolean} + */ +/** Longest unselected camera label the HUD shows before it gets noisy. */ +export const CAMERA_CODE_MAX_CHARS = 28; + +/** + * Short display code for the unselected camera label ("CAM-"): the + * feed's own name for the camera ("5TH ST / CONGRESS AVE", "TRAFALGAR + * SQUARE"), trimmed to CAMERA_CODE_MAX_CHARS. A pack may pass an explicit + * `code` (TxDOT's device key, NSW's title); the id is the last resort. + * + * @param {string} text + * @returns {string} + */ +export function cameraDisplayCode(text) { + const clean = String(text || '') + .replace(/\s+/g, ' ') + .trim(); + if (clean.length <= CAMERA_CODE_MAX_CHARS) return clean; + return `${clean.slice(0, CAMERA_CODE_MAX_CHARS - 1).trimEnd()}…`; +} + +/** Finite, in range, and not the null island that Number(null) produces. */ +export function isPlausibleLatLon(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 && + !(lat === 0 && lon === 0) + ); +} + +/** British Columbia bounding box (with the neighbouring border crossings). */ +export function isLikelyBcCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 48 && + lat <= 60.5 && + lon >= -139.5 && + lon <= -114 + ); +} + +/** Texas bounding box. */ +export function isLikelyTexasCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 25.5 && + lat <= 36.7 && + lon >= -107 && + lon <= -93.4 + ); +} + +/** New South Wales bounding box (incl. the ACT and Lord Howe Island). */ +export function isLikelyNswCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= -38 && + lat <= -28 && + lon >= 140.9 && + lon <= 159.2 + ); +} + +export function isLikelyFinlandCoordinate(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; +} + +/** + * Human label for one Fintraffic preset (camera view). + * + * Station names are machine-shaped road codes ("vt3_Hyvinkää_Noppo"); the + * underscores become spaces. A preset id is always its station id plus a + * two-digit view number, so the remainder distinguishes the several views that + * share one station position. The station list endpoint carries no + * presentationName ("Helsinkiin"); that lives only on the per-station detail + * endpoint, which would cost one request per station. + * + * @param {string} stationName - Raw `properties.name`. + * @param {string} stationId - Raw `properties.id` (e.g. "C01503"). + * @param {string} presetId - Raw preset id (e.g. "C0150301"). + * @returns {string} + */ +export function fintrafficCameraName(stationName, stationId, presetId) { + const base = + String(stationName || '') + .replace(/_/g, ' ') + .trim() || `Fintraffic ${stationId}`; + const view = String(presetId || '').slice(String(stationId || '').length); + return view ? `${base} (view ${view})` : base; +} + +/** + * Derive a deterministic fallback heading from a camera ID hash. + * + * Produces one of 16 evenly-spaced compass directions (0, 22.5, 45, ...). + * + * @param {string} cameraId + * @returns {number} Heading in degrees [0..360). + */ +export function fallbackHeadingFromId(cameraId) { + return (hashSeed(String(cameraId)) % 16) * 22.5; +} + +/** + * Convert a Socrata rows.json array row into a keyed object using column metadata. + * + * @param {Array} row - Array of cell values from the Socrata payload. + * @param {Array<{fieldName?:string, name?:string}>} columns - Column descriptors. + * @returns {object} Keyed record with normalized snake_case keys. + */ +export function rowArrayToObject(row, columns) { + const record = {}; + for (let idx = 0; idx < columns.length; idx++) { + const col = columns[idx]; + const key = normalizeKey(col.fieldName || col.name || `col_${idx}`); + if (!key) continue; + record[key] = row[idx]; + } + return record; +} + +/** + * Distance-prioritizes cameras to a cap: keeps the maxCount cameras closest + * to ANY of the given anchor points (min distance over anchors), tie-broken + * by original array order. Used by every live source pack (Austin: one + * downtown anchor; Caltrans: one anchor per major CA metro; TfL: central + * London) so a cap always keeps the densest, most interesting cores. + * + * @param {Array} cameras - Normalized camera source objects. + * @param {number} maxCount - Cap (<=0 or >= length disables). + * @param {Array<{lat:number,lon:number}>} anchors - At least one anchor. + * @returns {Array} Capped, priority-ordered camera list. + */ +export function prioritizeSources(cameras, maxCount, anchors) { + const list = Array.isArray(cameras) ? cameras : []; + const anchorList = (Array.isArray(anchors) ? anchors : []).filter( + (a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon), + ); + if (!anchorList.length) return list; + // Always sort when anchors exist, even when the pack fits its own cap: the + // catalog-wide cap (cap.js) thins a pack from the END of this order, so + // "nearest to an anchor first" has to hold whether or not the pack was + // trimmed here. + const cap = + Number.isFinite(maxCount) && maxCount > 0 + ? Math.min(maxCount, list.length) + : list.length; + + const scored = list.map((camera, idx) => { + const lat = Number(camera?.lat); + const lon = Number(camera?.lon); + const distKm = + Number.isFinite(lat) && Number.isFinite(lon) + ? Math.min( + ...anchorList.map((a) => haversineKm(lat, lon, a.lat, a.lon)), + ) + : Number.POSITIVE_INFINITY; + return { camera, idx, distKm }; + }); + + scored.sort((a, b) => { + if (a.distKm !== b.distKm) return a.distKm - b.distKm; + return a.idx - b.idx; + }); + + return scored.slice(0, cap).map((entry) => entry.camera); +} + +/** + * Normalize a raw CCTV source item into a canonical shape with safe defaults. + * + * @param {object} item - Raw source from file, env, or Austin Open Data. + * @returns {object} Normalized source with all expected fields populated. + */ +export function normalizeSourceItem(item) { + return { + id: String(item.id || '').trim(), + name: String(item.name || item.id || '').trim(), + city: String(item.city || ''), + cityId: String(item.cityId || ''), + provider: String(item.provider || 'Configured CCTV Source'), + lat: toFiniteNumber(item.lat), + lon: toFiniteNumber(item.lon), + headingDeg: toFiniteNumber(item.headingDeg), + headingConfidence: String( + item.headingConfidence || item.headingSource || '', + ).toLowerCase(), + pitchDeg: toFiniteNumber(item.pitchDeg), + fovDeg: toFiniteNumber(item.fovDeg), + rangeM: toFiniteNumber(item.rangeM), + mountHeightM: toFiniteNumber(item.mountHeightM), + groundElevationM: toFiniteNumber(item.groundElevationM), + feedType: normalizeFeedType(item.feedType || item.type || ''), + url: typeof item.url === 'string' ? item.url : '', + snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '', + license: String(item.license || item.licenseNote || ''), + // Per-camera attribution for feeds a partner supplies inside a pack + // (DriveBC: TransLink, city cameras). Shown beside the provider. + credit: String(item.credit || '').trim(), + // Unselected-label code: the pack's explicit short name, else the feed's + // name, else the id. + code: cameraDisplayCode( + item.code || String(item.name || '').toUpperCase() || item.id || '', + ), + sourceKind: String(item.sourceKind || item.kind || 'configured'), + // Optional CAL badge input (cctv-v2 design §3b/§9.2, additive-only per the + // global constraints — nothing else in this file changes): hand-authored + // file/env catalog entries may declare poseSource:'curated' so the panel + // badge can distinguish them from raw automated priors (e.g. Austin Open + // Data, which never sets this field). Passed through as-is to the client. + poseSource: item.poseSource === 'curated' ? 'curated' : undefined, + }; +} diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js new file mode 100644 index 000000000..ef0ae64b8 --- /dev/null +++ b/server/providers/cctv/sources.js @@ -0,0 +1,1391 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + DEFAULT_AUSTIN_ROWS_URL, + DEFAULT_AUSTIN_MAX_SOURCES, + AUSTIN_DOWNTOWN, + CALTRANS_CCTV_URL, + DEFAULT_CALTRANS_DISTRICTS, + DEFAULT_CALTRANS_MAX_SOURCES, + CALTRANS_ANCHORS, + TFL_JAMCAM_URL, + TFL_IMAGE_ORIGIN, + DEFAULT_TFL_MAX_SOURCES, + LONDON_CENTER, + ONTARIO_511_CAMERAS_URL, + ONTARIO_511_IMAGE_ORIGIN, + DEFAULT_ONTARIO_MAX_SOURCES, + ONTARIO_ANCHORS, + FINTRAFFIC_STATIONS_URL, + FINTRAFFIC_IMAGE_ORIGIN, + FINTRAFFIC_GROUND_ELEVATION_M, + DIGITRAFFIC_USER, + DEFAULT_FINTRAFFIC_MAX_SOURCES, + FINLAND_ANCHORS, + DRIVEBC_WEBCAMS_URL, + DRIVEBC_IMAGE_URL, + DEFAULT_DRIVEBC_MAX_SOURCES, + DRIVEBC_ANCHORS, + TXDOT_CCTV_STATUS_URL, + TXDOT_CCTV_SNAPSHOT_URL, + TXDOT_DISTRICTS, + DEFAULT_TXDOT_DISTRICTS, + DEFAULT_TXDOT_MAX_SOURCES, + TXDOT_ANCHORS, + TXDOT_DISTRICT_ELEVATION_M, + TXDOT_DEFAULT_ELEVATION_M, + DEFAULT_TALLINN_SOURCE_FILE, + DEFAULT_TALLINN_MAX_SOURCES, + TALLINN_IMAGE_ORIGIN, + TALLINN_CENTER, + TARKTEE_LOCATIONS_URL, + TARKTEE_IMAGES_URL, + TARKTEE_IMAGE_ORIGIN, + DEFAULT_TARKTEE_MAX_SOURCES, + TARKTEE_ANCHORS, + DEFAULT_WARENDORF_SOURCE_FILE, + WARENDORF_IMAGE_ORIGINS, + NSW_CAMERAS_URL, + NSW_IMAGE_ORIGIN, + DEFAULT_NSW_MAX_SOURCES, + SYDNEY_CENTER, + NSW_MAX_VIEW_LABEL, + CCTV_SOURCE_FETCH_TIMEOUT_MS, +} from './constants.js'; +import { + toFiniteNumber, + extractAustinCoords, + extractAustinCameraId, + extractAustinName, + extractAustinHeading, + isLikelyAustinCoordinate, + fallbackHeadingFromId, + isLikelyFinlandCoordinate, + fintrafficCameraName, + hashSeed, + isPlausibleLatLon, + isLikelyBcCoordinate, + isLikelyTexasCoordinate, + isLikelyNswCoordinate, + cameraDisplayCode, + rowArrayToObject, + prioritizeSources, +} from './normalize.js'; +import { directionToHeading } from '../../../src/data/directionText.js'; +/** + * Fetch and parse Austin traffic camera records from the city Open Data portal. + * + * Downloads the Socrata rows.json payload, converts each row to a keyed + * record, extracts camera ID / coords / heading / name, validates against + * the Austin bounding box, deduplicates by ID, then distance-prioritizes + * to stay within CCTV_AUSTIN_MAX_SOURCES. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadAustinSourcesFromOpenData() { + const endpoint = process.env.CCTV_AUSTIN_ROWS_URL || DEFAULT_AUSTIN_ROWS_URL; + try { + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] Austin source download failed:', resp.status); + return []; + } + const payload = await resp.json(); + const columns = Array.isArray(payload?.meta?.view?.columns) + ? payload.meta.view.columns + : []; + const rows = Array.isArray(payload?.data) ? payload.data : []; + if (!columns.length || !rows.length) return []; + + const cameras = []; + for (const row of rows) { + if (!Array.isArray(row)) continue; + const record = rowArrayToObject(row, columns); + const cameraId = extractAustinCameraId(record); + if (!cameraId) continue; + + // Only live cameras: the dataset carries DESIRED (planned, not built), + // REMOVED and VOID rows whose frame URLs never resolve — those cameras + // would render as permanent Street View / synthetic fallbacks. Tolerate + // a missing column (keep the row) so a schema change fails open. + const status = String(record.camera_status || '') + .trim() + .toUpperCase(); + if (status && status !== 'TURNED_ON') continue; + + const { lat, lon } = extractAustinCoords(record); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if (!isLikelyAustinCoordinate(lat, lon)) continue; + + const extractedHeading = extractAustinHeading(record); + const hasHeading = Number.isFinite(extractedHeading); + const headingDeg = hasHeading + ? extractedHeading + : fallbackHeadingFromId(cameraId); + cameras.push({ + id: cameraId, + name: extractAustinName(record, cameraId), + city: 'Austin', + cityId: 'austin', + provider: 'Austin Transportation & Public Works', + lat, + lon, + headingDeg, + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: 150, + feedType: 'image', + url: `https://cctv.austinmobility.io/image/${encodeURIComponent(cameraId)}.jpg`, + snapshotUrl: `https://cctv.austinmobility.io/image/${encodeURIComponent(cameraId)}.jpg`, + sourceKind: 'austin-open-data', + license: 'Public city traffic camera frame', + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_AUSTIN_MAX_SOURCES || DEFAULT_AUSTIN_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(300, Math.floor(maxRaw))) + : DEFAULT_AUSTIN_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, [AUSTIN_DOWNTOWN]); + if (prioritized.length < unique.length) { + console.log( + `[CCTV] Loaded Austin camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + } else { + console.log('[CCTV] Loaded Austin camera sources:', prioritized.length); + } + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Austin source download error:', + error?.message || error, + ); + return []; + } +} + +/** + * Fetch Caltrans CCTV cameras for the configured districts (CCTV_CALTRANS_DISTRICTS, + * comma-separated 1..12; empty string disables the pack). One official JSON feed per + * district, identical schema statewide; keyless. Only inService cameras with finite + * coords and a cwwp2.dot.ca.gov https image URL are kept (the image-URL origin check + * is defense-in-depth: the proxy only ever fetches catalog URLs, and this pins the + * catalog to the official host). Districts fetch in parallel and fail independently + * (Promise.allSettled) — one district outage never darkens the others. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadCaltransSourcesFromOpenData() { + const districtsRaw = + process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS; + const districts = String(districtsRaw) + .split(',') + .map((token) => Number(token.trim())) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= 12); + if (!districts.length) return []; + + const settled = await Promise.allSettled( + districts.map(async (district) => { + const resp = await fetch(CALTRANS_CCTV_URL(district), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`D${district} HTTP ${resp.status}`); + const payload = await resp.json(); + const rows = Array.isArray(payload?.data) ? payload.data : []; + return { district, rows }; + }), + ); + + const cameras = []; + for (const result of settled) { + if (result.status !== 'fulfilled') { + console.warn( + '[CCTV] Caltrans district fetch failed:', + result.reason?.message || result.reason, + ); + continue; + } + const { district, rows } = result.value; + for (const row of rows) { + const cctv = row?.cctv; + if (!cctv || String(cctv.inService).toLowerCase() !== 'true') continue; + const loc = cctv.location || {}; + const lat = toFiniteNumber(loc.latitude); + const lon = toFiniteNumber(loc.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + const imageUrl = String(cctv.imageData?.static?.currentImageURL || ''); + // Official-host pin (see JSDoc). Also drops records with no still image. + if (!imageUrl.startsWith('https://cwwp2.dot.ca.gov/')) continue; + + const locationName = String(loc.locationName || '').trim(); + // Leading token of locationName is the stable camera code ("TV102 -- I-580 : …"). + const codeMatch = /^([A-Za-z0-9_-]+)\s*--/.exec(locationName); + const code = ( + codeMatch ? codeMatch[1] : `x${cameras.length}` + ).toLowerCase(); + const cameraId = `ca-d${district}-${code}`; + + // loc.direction is a dedicated field ("West", "South") → allow bare words. + const heading = directionToHeading(loc.direction, true); + const hasHeading = Number.isFinite(heading); + const label = + locationName.replace(/^([A-Za-z0-9_-]+)\s*--\s*/, '') || + `Caltrans D${district} ${code}`; + cameras.push({ + id: cameraId, + name: loc.nearbyPlace ? `${label} (${loc.nearbyPlace})` : label, + city: String(loc.nearbyPlace || `Caltrans D${district}`), + cityId: `ca-d${district}`, + provider: 'Caltrans', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Same two fabricated pose personalities as Austin (design §1a): these are + // RAW PRIOR starting points; the client's one-shot ground snap + manual + // calibration own the truth. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + // loc.elevation is reported in FEET (verified: D3 maxes at 7427 ft ≈ + // 2264 m for the Sierra passes — as metres that would top Mt Whitney). + // Convert to metres and clamp to a sane CA-roads range so an occasional + // garbage upstream value can't fling a camera kilometres up. Prior only: + // the client one-shot snap corrects it on 3D-tile stacks — but on a + // no-tileset stack (keyless OSM) the snap misses and this height freezes, + // so it must be right-ish on its own. + groundElevationM: (() => { + const ft = toFiniteNumber(loc.elevation, NaN); + return Number.isFinite(ft) + ? Math.max(-100, Math.min(4000, ft * 0.3048)) + : 150; + })(), + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'caltrans-open-data', + license: 'Public Caltrans highway camera frame', + }); + } + } + + const maxRaw = Number( + process.env.CCTV_CALTRANS_MAX_SOURCES || DEFAULT_CALTRANS_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_CALTRANS_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, CALTRANS_ANCHORS); + console.log( + `[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`, + ); + return prioritized; +} + +/** + * Fetch TfL JamCams (London). Keyless: the optional TFL_APP_KEY only raises the + * list-endpoint rate limit (frames come from TfL's public S3 bucket, which is not + * rate-limited); the 15-min source cache keeps list hits far below anonymous + * limits anyway. Only `available === "true"` cameras with finite coords and an + * image URL on the official bucket are kept. Attribution: "Powered by TfL Open + * Data" (registered in src/data/dataCredits.js). + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadTflSourcesFromOpenData() { + try { + const appKey = String(process.env.TFL_APP_KEY || '').trim(); + const url = appKey + ? `${TFL_JAMCAM_URL}?app_key=${encodeURIComponent(appKey)}` + : TFL_JAMCAM_URL; + const resp = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] TfL JamCam download failed:', resp.status); + return []; + } + const places = await resp.json(); + if (!Array.isArray(places)) return []; + + const cameras = []; + for (const place of places) { + const props = {}; + for (const p of place?.additionalProperties || []) { + if (p?.key) props[p.key] = p.value; + } + if (String(props.available).toLowerCase() !== 'true') continue; + const lat = toFiniteNumber(place?.lat); + const lon = toFiniteNumber(place?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const imageUrl = String(props.imageUrl || ''); + if (!imageUrl.startsWith(TFL_IMAGE_ORIGIN)) continue; // official-bucket pin + + // "JamCams_00002.00865" → "tfl-00002.00865" (provider-stable id). + const rawId = String(place?.id || '').replace(/^JamCams_/, ''); + if (!rawId) continue; + const cameraId = `tfl-${rawId}`; + + cameras.push({ + id: cameraId, + name: String(place?.commonName || `JamCam ${rawId}`), + city: 'London', + cityId: 'london', + provider: 'Transport for London', + lat, + lon, + // No heading signal at all in JamCam data → id-hash fallback, low + // confidence personality (same as headingless Austin cameras). + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: 15, // Thames-basin prior; one-shot snap corrects. + feedType: 'image', // stills-first (owner decision); props.videoUrl deliberately unused + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'tfl-open-data', + license: 'Powered by TfL Open Data', + }); + } + + const maxRaw = Number( + process.env.CCTV_TFL_MAX_SOURCES || DEFAULT_TFL_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_TFL_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, [LONDON_CENTER]); + console.log( + `[CCTV] Loaded TfL JamCam sources: ${cameras.length} available (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] TfL JamCam download error:', error?.message || error); + return []; + } +} + +/** + * Bounding-box sanity check for Ontario 511 rows. + * + * @param {number} lat + * @param {number} lon + * @returns {boolean} + */ +function isLikelyOntarioCoordinate(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + return lat >= 41.0 && lat <= 57.5 && lon >= -95.6 && lon <= -74.0; +} + +/** + * Pin an Ontario 511 camera view URL to the official still-image host. + * + * @param {string} value - Upstream view URL. + * @returns {string} Canonical 511on.ca still URL, or '' if not accepted. + */ +function normalizeOntarioCctvUrl(value) { + try { + const parsed = new URL(String(value || '').trim()); + const match = /^\/map\/Cctv\/([^/?#]+)$/.exec(parsed.pathname); + if (!match) return ''; + const host = parsed.hostname.toLowerCase(); + if ( + parsed.protocol !== 'https:' || + (host !== '511on.ca' && !host.endsWith('.traveliq.co')) + ) { + return ''; + } + const viewId = decodeURIComponent(match[1]); + if (!/^[A-Za-z0-9_.-]+$/.test(viewId)) return ''; + return `${ONTARIO_511_IMAGE_ORIGIN}${encodeURIComponent(viewId)}`; + } catch { + return ''; + } +} + +/** + * Select the best Ontario 511 still view for a camera. + * + * @param {Array} views + * @returns {{url:string,description:string}|null} + */ +function pickOntarioCctvView(views) { + const enabled = (Array.isArray(views) ? views : []) + .filter( + (view) => + String(view?.Status || view?.status || '') + .trim() + .toLowerCase() === 'enabled', + ) + .map((view) => ({ + url: normalizeOntarioCctvUrl(view?.Url || view?.url), + description: String(view?.Description || view?.description || '').trim(), + })) + .filter((view) => view.url); + if (!enabled.length) return null; + return ( + enabled.find((view) => !/\bdown\b/i.test(view.description)) || enabled[0] + ); +} + +/** + * Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the + * public 511 API, while frame URLs are stable still-image endpoints under + * 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one + * enabled official still view are kept. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadOntarioSourcesFromOpenData() { + try { + const resp = await fetch(ONTARIO_511_CAMERAS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] Ontario 511 camera download failed:', resp.status); + return []; + } + const rows = await resp.json(); + if (!Array.isArray(rows)) return []; + + const cameras = []; + for (const row of rows) { + const rawId = String(row?.Id ?? row?.id ?? '').trim(); + if (!rawId) continue; + const lat = toFiniteNumber(row?.Latitude ?? row?.latitude); + const lon = toFiniteNumber(row?.Longitude ?? row?.longitude); + if (!isLikelyOntarioCoordinate(lat, lon)) continue; + + const view = pickOntarioCctvView(row?.Views || row?.views); + if (!view) continue; + + const cameraId = `on-${rawId}`; + const location = String(row?.Location || row?.location || '').trim(); + const roadway = String(row?.Roadway || row?.roadway || '').trim(); + const viewLabel = + view.description && !/\bdown\b/i.test(view.description) + ? view.description + : ''; + const label = [ + location || roadway || `Ontario 511 Camera ${rawId}`, + viewLabel, + ] + .filter(Boolean) + .join(' - '); + let heading = directionToHeading(row?.Direction ?? row?.direction, true); + if (!Number.isFinite(heading)) { + heading = directionToHeading(view.description, true); + } + const hasHeading = Number.isFinite(heading); + + cameras.push({ + id: cameraId, + name: label, + city: location || roadway || 'Ontario', + cityId: 'ontario', + provider: 'Ontario 511', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: 200, + feedType: 'image', + url: view.url, + snapshotUrl: view.url, + sourceKind: 'ontario-511-open-data', + license: 'Open Government Licence - Ontario', + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) + : DEFAULT_ONTARIO_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, ONTARIO_ANCHORS); + console.log( + `[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Ontario 511 camera download error:', + error?.message || error, + ); + return []; + } +} + +/** + * Fetch Fintraffic road weather cameras (all of Finland) from Digitraffic. + * Keyless; one GeoJSON station list per refresh (~37 KB gzipped, 809 stations + * / 2,275 presets), identifying itself with the `Digitraffic-User` header the + * service asks for. One PRESET — one fixed view of a station — is one camera + * here; the presets of a station share its position, and the id-hash fallback + * heading fans their gizmos apart instead of stacking them on one bearing. + * + * Skips stations whose `collectionStatus` is anything but GATHERING and presets + * with `inCollection: false`, so the mesh carries no dead cameras. Frame URLs + * are BUILT from the official image origin and a strictly-validated preset id + * rather than read from the payload, which pins the frame proxy to + * weathercam.digitraffic.fi by construction; the catalog fetch refuses + * redirects (`redirect: 'manual'`) so the list host cannot be steered either. + * + * No compass heading exists anywhere in this dataset: the per-preset + * `direction` on the detail endpoint is road-register relative + * (INCREASING_DIRECTION = "towards higher road addresses"), not a bearing, and + * converting it would need road geometry this app does not load. Every preset + * therefore takes the id-hash fallback and the low-confidence pose personality, + * the same as headingless Austin and TfL cameras. + * + * Attribution: "Fintraffic / digitraffic.fi" (CC BY 4.0), registered in + * src/data/dataCredits.js. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadFintrafficSourcesFromOpenData() { + try { + const resp = await fetch(FINTRAFFIC_STATIONS_URL, { + headers: { + Accept: 'application/json', + 'Accept-Encoding': 'gzip', + 'Digitraffic-User': DIGITRAFFIC_USER, + }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Fintraffic station list redirected; redirects are not followed', + ); + return []; + } + if (!resp.ok) { + console.warn('[CCTV] Fintraffic station download failed:', resp.status); + return []; + } + const payload = await resp.json(); + const features = Array.isArray(payload?.features) ? payload.features : []; + if (!features.length) return []; + + const cameras = []; + let stationsSeen = 0; + for (const feature of features) { + const props = feature?.properties || {}; + const stationId = String(props.id || '').trim(); + if (!stationId) continue; + // GATHERING is the only status that means "this station is collecting + // images right now"; REMOVED_TEMPORARILY and friends would render as + // permanent Street View / synthetic fallbacks. + if (String(props.collectionStatus || '').toUpperCase() !== 'GATHERING') + continue; + + const coords = feature?.geometry?.coordinates; + const lon = toFiniteNumber(coords?.[0]); + const lat = toFiniteNumber(coords?.[1]); + if (!isLikelyFinlandCoordinate(lat, lon)) continue; + // Third coordinate is metres, but 0 means "not reported" rather than sea + // level, so only a positive value is a real reading. Prior only: the + // client's one-shot ground snap corrects it on 3D-tile stacks, and on a + // no-tileset stack this height is what freezes in. + const reportedElevation = toFiniteNumber(coords?.[2], 0); + const groundElevationM = + reportedElevation > 0 + ? Math.min(1400, reportedElevation) + : FINTRAFFIC_GROUND_ELEVATION_M; + + stationsSeen += 1; + for (const preset of props.presets || []) { + if (preset?.inCollection !== true) continue; + const presetId = String(preset?.id || '').trim(); + // Strict id shape (station id + two-digit view). Also the guard that + // keeps a hostile id out of the synthesized frame URL's path. + if (!/^C\d{7}$/.test(presetId)) continue; + if (!presetId.startsWith(stationId)) continue; + + const cameraId = `fi-${presetId.toLowerCase()}`; + const imageUrl = `${FINTRAFFIC_IMAGE_ORIGIN}${presetId}.jpg`; + cameras.push({ + id: cameraId, + name: fintrafficCameraName(props.name, stationId, presetId), + city: 'Finland', + cityId: 'finland', + provider: 'Fintraffic', + lat, + lon, + // Headingless personality (see JSDoc), identical to TfL's. + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'fintraffic-open-data', + license: 'Fintraffic / digitraffic.fi (CC BY 4.0)', + }); + } + } + + const maxRaw = Number( + process.env.CCTV_FINTRAFFIC_MAX_SOURCES || DEFAULT_FINTRAFFIC_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_FINTRAFFIC_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, FINLAND_ANCHORS); + console.log( + `[CCTV] Loaded Fintraffic camera sources: ${cameras.length} live presets across ${stationsSeen} stations (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Fintraffic station download error:', + error?.message || error, + ); + return []; + } +} + +/** + * The DriveBC `credit` field mixes third-party image attribution ("Images + * courtesy of TransLink") with operational notes ("relies on solar power"). + * Only the attribution kind is carried onto the camera, HTML stripped, so a + * partner-owned feed names its owner in the panel; everything else is dropped. + * + * @param {unknown} raw + * @returns {string} + */ +export function driveBcImageCredit(raw) { + const text = String(raw || '') + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return ''; + return /courtesy|provided by|presented in cooperation|city of|parks canada/i.test( + text, + ) + ? text + : ''; +} + +/** DriveBC orientation codes (the eight compass points) as headings in degrees. */ +const DRIVEBC_ORIENTATION_HEADINGS = Object.freeze({ + N: 0, + NE: 45, + E: 90, + SE: 135, + S: 180, + SW: 225, + W: 270, + NW: 315, +}); + +/** + * Fetch DriveBC highway cameras (British Columbia). Keyless: one list endpoint + * served by the DriveBC.ca site. Only cameras that are switched on and published + * (`is_on` and `should_appear`) with a positive integer id and finite coordinates + * are kept. Frame URLs are built from that id on the official image host and are + * never read from the payload. Orientation codes give a high-confidence heading; + * `elevation` is metres above sea level. Attribution: Open Government Licence – + * British Columbia (registered in src/data/dataCredits.js). + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadDriveBcSourcesFromOpenData() { + try { + const resp = await fetch(DRIVEBC_WEBCAMS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] DriveBC camera download failed:', resp.status); + return []; + } + const rows = await resp.json(); + if (!Array.isArray(rows)) return []; + + const cameras = []; + for (const row of rows) { + if (row?.is_on !== true || row?.should_appear !== true) continue; + if (!Number.isSafeInteger(row.id) || row.id <= 0) continue; + // GeoJSON point order: [longitude, latitude]. + const [lon, lat] = Array.isArray(row.location?.coordinates) + ? row.location.coordinates + : []; + if (!isLikelyBcCoordinate(lat, lon)) continue; + + const cameraId = `drivebc-${row.id}`; + const heading = + DRIVEBC_ORIENTATION_HEADINGS[ + String(row.orientation || '') + .trim() + .toUpperCase() + ]; + const hasHeading = Number.isFinite(heading); + const region = String(row.region_name || '').trim(); + const imageUrl = DRIVEBC_IMAGE_URL(row.id); + const credit = driveBcImageCredit(row.credit); + cameras.push({ + id: cameraId, + name: String(row.name || '').trim() || `DriveBC camera ${row.id}`, + // DriveBC regions: Lower Mainland, Vancouver Island, Southern Interior, + // Northern, and "Border Cams" for the US crossings. + city: + region === 'Border Cams' ? 'BC Border' : region || 'British Columbia', + cityId: 'british-columbia', + provider: 'DriveBC', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Same two pose personalities as the other packs: raw priors that the + // client's ground snap and manual calibration refine. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + // Clamped like Caltrans so a garbage value can't fling a camera + // kilometres up; sea level is the prior for the coastal default anchors. + groundElevationM: Number.isFinite(row.elevation) + ? Math.max(-100, Math.min(4000, row.elevation)) + : 0, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'drivebc-open-data', + license: 'DriveBC, Open Government Licence – British Columbia', + credit, + }); + } + + const maxRaw = Number( + process.env.CCTV_DRIVEBC_MAX_SOURCES || DEFAULT_DRIVEBC_MAX_SOURCES, + ); + // Up to the catalog ceiling, so a BC-only setup can load the whole province. + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_DRIVEBC_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, DRIVEBC_ANCHORS); + console.log( + `[CCTV] Loaded DriveBC camera sources: ${cameras.length} published (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] DriveBC camera download error:', + error?.message || error, + ); + return []; + } +} + +/** + * Normalize one TxDOT district catalog into camera source objects. Split out + * from the fetch so the shape handling is unit-testable without a network. + * + * The payload nests cameras under `roadwayCctvStatuses`, keyed by roadway. + * Only `Device Online` rows register: an offline TxDOT device keeps serving a + * stale frame that can be years old and would otherwise look live. + * + * @param {object} payload - Parsed GetCctvStatusListByDistrict response. + * @param {string} district - TxDOT district code, e.g. 'AUS'. + * @returns {Array} Normalized camera source objects. + */ +export function normalizeTxdotDistrictPayload(payload, district) { + const byRoadway = payload?.roadwayCctvStatuses; + if (!byRoadway || typeof byRoadway !== 'object') return []; + const code = String(district || '').toUpperCase(); + const groundElevationM = + TXDOT_DISTRICT_ELEVATION_M[code] ?? TXDOT_DEFAULT_ELEVATION_M; + const cameras = []; + const seen = new Set(); + + for (const rows of Object.values(byRoadway)) { + if (!Array.isArray(rows)) continue; + for (const row of rows) { + if (String(row?.statusDescription || '') !== 'Device Online') continue; + if (row?.hasSnapshot === false) continue; + // Coordinates must be present as numbers: Number(null) and Number('') + // are both 0, which would silently park a camera on null island. + const lat = typeof row?.latitude === 'number' ? row.latitude : NaN; + const lon = typeof row?.longitude === 'number' ? row.longitude : NaN; + if (!isLikelyTexasCoordinate(lat, lon)) continue; + + // icd_Id is the device key the snapshot endpoint takes and is unique + // within a district. An interchange camera appears under both of its + // roadways, so dedupe on it. + const icdId = String(row?.icd_Id || '').trim(); + if (!icdId || seen.has(icdId)) continue; + seen.add(icdId); + + const name = String(row?.name || icdId).trim(); + // Heading comes from an explicit travel token in the NAME ("US-290 EB"), + // parsed in strict mode: bare cardinals are refused because Texas route + // names are full of them ("N Lamar", "West Ave"). Deliberately NOT from + // row.dirDescription / equipLoc.direction: that is the ROADWAY's + // canonical direction, not the camera's facing (it reads "North" for + // most Austin rows, including every east-west highway). + const heading = directionToHeading(name, false); + const hasHeading = Number.isFinite(heading); + // The device key itself, base64url-encoded, so every distinct key gets + // a distinct id (a hash or a slug can collide) and the key is + // recoverable from the id. + const cameraId = `txdot-${code.toLowerCase()}-${Buffer.from(icdId, 'utf8').toString('base64url')}`; + const snapshot = new URL(TXDOT_CCTV_SNAPSHOT_URL); + snapshot.searchParams.set('icdId', icdId); + snapshot.searchParams.set('districtCode', code); + + cameras.push({ + id: cameraId, + name, + city: String(row?.equipLoc?.roadway || code), + cityId: `tx-${code.toLowerCase()}`, + provider: 'TxDOT', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Same two pose personalities as the other packs: raw priors the + // client's ground snap and manual calibration refine. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + // TxDOT mounts run tall on highway poles and mast arms. + mountHeightM: hasHeading ? 12 : 10, + groundElevationM, + feedType: 'image', + // JSON carrying a base64 JPEG; media.js decodes it for this origin only. + url: snapshot.toString(), + snapshotUrl: snapshot.toString(), + sourceKind: 'txdot-its', + license: 'Public TxDOT traffic camera data', + code: cameraDisplayCode(icdId.toUpperCase()), + }); + } + } + return cameras; +} + +/** + * Fetch TxDOT ITS highway cameras (Texas), keyless. Districts come from + * CCTV_TXDOT_DISTRICTS (comma-separated codes; empty string disables the + * pack). One official JSON catalog per district, identical schema statewide; + * districts fetch in parallel and fail independently. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadTxdotSourcesFromOpenData() { + const districtsRaw = + process.env.CCTV_TXDOT_DISTRICTS ?? DEFAULT_TXDOT_DISTRICTS; + const districts = [ + ...new Set( + String(districtsRaw) + .split(',') + .map((token) => token.trim().toUpperCase()) + .filter((code) => TXDOT_DISTRICTS.has(code)), + ), + ]; + if (!districts.length) return []; + + const settled = await Promise.allSettled( + districts.map(async (district) => { + const resp = await fetch(TXDOT_CCTV_STATUS_URL(district), { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`${district} HTTP ${resp.status}`); + return { district, payload: await resp.json() }; + }), + ); + + const cameras = []; + for (const result of settled) { + if (result.status !== 'fulfilled') { + console.warn( + '[CCTV] TxDOT district fetch failed:', + result.reason?.message || result.reason, + ); + continue; + } + cameras.push( + ...normalizeTxdotDistrictPayload( + result.value.payload, + result.value.district, + ), + ); + } + + const maxRaw = Number( + process.env.CCTV_TXDOT_MAX_SOURCES || DEFAULT_TXDOT_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(2000, Math.floor(maxRaw))) + : DEFAULT_TXDOT_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, TXDOT_ANCHORS); + console.log( + `[CCTV] Loaded TxDOT camera sources: ${cameras.length} online across ${districts.join(',')} (using nearest ${prioritized.length})`, + ); + return prioritized; +} + +/** + * Load Tallinn intersection cameras from the curated catalog file. + * + * Frames are public JPEG stills on ristmikud.tallinn.ee (stable /last/camNNN.jpg + * URLs). The catalog ships coordinates + curated heading priors; only official + * ristmikud HTTPS URLs are kept (proxy fetches registered URLs only). + * + * @returns {Array} Normalized camera source objects. + */ +export function loadTallinnSourcesFromCatalog({ + sourceRoot = process.cwd(), +} = {}) { + const sourceFile = + process.env.CCTV_TALLINN_SOURCES_FILE || DEFAULT_TALLINN_SOURCE_FILE; + const resolved = path.isAbsolute(sourceFile) + ? sourceFile + : path.resolve(sourceRoot, sourceFile); + let rows = []; + try { + if (!fs.existsSync(resolved)) { + console.warn('[CCTV] Tallinn source file missing:', resolved); + return []; + } + const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8')); + rows = Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.warn( + '[CCTV] Tallinn source file read error:', + error?.message || error, + ); + return []; + } + + const cameras = []; + for (const item of rows) { + if (!item || typeof item !== 'object') continue; + const cameraId = + typeof item.id === 'string' || typeof item.id === 'number' + ? String(item.id).trim() + : ''; + if (!cameraId) continue; + const lat = typeof item.lat === 'number' ? item.lat : NaN; + const lon = typeof item.lon === 'number' ? item.lon : NaN; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + // Rough Estonia/Tallinn metro sanity (allows nearby suburbs already in the pack). + if (lat < 59.2 || lat > 59.7 || lon < 24.3 || lon > 25.4) continue; + + const imageUrl = + typeof item.url === 'string' + ? item.url.trim() + : typeof item.snapshotUrl === 'string' + ? item.snapshotUrl.trim() + : ''; + if (!imageUrl.startsWith(TALLINN_IMAGE_ORIGIN)) continue; + + const extractedHeading = toFiniteNumber(item.headingDeg, NaN); + const hasHeading = Number.isFinite(extractedHeading); + const headingDeg = hasHeading + ? ((extractedHeading % 360) + 360) % 360 + : fallbackHeadingFromId(cameraId); + const headingConfidence = hasHeading + ? String(item.headingConfidence || '').toLowerCase() === 'low' + ? 'low' + : 'high' + : 'low'; + cameras.push({ + id: cameraId, + name: String(item.name || cameraId).trim(), + city: 'Tallinn', + cityId: 'tallinn', + provider: 'City of Tallinn', + lat, + lon, + headingDeg, + headingConfidence, + pitchDeg: headingConfidence === 'high' ? -24 : -18, + fovDeg: headingConfidence === 'high' ? 56 : 44, + rangeM: headingConfidence === 'high' ? 210 : 145, + mountHeightM: headingConfidence === 'high' ? 10 : 8, + groundElevationM: toFiniteNumber(item.groundElevationM, 15), + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'tallinn-ristmikud', + license: 'Public City of Tallinn traffic camera data', + poseSource: hasHeading ? 'curated' : undefined, + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_TALLINN_MAX_SOURCES || DEFAULT_TALLINN_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(300, Math.floor(maxRaw))) + : DEFAULT_TALLINN_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, [TALLINN_CENTER]); + console.log( + `[CCTV] Loaded Tallinn camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; +} + +/** + * Extract DATEX2 predefined-location id → {name, lat, lon} from Tarktee XML. + * + * @param {string} xml + * @returns {Map} + */ +export function parseTarkteeDatexLocations(xml) { + const out = new Map(); + const blockRe = + /]*>([\s\S]*?)<\/predefinedLocation>/g; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const id = match[1]; + const body = match[2]; + // Skip the group container (no coordinates of its own). + const latMatch = /\s*(-?\d+(?:\.\d+)?)\s*<\/latitude>/i.exec( + body, + ); + const lonMatch = /\s*(-?\d+(?:\.\d+)?)\s*<\/longitude>/i.exec( + body, + ); + if (!latMatch || !lonMatch) continue; + const lat = toFiniteNumber(latMatch[1]); + const lon = toFiniteNumber(lonMatch[1]); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const nameMatch = /]*>\s*([^<]+?)\s*<\/value>/i.exec(body); + const name = nameMatch ? nameMatch[1].trim() : id; + out.set(id, { name, lat, lon }); + } + return out; +} + +/** + * Extract DATEX2 traffic-view location id → HTTPS image URL from Tarktee XML. + * + * @param {string} xml + * @returns {Map} + */ +export function parseTarkteeDatexImages(xml) { + const out = new Map(); + const blockRe = /]*>([\s\S]*?)<\/trafficView>/g; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const body = match[1]; + const refMatch = + /]*\bid="([^"]+)"/i.exec(body); + const urlMatch = /\s*([^<\s]+)\s*<\/urlLinkAddress>/i.exec( + body, + ); + if (!refMatch || !urlMatch) continue; + const url = urlMatch[1].trim(); + if (!url.startsWith(TARKTEE_IMAGE_ORIGIN)) continue; + out.set(refMatch[1], url); + } + return out; +} + +/** + * Fetch Estonian Transpordiamet / Tarktee road-weather cameras via DATEX2. + * + * Locations and current still URLs are keyless public feeds. Image paths on the + * ArcGIS MapServer layer go stale; DATEX always carries the current JPEG URL. + * Only https://tarktee.transpordiamet.ee/images/… URLs are registered. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadTarkteeSourcesFromDatex() { + try { + const [locResp, imgResp] = await Promise.all([ + fetch(TARKTEE_LOCATIONS_URL, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }), + fetch(TARKTEE_IMAGES_URL, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }), + ]); + if (!locResp.ok) { + console.warn('[CCTV] Tarktee locations download failed:', locResp.status); + return []; + } + if (!imgResp.ok) { + console.warn('[CCTV] Tarktee images download failed:', imgResp.status); + return []; + } + const [locXml, imgXml] = await Promise.all([ + locResp.text(), + imgResp.text(), + ]); + const locations = parseTarkteeDatexLocations(locXml); + const images = parseTarkteeDatexImages(imgXml); + if (!locations.size || !images.size) { + console.warn('[CCTV] Tarktee DATEX parse empty:', { + locations: locations.size, + images: images.size, + }); + return []; + } + + const cameras = []; + for (const [locationId, loc] of locations.entries()) { + const imageUrl = images.get(locationId); + if (!imageUrl) continue; + // Estonia bounding box (mainland + nearby islands). + if (loc.lat < 57.4 || loc.lat > 59.9 || loc.lon < 21.5 || loc.lon > 28.4) + continue; + + const numMatch = /\/images\/(\d+)\//.exec(imageUrl); + const cameraId = numMatch + ? `ee-tarktee-${numMatch[1]}` + : `ee-tarktee-${locationId}`; + cameras.push({ + id: cameraId, + name: loc.name, + city: loc.name, + cityId: 'estonia', + provider: 'Transpordiamet (Tarktee)', + lat: loc.lat, + lon: loc.lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: 40, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'tarktee-datex', + license: 'Public Transpordiamet / Tarktee road weather camera data', + }); + } + + const maxRaw = Number( + process.env.CCTV_TARKTEE_MAX_SOURCES || DEFAULT_TARKTEE_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(300, Math.floor(maxRaw))) + : DEFAULT_TARKTEE_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, TARKTEE_ANCHORS); + console.log( + `[CCTV] Loaded Tarktee camera sources: ${cameras.length} with images (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Tarktee DATEX download error:', + error?.message || error, + ); + return []; + } +} + +/** + * Load the Warendorf municipal webcams (Stadt Warendorf Marktplatz, Kreis + * Warendorf registration offices) from the curated catalog file. Poses are + * curated; only the two official municipal image hosts are registered. + * + * @returns {Array} Normalized camera source objects. + */ +export function loadWarendorfSourcesFromCatalog({ + sourceRoot = process.cwd(), +} = {}) { + const sourceFile = + process.env.CCTV_WARENDORF_SOURCES_FILE || DEFAULT_WARENDORF_SOURCE_FILE; + const resolved = path.isAbsolute(sourceFile) + ? sourceFile + : path.resolve(sourceRoot, sourceFile); + let rows = []; + try { + if (!fs.existsSync(resolved)) { + console.warn('[CCTV] Warendorf source file missing:', resolved); + return []; + } + const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8')); + rows = Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.warn( + '[CCTV] Warendorf source file read error:', + error?.message || error, + ); + return []; + } + const cameras = []; + for (const item of rows) { + if (!item || typeof item !== 'object') continue; + const id = typeof item.id === 'string' ? item.id.trim() : ''; + const url = + typeof item.url === 'string' + ? item.url.trim() + : typeof item.snapshotUrl === 'string' + ? item.snapshotUrl.trim() + : ''; + if (!id || !WARENDORF_IMAGE_ORIGINS.some((o) => url.startsWith(o))) + continue; + const lat = typeof item.lat === 'number' ? item.lat : NaN; + const lon = typeof item.lon === 'number' ? item.lon : NaN; + if (!isPlausibleLatLon(lat, lon)) continue; + cameras.push({ + ...item, + id, + url, + snapshotUrl: url, + cityId: String(item.cityId || 'warendorf'), + feedType: 'image', + sourceKind: 'municipal-webcam', + }); + } + console.log('[CCTV] Loaded Warendorf camera sources:', cameras.length); + return cameras; +} + +/** + * Label for one NSW camera: its `view` sentence when that is really a view + * ("5 Ways at The Boulevarde looking west towards Sutherland"), else the + * title ("5 Ways (Miranda)"). A works notice longer than NSW_MAX_VIEW_LABEL or + * containing a line break is not a label. + * + * @param {{view?:string, title?:string}} props + * @returns {string} + */ +export function nswCameraLabel(props) { + const view = String(props?.view || '').trim(); + const title = String(props?.title || '').trim(); + const viewIsALabel = + view.length > 0 && + view.length <= NSW_MAX_VIEW_LABEL && + !/[\r\n]/.test(view); + return viewIsALabel ? view : title; +} + +/** + * One Live Traffic NSW camera feature -> one catalog source, or null. Every + * camera carries a compass `direction` ("N-E") and a `view` sentence. + * + * @param {object} feature - GeoJSON feature from the traffic-cam feed. + * @returns {?object} + */ +export function nswCameraToSource(feature) { + const rawId = String(feature?.id || '').trim(); + if (!rawId) return null; + const coords = feature?.geometry?.coordinates; + // Numbers only: Number(null) and Number('') are 0, which would park a + // camera on the equator. + const lon = typeof coords?.[0] === 'number' ? coords[0] : NaN; + const lat = typeof coords?.[1] === 'number' ? coords[1] : NaN; + if (!isLikelyNswCoordinate(lat, lon)) return null; + const props = feature?.properties || {}; + const url = String(props.href || '').trim(); + if (!url.startsWith(NSW_IMAGE_ORIGIN)) return null; + // "N-E" -> "NE" for the compass lookup. + const direction = String(props.direction || '') + .trim() + .toUpperCase() + .replace(/-/g, ''); + const heading = directionToHeading(direction, true); + const hasHeading = Number.isFinite(heading); + const cameraId = `nsw-${rawId}`; + return { + id: cameraId, + name: nswCameraLabel(props) || `NSW ${rawId}`, + city: String(props.region || 'New South Wales').replace(/_/g, ' '), + cityId: 'nsw', + provider: 'Live Traffic NSW', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: 25, // Sydney basin prior; the client's ground snap corrects. + feedType: 'image', + url, + snapshotUrl: url, + sourceKind: 'nsw-livetraffic', + license: 'Live Traffic NSW — Transport for NSW, CC BY 4.0', + code: cameraDisplayCode(String(props.title || '').toUpperCase() || rawId), + }; +} + +/** + * Fetch Live Traffic NSW cameras (New South Wales), keyless: the public + * traffic-cam GeoJSON feed. Frames are stills on webcams.transport.nsw.gov.au. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadNswSourcesFromOpenData() { + try { + const resp = await fetch(NSW_CAMERAS_URL, { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] NSW camera download failed:', resp.status); + return []; + } + const body = await resp.json(); + const features = Array.isArray(body?.features) ? body.features : []; + const cameras = features.map(nswCameraToSource).filter(Boolean); + const maxRaw = Number( + process.env.CCTV_NSW_MAX_SOURCES || DEFAULT_NSW_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(900, Math.floor(maxRaw))) + : DEFAULT_NSW_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, [SYDNEY_CENTER]); + console.log( + `[CCTV] Loaded NSW camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] NSW camera download error:', error?.message || error); + return []; + } +} diff --git a/server/providers/common/geo.js b/server/providers/common/geo.js new file mode 100644 index 000000000..1b7a9d85b --- /dev/null +++ b/server/providers/common/geo.js @@ -0,0 +1,18 @@ +/** + * Haversine great-circle distance between two WGS-84 points. + * + * @param {number} lat1 - Latitude of point A (degrees). + * @param {number} lon1 - Longitude of point A (degrees). + * @param {number} lat2 - Latitude of point B (degrees). + * @param {number} lon2 - Longitude of point B (degrees). + * @returns {number} Distance in kilometers. + */ +export function haversineKm(lat1, lon1, lat2, lon2) { + const toRad = (value) => (value * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/server/providers/common/http.js b/server/providers/common/http.js new file mode 100644 index 000000000..561d19cf8 --- /dev/null +++ b/server/providers/common/http.js @@ -0,0 +1,118 @@ +/** + * Read a fetch() Response body as text with a hard byte cap. Rejects early on an + * oversized Content-Length, then streams with a running cap so a chunked or + * length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }. + */ +export async function readResponseTextCapped(response, maxBytes, signal) { + const tooLarge = () => + Object.assign(new Error('Upstream response too large'), { + code: 'RESPONSE_TOO_LARGE', + }); + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + void response.body?.cancel().catch(() => {}); + throw tooLarge(); + } + signal?.throwIfAborted(); + const reader = response.body?.getReader?.(); + if (!reader) { + const text = await response.text(); + signal?.throwIfAborted(); + if (Buffer.byteLength(text) > maxBytes) throw tooLarge(); + return text; + } + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener('abort', cancel, { once: true }); + const decoder = new TextDecoder(); + let out = ''; + let total = 0; + try { + for (;;) { + signal?.throwIfAborted(); + const { done, value } = await reader.read(); + signal?.throwIfAborted(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw tooLarge(); + out += decoder.decode(value, { stream: true }); + } + return out + decoder.decode(); + } catch (error) { + cancel(); + throw error; + } finally { + signal?.removeEventListener('abort', cancel); + reader.releaseLock(); + } +} + +/** Parse a fetch() JSON response only after enforcing a hard byte cap. */ +export async function readResponseJsonCapped(response, maxBytes) { + return JSON.parse(await readResponseTextCapped(response, maxBytes)); +} + +/** + * Return the existing promise for a cache key, or create one and remove it + * only when that exact promise settles. + */ +export function coalesceProxyRequest(inFlight, key, create) { + const existing = inFlight.get(key); + if (existing) return { promise: existing, shared: true }; + let promise; + promise = Promise.resolve() + .then(create) + .finally(() => { + if (inFlight.get(key) === promise) inFlight.delete(key); + }); + inFlight.set(key, promise); + return { promise, shared: false }; +} + +/** + * Read a fetch Response body as text while enforcing a hard byte cap during + * the read — so a malicious or buggy upstream that streams an unbounded body + * (no/oversized Content-Length, chunked) can't OOM the proxy. Returns + * { tooLarge, text }. Cancels the stream as soon as the cap is crossed. + * @param {Response} upstream - fetch() response. + * @param {number} maxBytes - hard ceiling on decoded bytes. + * @returns {Promise<{tooLarge: boolean, text: string}>} + */ +export async function readCappedResponseText(upstream, maxBytes) { + const declared = Number(upstream.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } + return { tooLarge: true, text: '' }; + } + if ( + !upstream.body || + typeof upstream.body[Symbol.asyncIterator] !== 'function' + ) { + const text = await upstream.text(); + return text.length > maxBytes + ? { tooLarge: true, text: '' } + : { tooLarge: false, text }; + } + const decoder = new TextDecoder(); + let text = ''; + let total = 0; + for await (const chunk of upstream.body) { + total += chunk.length; + if (total > maxBytes) { + try { + await upstream.body.cancel(); + } catch { + /* no-op */ + } + return { tooLarge: true, text: '' }; + } + text += decoder.decode(chunk, { stream: true }); + } + text += decoder.decode(); + return { tooLarge: false, text }; +} diff --git a/server/providers/common/query.js b/server/providers/common/query.js new file mode 100644 index 000000000..9e23d2354 --- /dev/null +++ b/server/providers/common/query.js @@ -0,0 +1,12 @@ +export function requiredFiniteQueryNumber(params, key) { + const value = params.get(key); + if (value === null || value.trim() === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +export function clampInt(value, min, max, fallback) { + const number = Number.parseInt(value, 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, number)); +} diff --git a/server/providers/common/rate-limit.js b/server/providers/common/rate-limit.js new file mode 100644 index 000000000..3d7c0b9f8 --- /dev/null +++ b/server/providers/common/rate-limit.js @@ -0,0 +1,67 @@ +/** + * Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard + * security boundary (dev-only), just a backstop so a runaway client can't hammer + * the public Overpass / OSRM mirrors or exhaust this process. + */ +const RATE_LIMITER_MAX_KEYS = 2000; + +export function makeRateLimiter({ windowMs, max, globalMax }) { + const hits = new Map(); // key -> number[] (timestamps within window) + let globalTimes = []; // all hits in window, for the global backstop + return function allow(key) { + const now = Date.now(); + globalTimes = globalTimes.filter((t) => now - t < windowMs); + if (globalMax && globalTimes.length >= globalMax) return false; // global backstop + const recent = (hits.get(key) || []).filter((t) => now - t < windowMs); + if (recent.length >= max) { + hits.set(key, recent); + return false; + } + recent.push(now); + hits.set(key, recent); + globalTimes.push(now); + // Hard key cap so a key-rotating caller can't grow the map without bound. + if (hits.size > RATE_LIMITER_MAX_KEYS) { + const oldest = hits.keys().next().value; + if (oldest !== undefined) hits.delete(oldest); + } + if (hits.size > 256) { + for (const [k, v] of hits) { + if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k); + } + } + return true; + }; +} + +/** + * Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google). + * DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this + * returns `null` and the caller skips the check entirely — a runtime no-op that + * preserves the original behavior. Only a positive integer N enables a fixed + * 60s window of N requests/IP (built lazily once, then reused so its per-IP + * window state persists across requests). The global backstop is set to a + * generous multiple of the per-IP cap so a single host can't starve the rest. + * + * @param {string|undefined} envValue - Raw env value (requests/min/IP). + * @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited. + */ +export function makeOptInRateLimiter(envValue) { + const max = Number(envValue); + if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited + return makeRateLimiter({ + windowMs: 60_000, + max: Math.floor(max), + globalMax: Math.floor(max) * 20, + }); +} + +/** + * Client key for rate limiting. Uses the real socket peer address only — we do + * NOT trust X-Forwarded-For (client-controlled; a rotating value would mint fresh + * quota and grow the limiter map). This is a localhost dev proxy, so the socket + * address is the real client. + */ +export function clientKey(req) { + return String(req.socket?.remoteAddress || 'local'); +} diff --git a/server/providers/common/request.js b/server/providers/common/request.js new file mode 100644 index 000000000..ab261d779 --- /dev/null +++ b/server/providers/common/request.js @@ -0,0 +1,35 @@ +/** Read a request body with a hard byte cap; throws { code:'BODY_TOO_LARGE' } past the cap. */ +async function readRequestBodyCapped(req, maxBytes) { + const chunks = []; + let total = 0; + for await (const chunk of req) { + total += chunk.length; + if (total > maxBytes) { + const err = new Error('Request body too large'); + err.code = 'BODY_TOO_LARGE'; + throw err; + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function readRequestBody(req, maxBytes = 1024 * 1024) { + return new Promise((resolve, reject) => { + let total = 0; + const chunks = []; + req.on('data', (chunk) => { + total += chunk.length; + if (total > maxBytes) { + reject(new Error(`Request body exceeds ${maxBytes} bytes`)); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +export { readRequestBodyCapped, readRequestBody }; diff --git a/server/providers/common/source-root.js b/server/providers/common/source-root.js new file mode 100644 index 000000000..803466cb4 --- /dev/null +++ b/server/providers/common/source-root.js @@ -0,0 +1,10 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Resolve defaultSourceRoot for ESM context. */ +const defaultSourceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..', +); + +export { defaultSourceRoot }; diff --git a/server/providers/firms.js b/server/providers/firms.js new file mode 100644 index 000000000..3a4c5ea9f --- /dev/null +++ b/server/providers/firms.js @@ -0,0 +1,261 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; + +import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; + +/** + * NASA FIRMS live active-fire proxy with a memory + disk cache. + * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 + * + * Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent + * satellites, no cross-source dedup) fetched sequentially with `days=2` + * (`days=1` means "current UTC day", nearly empty just after 00:00Z) and + * clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS quota is + * 5,000 transactions / 10 min per MAP_KEY, so the cache is the point: + * TTL 30 min, single-flight refresh, serve-stale-on-failure, and a + * fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream + * fetch across dev-server restarts. Pattern mirrors celestrakProxy. + * + * Routes: + * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} + * GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions} + * + * Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status → + * {hasKey:false}. Upstream is never touched without a key. + * + * @returns {import('vite').Plugin} + */ +export function firmsProxy() { + const TTL_MS = 30 * 60_000; + const STATUS_TTL_MS = 5 * 60_000; + const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT']; + const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const CACHE_PATH = path.join(CACHE_DIR, 'firms.json'); + + /** @type {?{at: number, sources: Array, fires: Array}} */ + let mem = null; + let diskChecked = false; + /** @type {?Promise, fires: Array}>} single-flight refresh */ + let inflight = null; + /** @type {?{at: number, transactions: ?{used: number, limit: number}}} mapkey_status cache */ + let statusCache = null; + /** @type {?Promise} */ + let statusInflight = null; + + const mapKey = () => String(process.env.FIRMS_MAP_KEY || '').trim(); + + async function readDiskOnce() { + if (diskChecked) return; + diskChecked = true; + try { + const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); + if ( + Number.isFinite(parsed?.at) && + Array.isArray(parsed?.sources) && + Array.isArray(parsed?.fires) + ) { + mem = parsed; + } + } catch { + /* no disk cache yet */ + } + } + + async function writeDisk(entry) { + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + await fsp.writeFile(CACHE_PATH, JSON.stringify(entry), 'utf8'); + } catch (err) { + console.warn('[firms-proxy] cache write failed:', err?.message || err); + } + } + + /** + * Fetch + parse one FIRMS source. Throws on HTTP error or a non-CSV body + * (FIRMS reports errors as HTML/plain text, never CSV). Never log the URL — + * it embeds the MAP_KEY. + */ + async function fetchSource(key, source) { + const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; + const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const records = parseFirmsCsv(await res.text()); + if (records === null) throw new Error('non-CSV upstream response'); + return records; + } + + /** + * Refresh all sources sequentially (quota courtesy — never in parallel). + * Partial success (≥1 source ok) still produces a cacheable entry with the + * failed sources marked ok:false; total failure throws so the caller can + * serve stale. + */ + async function refreshUpstream(key) { + const now = Date.now(); + const sources = []; + const fires = []; + for (const source of SOURCES) { + try { + const records = filterTrailing24h(await fetchSource(key, source), now); + // NOT fires.push(...records): spread passes each record as an argument, + // and a world/2 VIIRS pull exceeds V8's argument limit (~125k) at + // ~131k records — RangeError, and the whole source is silently dropped. + for (const record of records) fires.push(record); + sources.push({ source, count: records.length, ok: true }); + } catch (err) { + console.warn( + `[firms-proxy] ${source} fetch failed:`, + err?.message || err, + ); + sources.push({ source, count: 0, ok: false }); + } + } + if (!sources.some((s) => s.ok)) throw new Error('all FIRMS sources failed'); + return { at: now, sources, fires }; + } + + /** + * Cache entry → response payload. Fires are RE-filtered to the trailing + * 24 h at serve time so a stale cache never serves >24h-old detections. + */ + function buildPayload(entry, stale) { + const fires = filterTrailing24h(entry.fires, Date.now()); + return { + fetchedAt: entry.at, + stale, + ttlMs: TTL_MS, + sources: entry.sources, + count: fires.length, + fires, + }; + } + + /** mapkey_status transactions, cached 5 min, best-effort (null on failure). */ + function getTransactions(key) { + const now = Date.now(); + if (statusCache && now - statusCache.at < STATUS_TTL_MS) { + return Promise.resolve(statusCache.transactions); + } + if (!statusInflight) { + statusInflight = (async () => { + try { + const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`; + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + const used = Number(body?.current_transactions); + const limit = Number(body?.transaction_limit); + return Number.isFinite(used) && Number.isFinite(limit) + ? { used, limit } + : null; + } catch (err) { + console.warn( + '[firms-proxy] mapkey status failed:', + err?.message || err, + ); + return null; + } + })() + .then((transactions) => { + statusCache = { at: Date.now(), transactions }; + return transactions; + }) + .finally(() => { + statusInflight = null; + }); + } + return statusInflight; + } + + const installMiddleware = (server) => { + server.middlewares.use('/api/firms', async (req, res) => { + const sendJson = (status, obj) => { + if (res.headersSent) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(obj)); + }; + try { + const subPath = String(req.url || '').split('?')[0]; + const key = mapKey(); + await readDiskOnce(); + + if (subPath === '/status') { + if (!key) { + sendJson(200, { + hasKey: false, + lastFetch: null, + count: null, + stale: false, + ttlMs: TTL_MS, + transactions: null, + }); + return; + } + const transactions = await getTransactions(key); + sendJson(200, { + hasKey: true, + lastFetch: mem ? mem.at : null, + count: mem ? mem.fires.length : null, + stale: mem ? Date.now() - mem.at >= TTL_MS : false, + ttlMs: TTL_MS, + transactions, + }); + return; + } + + if (!key) { + sendJson(503, { error: 'no_key' }); + return; + } + + const entry = mem; + if (entry && Date.now() - entry.at < TTL_MS) { + sendJson(200, buildPayload(entry, false)); + return; + } + // Stale or missing → refresh, single-flight (concurrent requests + // share one upstream pass). Capture the promise locally BEFORE + // awaiting: the .finally() nulls `inflight` the moment it settles. + if (!inflight) { + inflight = refreshUpstream(key) + .then(async (fresh) => { + mem = fresh; + await writeDisk(fresh); + return fresh; + }) + .catch((err) => { + console.warn( + `[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`, + ); + return null; + }) + .finally(() => { + inflight = null; + }); + } + const pending = inflight; + const fresh = await pending; + if (fresh) { + sendJson(200, buildPayload(fresh, false)); + } else if (entry) { + sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty + } else { + sendJson(502, { + error: 'firms fetch failed and no cache available', + }); + } + } catch (err) { + console.warn('[firms-proxy] error:', err?.message || err); + sendJson(500, { error: 'firms proxy error' }); + } + }); + }; + return { + name: 'firms-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js new file mode 100644 index 000000000..9741bb4e6 --- /dev/null +++ b/server/providers/gbfs.js @@ -0,0 +1,249 @@ +import { readResponseTextCapped } from './common/http.js'; +import { + isAllowedGbfsHost, + isAllowedGbfsPath, + gbfsCacheControl, +} from '../../src/data/gbfsSource.js'; + +// --------------------------------------------------------------------------- +// GBFS (General Bikeshare Feed Specification) proxy constants +// --------------------------------------------------------------------------- +/** Upstream fetch timeout for GBFS requests (ms). */ +const GBFS_PROXY_TIMEOUT_MS = 12000; + +export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB + +function gbfsRedirectHost(location, requestUrl) { + if (!location) return ''; + try { + return new URL(String(location), requestUrl).hostname; + } catch { + return ''; + } +} + +/** + * Fetch one validated GBFS endpoint without following redirects and with the + * body cap enforced while the response streams. + * + * The middleware validates the target before calling this; the network step + * lives apart from it so a unit test can stand in for a hostile upstream — the + * host allowlist keeps the real handler from being pointed at a local server. + * `redirect: 'manual'` makes fetch() return a 3xx response instead of + * following it, so an allowlisted operator that redirects cannot steer the + * proxy to a destination the allowlist never saw; any 3xx is rejected with + * { code:'GBFS_REDIRECT', redirectHost }. An oversized body surfaces as + * { code:'RESPONSE_TOO_LARGE' } from readResponseTextCapped, which cancels the + * upstream read the moment the running byte count passes the cap. + * + * @param {string} url - Validated https GBFS endpoint URL. + * @param {object} [options] + * @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation. + * @param {number} [options.timeoutMs=GBFS_PROXY_TIMEOUT_MS] - Upstream abort timeout. + * @param {number} [options.maxBytes=GBFS_MAX_BODY_BYTES] - Body byte cap. + * @returns {Promise<{status:number,contentType:string,body:string}>} + */ +export async function fetchGbfsUpstream( + url, + { + fetchImpl = fetch, + timeoutMs = GBFS_PROXY_TIMEOUT_MS, + maxBytes = GBFS_MAX_BODY_BYTES, + } = {}, +) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + let upstream; + try { + upstream = await fetchImpl(url, { + method: 'GET', + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-gbfs-proxy/1.0', + }, + redirect: 'manual', + signal: controller.signal, + }); + + if (upstream.status >= 300 && upstream.status < 400) { + try { + void upstream.body?.cancel().catch(() => {}); + } catch { + /* no-op */ + } + const err = new Error( + 'GBFS upstream redirected; redirects are not followed', + ); + err.code = 'GBFS_REDIRECT'; + err.redirectHost = gbfsRedirectHost( + upstream.headers.get('location'), + url, + ); + throw err; + } + + const body = await readResponseTextCapped( + upstream, + maxBytes, + controller.signal, + ); + return { + status: upstream.status, + contentType: upstream.headers.get('content-type') || 'application/json', + body, + }; + } catch (error) { + controller.abort(); + if (upstream?.body && !upstream.body.locked) { + void upstream.body.cancel().catch(() => {}); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Vite plugin: GBFS bike-share proxy with host allowlisting and size limits. + * + * Accepts GET /api/gbfs/ and proxies the request + * to the upstream GBFS provider. Validates hostname against an allowlist, + * restricts to station_information/station_status paths, enforces HTTPS, + * and caps response body at 5 MB. + * + * @returns {import('vite').Plugin} + */ +export function gbfsProxy() { + const installMiddleware = (server) => { + server.middlewares.use('/api/gbfs', async (req, res) => { + try { + if (req.method !== 'GET') { + res.writeHead(405, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + + const url = new URL(req.url || '/', 'http://localhost'); + const encodedTarget = url.pathname.replace(/^\/+/, ''); + if (!encodedTarget) { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Missing GBFS upstream target' })); + return; + } + + let decodedTarget = ''; + try { + decodedTarget = decodeURIComponent(encodedTarget); + } catch { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' })); + return; + } + + let upstreamUrl = null; + try { + upstreamUrl = new URL(decodedTarget); + } catch { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' })); + return; + } + + if (upstreamUrl.protocol !== 'https:') { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ error: 'Only https GBFS targets are allowed' }), + ); + return; + } + + if (!isAllowedGbfsHost(upstreamUrl.hostname)) { + res.writeHead(403, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS host not allowed' })); + return; + } + + if (!isAllowedGbfsPath(upstreamUrl.pathname)) { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: + 'Only station_information/station_status endpoints are allowed', + }), + ); + return; + } + + const upstream = await fetchGbfsUpstream(upstreamUrl.toString()); + res.writeHead(upstream.status, { + 'Content-Type': upstream.contentType, + 'Cache-Control': gbfsCacheControl(upstreamUrl.pathname), + 'X-GBFS-Upstream': upstreamUrl.hostname, + 'X-GBFS-Cache': 'MISS', + }); + res.end(upstream.body); + } catch (error) { + if (error?.name === 'AbortError') { + res.writeHead(504, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS upstream timeout' })); + return; + } + if ( + error?.code === 'GBFS_REDIRECT' || + error?.code === 'RESPONSE_TOO_LARGE' + ) { + if (error.code === 'GBFS_REDIRECT') + console.warn('[GBFS Proxy] Redirect refused', error.redirectHost); + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: + error.code === 'GBFS_REDIRECT' + ? 'GBFS upstream redirect refused' + : 'GBFS upstream response too large', + }), + ); + return; + } + console.error('[GBFS Proxy]', error?.message || String(error)); + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS proxy error' })); + } + }); + }; + return { + name: 'gbfs-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/live.js b/server/providers/live.js new file mode 100644 index 000000000..3afb5b8c9 --- /dev/null +++ b/server/providers/live.js @@ -0,0 +1,12 @@ +export { + readResponseTextCapped, + readResponseJsonCapped, + coalesceProxyRequest, + readCappedResponseText, +} from './common/http.js'; +export { requiredFiniteQueryNumber, clampInt } from './common/query.js'; +export { adsbLolFallbackAnchor, openSkyProxy } from './aircraft/opensky.js'; +export { adsbLolProxy } from './aircraft/adsb-lol.js'; +export { adsbdbProxy } from './aircraft/enrichment.js'; +export { trackBackfillProxies } from './aircraft/tracks.js'; +export { aisLiveProxy } from './vessels/ais-live.js'; diff --git a/server/providers/local.js b/server/providers/local.js new file mode 100644 index 000000000..52a48f6d8 --- /dev/null +++ b/server/providers/local.js @@ -0,0 +1,89 @@ +import { openSkyProxy } from './aircraft/opensky.js'; +import { celestrakProxy, rocketLaunchesProxy } from './space.js'; +import { tomtomProxy } from './traffic.js'; +import { firmsProxy } from './firms.js'; +import { terrainHeightsProxy } from './terrain.js'; +import { adsbdbProxy } from './aircraft/enrichment.js'; +import { overpassProxy } from './overpass.js'; +import { militaryInstallationsProxy } from './military-installations.js'; +import { regionalBriefProxy } from './regional/briefing.js'; +import { weatherEffectsProxy } from './regional/weather-effects.js'; +import { cctvProxy } from './cctv.js'; +import { defaultSourceRoot } from './common/source-root.js'; +import { radioBrowserProxy } from './radio.js'; +import { gbfsProxy } from './gbfs.js'; +import { adsbLolProxy } from './aircraft/adsb-lol.js'; +import { aisLiveProxy } from './vessels/ais-live.js'; +import { trackBackfillProxies } from './aircraft/tracks.js'; +import { openAiRealtimeProxy } from './openai.js'; +import { googlePlacesContextProxy } from './places.js'; +import { keySetupEndpoint } from '../standalone/key-setup.js'; + +/** Construct the local provider plugins in their established order. */ +function localProviderPlugins() { + return [ + openSkyProxy(), + celestrakProxy(), + tomtomProxy(), + firmsProxy(), + rocketLaunchesProxy(), + terrainHeightsProxy(), + adsbdbProxy(), + overpassProxy(), + militaryInstallationsProxy(), + regionalBriefProxy(), + weatherEffectsProxy(), + cctvProxy({ sourceRoot: defaultSourceRoot }), + radioBrowserProxy(), + gbfsProxy(), + adsbLolProxy(), + aisLiveProxy(), + trackBackfillProxies(), + openAiRealtimeProxy(), + googlePlacesContextProxy(), + keySetupEndpoint(), + ]; +} + +export { localProviderPlugins }; + +export { + CCTV_FRAME_FETCH_TIMEOUT_MS, + fetchCctvImageFromUpstream, +} from './cctv.js'; +export { + createRadioProxyMiddleware, + isPublicRadioAddress, + normalizeRadioBrowserStation, + publicRadioStation, + publicRadioHttpsUrl, +} from './radio.js'; +export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; +export { googlePlacesContextProxy } from './places.js'; +export { googleServerApiKey } from './places.js'; +export { keylessGooglePlacesResponse } from './places.js'; +export { adsbLolFallbackAnchor } from './aircraft/opensky.js'; +export { readResponseTextCapped } from './common/http.js'; +export { readResponseJsonCapped } from './common/http.js'; +export { coalesceProxyRequest } from './common/http.js'; +export { requiredFiniteQueryNumber } from './common/query.js'; +export { isOverpassBoundaryQuery } from './overpass/query.js'; +export { simplifyOverpassPayloadBody } from './overpass/geometry.js'; +export { readOverpassDisk } from './overpass/cache.js'; +export { resolveOverpassPreflight } from './overpass/cache.js'; +export { overpassPayloadIsData } from './overpass/transport.js'; +export { fetchOverpassPayload } from './overpass/transport.js'; +export { openAiRealtimeProxy } from './openai.js'; +export { MILITARY_INSTALLATION_ELEMENT_CAP } from './military-installations/constants.js'; +export { quantizeMilitaryInstallationBox } from './military-installations/query.js'; +export { militaryInstallationCacheKey } from './military-installations/query.js'; +export { resolveMilitaryInstallationTier } from './military-installations/cache.js'; +export { migrateMilitaryInstallationEntry } from './military-installations/cache.js'; +export { militaryInstallationDiskFresh } from './military-installations/cache.js'; +export { militaryInstallationDiskPath } from './military-installations/cache.js'; +export { readMilitaryInstallationDisk } from './military-installations/cache.js'; +export { writeMilitaryInstallationDisk } from './military-installations/cache.js'; +export { validMilitaryInstallationBox } from './military-installations/query.js'; +export { militaryInstallationFailureReason } from './military-installations/query.js'; +export { validRegionalPoint } from './regional/query.js'; +export { regionalBriefHasAnySource } from './regional/briefing.js'; diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js new file mode 100644 index 000000000..6f8bb6cee --- /dev/null +++ b/server/providers/military-installations.js @@ -0,0 +1,215 @@ +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { + MILITARY_INSTALLATION_ELEMENT_CAP, + MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, + MILITARY_INSTALLATION_DISK_TTL_MS, + MILITARY_INSTALLATION_STALE_MS, +} from './military-installations/constants.js'; +import { fetchOverpassPayload } from './overpass/transport.js'; +import { + _militaryInstallationCache, + trimMilitaryInstallationCache, + writeMilitaryInstallationDisk, + resolveMilitaryInstallationTier, + readMilitaryInstallationDisk, +} from './military-installations/cache.js'; +import { + validMilitaryInstallationBox, + quantizeMilitaryInstallationBox, + militaryInstallationCacheKey, + militaryInstallationFailureReason, +} from './military-installations/query.js'; +import { coalesceProxyRequest } from './common/http.js'; + +const _militaryInstallationsRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 90, + globalMax: 300, +}); + +const _militaryInstallationInFlight = new Map(); + +function militaryInstallationsProxy() { + async function refresh(box, key) { + const bbox = `${box.south},${box.west},${box.north},${box.east}`; + const ql = `[out:json][timeout:20];(nwr["military"~"^(airfield|naval_base|range|barracks|base)$"](${bbox});nwr["landuse"="military"](${bbox}););out center tags geom ${MILITARY_INSTALLATION_ELEMENT_CAP};`; + const upstream = await fetchOverpassPayload( + `data=${encodeURIComponent(ql)}`, + MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, + ); + if ( + upstream.status >= 400 || + upstream.rateLimited || + upstream.runtimeError + ) { + throw Object.assign( + new Error('Mapped installation upstream unavailable'), + { + installationReason: upstream.rateLimited + ? 'rate_limited' + : upstream.status === 504 + ? 'timeout' + : upstream.runtimeError + ? 'query_failed' + : 'unavailable', + }, + ); + } + const parsed = JSON.parse(upstream.body); + const elements = Array.isArray(parsed?.elements) + ? parsed.elements.slice(0, MILITARY_INSTALLATION_ELEMENT_CAP) + : []; + const payload = { + elements, + // Honest truncation flag — the client re-asks for its exact viewport so + // off-view features can never starve in-view ones. The cap travels with + // the payload so the client never has to hard-code it. + saturated: elements.length >= MILITARY_INSTALLATION_ELEMENT_CAP, + elementCap: MILITARY_INSTALLATION_ELEMENT_CAP, + retrievedAt: new Date().toISOString(), + status: 'ready', + }; + const entry = { payload, cachedAt: Date.now() }; + _militaryInstallationCache.set(key, entry); + trimMilitaryInstallationCache(); + writeMilitaryInstallationDisk(key, entry); + return payload; + } + + function install(middlewares) { + middlewares.use('/api/military-installations', async (req, res) => { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + if (!_militaryInstallationsRateLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '5', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url, 'http://localhost'); + const requested = validMilitaryInstallationBox(url.searchParams); + if (!requested) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'A non-dateline bbox no larger than 10 degrees is required', + }), + ); + return; + } + // Query the SNAPPED box, not the raw viewport: neighbouring views then + // share one cache entry, and an outward snap always covers what was asked. + // `exact=1` opts out — the client sends it after a SATURATED snapped + // response, so a truncated tile can never starve the actual viewport. It + // is keyed separately so exact and snapped answers never collide. + const exact = url.searchParams.get('exact') === '1'; + const box = exact + ? requested + : quantizeMilitaryInstallationBox(requested); + // Key at the precision the query actually uses (see militaryInstallationCacheKey). + const key = exact + ? `exact:${militaryInstallationCacheKey(box, 5)}` + : militaryInstallationCacheKey(box); + const now = Date.now(); + const cached = _militaryInstallationCache.get(key); + const preflight = await resolveMilitaryInstallationTier({ + cacheKey: key, + memoryCache: _militaryInstallationCache, + inFlight: _militaryInstallationInFlight, + readDisk: () => + readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS), + now, + }); + if (preflight.source !== 'UPSTREAM') { + if (preflight.source === 'DISK') { + _militaryInstallationCache.set(key, preflight.entry); + trimMilitaryInstallationCache(); + } + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60', + 'X-Military-Installations': preflight.source, + }); + res.end( + JSON.stringify({ ...preflight.entry.payload, status: 'cached' }), + ); + return; + } + const request = coalesceProxyRequest( + _militaryInstallationInFlight, + key, + () => refresh(box, key), + ); + try { + const payload = await request.promise; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60', + 'X-Military-Installations': request.shared ? 'INFLIGHT' : 'MISS', + }); + res.end(JSON.stringify(payload)); + } catch (error) { + if (cached && now - cached.cachedAt <= MILITARY_INSTALLATION_STALE_MS) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Military-Installations': 'STALE', + }); + res.end(JSON.stringify({ ...cached.payload, status: 'stale' })); + return; + } + // Overpass is down: last-good mapped context at ANY age beats an empty + // layer (the same serve-stale rule the Overpass proxy applies). + const stale = await readMilitaryInstallationDisk(key, Infinity); + if (stale) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Military-Installations': 'STALE-DISK', + }); + res.end(JSON.stringify({ ...stale.payload, status: 'stale' })); + return; + } + res.writeHead(503, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'Mapped installation context is temporarily unavailable', + reason: militaryInstallationFailureReason(error), + }), + ); + } + }); + } + + return { + name: 'military-installations-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { militaryInstallationsProxy }; + +export { MILITARY_INSTALLATION_ELEMENT_CAP } from './military-installations/constants.js'; +export { quantizeMilitaryInstallationBox } from './military-installations/query.js'; +export { militaryInstallationCacheKey } from './military-installations/query.js'; +export { resolveMilitaryInstallationTier } from './military-installations/cache.js'; +export { migrateMilitaryInstallationEntry } from './military-installations/cache.js'; +export { militaryInstallationDiskFresh } from './military-installations/cache.js'; +export { militaryInstallationDiskPath } from './military-installations/cache.js'; +export { readMilitaryInstallationDisk } from './military-installations/cache.js'; +export { writeMilitaryInstallationDisk } from './military-installations/cache.js'; +export { validMilitaryInstallationBox } from './military-installations/query.js'; +export { militaryInstallationFailureReason } from './military-installations/query.js'; diff --git a/server/providers/military-installations/cache.js b/server/providers/military-installations/cache.js new file mode 100644 index 000000000..c77627e29 --- /dev/null +++ b/server/providers/military-installations/cache.js @@ -0,0 +1,171 @@ +import { + MILITARY_INSTALLATION_CACHE_MS, + MILITARY_INSTALLATION_ELEMENT_CAP, + MILITARY_INSTALLATION_DISK_TTL_MS, + MILITARY_INSTALLATION_DISK_DIR, + MILITARY_INSTALLATION_MAX_CACHE, +} from './constants.js'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; + +const _militaryInstallationCache = new Map(); + +/** + * Resolve the READ tiers for one installation request, in order: fresh memory, + * then disk. Returns UPSTREAM when neither can answer. + * + * Disk is skipped while a request for this key is already in flight — the + * caller joins that instead of paying a read. Exported so the tier ORDER is + * testable against a real temp directory without a Vite server, mirroring + * resolveOverpassPreflight. + * + * @param {object} options + * @param {string} options.cacheKey + * @param {Map} options.memoryCache + * @param {Map} options.inFlight + * @param {() => Promise} options.readDisk + * @param {number} [options.now] + * @param {number} [options.cacheMs] + * @returns {Promise<{source: 'HIT'|'DISK'|'UPSTREAM', entry: ?object}>} + */ +async function resolveMilitaryInstallationTier({ + cacheKey, + memoryCache, + inFlight, + readDisk, + now = Date.now(), + cacheMs = MILITARY_INSTALLATION_CACHE_MS, +}) { + const cached = memoryCache.get(cacheKey); + if (cached && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', entry: cached }; + if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null }; + const disk = await readDisk(); + return disk + ? { source: 'DISK', entry: disk } + : { source: 'UPSTREAM', entry: null }; +} + +/** + * Bring a stored installation entry up to the current payload shape. + * + * Entries written before the saturation guard shipped carry no `saturated` + * field, and the disk TTL is 30 DAYS — so without this a cached, truncated + * 700-element snapped response would keep skipping the exact-viewport retry for + * a month, quietly starving in-view sites. Saturation is DERIVED from the + * element count rather than invalidating those entries, so warm caches survive + * the upgrade. + * @param {?{payload: object, cachedAt: number}} entry + * @returns {?{payload: object, cachedAt: number}} + */ +function migrateMilitaryInstallationEntry(entry) { + if (!entry?.payload || typeof entry.payload.saturated === 'boolean') + return entry; + const elements = Array.isArray(entry.payload.elements) + ? entry.payload.elements + : []; + return { + ...entry, + payload: { + ...entry.payload, + saturated: elements.length >= MILITARY_INSTALLATION_ELEMENT_CAP, + }, + }; +} + +/** Whether a stored installation entry is still inside its TTL. */ +function militaryInstallationDiskFresh( + entry, + maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS, + now = Date.now(), +) { + if ( + !entry || + !Number.isFinite(entry.cachedAt) || + !Array.isArray(entry.payload?.elements) + ) + return false; + return now - entry.cachedAt <= maxAgeMs; +} + +/** Cache key -> stable disk-cache file path. */ +function militaryInstallationDiskPath( + cacheKey, + dir = MILITARY_INSTALLATION_DISK_DIR, +) { + return path.join( + dir, + `${createHash('sha1').update(cacheKey).digest('hex')}.json`, + ); +} + +/** + * Read a disk-cached installation entry. maxAgeMs Infinity = any age (the + * serve-stale path when Overpass is down). + * @returns {Promise} + */ +async function readMilitaryInstallationDisk( + cacheKey, + maxAgeMs, + dir = MILITARY_INSTALLATION_DISK_DIR, +) { + try { + const entry = JSON.parse( + await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'), + ); + if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null; + return migrateMilitaryInstallationEntry(entry); + } catch { + return null; + } +} + +/** + * Persist one installation payload ATOMICALLY: serialize to a temp sibling, + * then rename over the target. A crash or a full disk mid-write leaves the + * PREVIOUS entry intact — an in-place overwrite would shred the last-good copy + * and take serve-stale down with it, exactly when it is needed most. + * @returns {Promise} Whether the entry landed. + */ +async function writeMilitaryInstallationDisk( + cacheKey, + entry, + dir = MILITARY_INSTALLATION_DISK_DIR, +) { + const target = militaryInstallationDiskPath(cacheKey, dir); + // Same directory, so the rename is atomic on POSIX rather than a cross-device copy. + const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; + try { + await fsp.mkdir(dir, { recursive: true }); + await fsp.writeFile(temp, JSON.stringify(entry)); + await fsp.rename(temp, target); + return true; + } catch (err) { + console.warn( + '[Installations Proxy] disk cache write failed:', + err?.message || err, + ); + await fsp.rm(temp, { force: true }).catch(() => {}); + return false; + } +} + +function trimMilitaryInstallationCache() { + while (_militaryInstallationCache.size > MILITARY_INSTALLATION_MAX_CACHE) { + const oldest = _militaryInstallationCache.keys().next().value; + if (oldest === undefined) break; + _militaryInstallationCache.delete(oldest); + } +} + +export { + _militaryInstallationCache, + trimMilitaryInstallationCache, + writeMilitaryInstallationDisk, + resolveMilitaryInstallationTier, + readMilitaryInstallationDisk, + migrateMilitaryInstallationEntry, + militaryInstallationDiskFresh, + militaryInstallationDiskPath, +}; diff --git a/server/providers/military-installations/constants.js b/server/providers/military-installations/constants.js new file mode 100644 index 000000000..1ee5137d4 --- /dev/null +++ b/server/providers/military-installations/constants.js @@ -0,0 +1,61 @@ +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Military-installation context proxy +// --------------------------------------------------------------------------- +// This narrow endpoint deliberately does not expose arbitrary Overpass QL to +// the browser. It returns only allow-listed mapped context and rejects global, +// cross-dateline, or oversized requests before touching public OSM mirrors. +const MILITARY_INSTALLATION_CACHE_MS = 5 * 60_000; + +const MILITARY_INSTALLATION_STALE_MS = 60 * 60_000; + +const MILITARY_INSTALLATION_MAX_CACHE = 80; + +const MILITARY_INSTALLATION_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + +/** + * Upstream element cap. A response that hits it exactly is SATURATED — Overpass + * truncated, so off-viewport features from the snapped bbox may have crowded out + * in-viewport ones. Callers re-ask for the exact viewport in that case. + */ +const MILITARY_INSTALLATION_ELEMENT_CAP = 700; + +/** + * Disk-cache TTL for mapped installations (ms) — 30 days. + * + * Owner playtest 2026-08-18: "search nearby sites" was slow because every look + * around paid a live Overpass round trip, and the 5-minute in-memory tier died + * with the dev server. Mapped military features change on a survey timescale, + * not a session one, so a month-old answer is still the right answer — the same + * reasoning the Overpass proxy already applies to admin boundaries. + */ +const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000; + +/** Disk-cache directory for mapped installation payloads. */ +const MILITARY_INSTALLATION_DISK_DIR = path.join( + process.cwd(), + '.gev-cache', + 'military-installations', +); + +/** + * Cache-key grid step in degrees (~5.5 km). + * + * The browser sends the raw view rectangle, so every pixel of pan minted a new + * key and a new upstream query. Snapping the bbox OUTWARD onto a coarse grid + * makes neighbouring viewports share one entry, and because the snap only ever + * grows the box, the cached answer is always a superset of what was asked for. + */ +const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05; + +export { + MILITARY_INSTALLATION_ELEMENT_CAP, + MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, + MILITARY_INSTALLATION_DISK_TTL_MS, + MILITARY_INSTALLATION_STALE_MS, + MILITARY_INSTALLATION_CACHE_MS, + MILITARY_INSTALLATION_DISK_DIR, + MILITARY_INSTALLATION_MAX_CACHE, + MILITARY_INSTALLATION_BBOX_STEP_DEG, +}; diff --git a/server/providers/military-installations/query.js b/server/providers/military-installations/query.js new file mode 100644 index 000000000..f8fcec9af --- /dev/null +++ b/server/providers/military-installations/query.js @@ -0,0 +1,85 @@ +import { MILITARY_INSTALLATION_BBOX_STEP_DEG } from './constants.js'; +import { requiredFiniteQueryNumber } from '../common/query.js'; + +/** + * Snap a request bbox outward onto the shared installation cache grid. + * @param {{south:number, west:number, north:number, east:number}} box + * @param {number} [stepDeg] + * @returns {{south:number, west:number, north:number, east:number}} + */ +function quantizeMilitaryInstallationBox( + box, + stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG, +) { + // Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line + // in binary floating point, which would otherwise snap a whole cell too far. + const snap = (value, grow) => { + const cells = Number((value / stepDeg).toFixed(9)); + return Number( + ((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6), + ); + }; + return { + south: Math.max(-90, snap(box.south, -1)), + west: Math.max(-180, snap(box.west, -1)), + north: Math.min(90, snap(box.north, 1)), + east: Math.min(180, snap(box.east, 1)), + }; +} + +/** + * Stable disk/memory cache key for an installation bbox. + * + * The key's precision must match the precision of the bounds the QUERY uses, or + * two different queries collide on one entry. Snapped boxes live on a 0.05 deg + * grid, so 3 decimals is exact for them; an `exact=1` request carries the raw + * viewport at 5 decimals and must be keyed at 5, otherwise two nearby exact + * viewports would share an answer and the second would be missing the edge + * strip it just exposed. + * @param {{south:number, west:number, north:number, east:number}} box + * @param {number} [decimals] + */ +function militaryInstallationCacheKey(box, decimals = 3) { + return [box.south, box.west, box.north, box.east] + .map((value) => value.toFixed(decimals)) + .join(','); +} + +function validMilitaryInstallationBox(params) { + const south = requiredFiniteQueryNumber(params, 'south'); + const west = requiredFiniteQueryNumber(params, 'west'); + const north = requiredFiniteQueryNumber(params, 'north'); + const east = requiredFiniteQueryNumber(params, 'east'); + if (![south, west, north, east].every(Number.isFinite)) return null; + if ( + south < -90 || + north > 90 || + west < -180 || + east > 180 || + south >= north || + west >= east + ) + return null; + if (north - south > 10 || east - west > 10) return null; + return { south, west, north, east }; +} + +/** Safe, evidence-based reason for an installation upstream failure. */ +function militaryInstallationFailureReason(error) { + if ( + ['rate_limited', 'timeout', 'query_failed'].includes( + error?.installationReason, + ) + ) + return error.installationReason; + return ['AbortError', 'TimeoutError'].includes(error?.name) + ? 'timeout' + : 'unavailable'; +} + +export { + validMilitaryInstallationBox, + quantizeMilitaryInstallationBox, + militaryInstallationCacheKey, + militaryInstallationFailureReason, +}; diff --git a/server/providers/openai.js b/server/providers/openai.js new file mode 100644 index 000000000..99ae483ea --- /dev/null +++ b/server/providers/openai.js @@ -0,0 +1,41 @@ +import { defaultSourceRoot } from './common/source-root.js'; +import { handleHudSummary } from './openai/hud-summary.js'; +import { createDebugLogHandler } from './openai/debug-log.js'; +import { createRealtimeTokenHandler } from './openai/realtime.js'; + +/** + * Vite plugin: OpenAI Realtime ephemeral client secret. + * + * Keeps OPENAI_API_KEY server-side while the browser connects to the + * Realtime API over WebRTC with a short-lived secret. + */ +function openAiRealtimeProxy({ + sourceRoot = defaultSourceRoot, + annotationGuidance, +} = {}) { + function install(middlewares) { + middlewares.use('/api/openai/hud-summary', handleHudSummary); + + middlewares.use( + '/api/realtime/debug-log', + createDebugLogHandler({ sourceRoot }), + ); + + middlewares.use( + '/api/realtime/token', + createRealtimeTokenHandler({ annotationGuidance }), + ); + } + + return { + name: 'openai-realtime-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { openAiRealtimeProxy }; diff --git a/server/providers/openai/constants.js b/server/providers/openai/constants.js new file mode 100644 index 000000000..b5643636b --- /dev/null +++ b/server/providers/openai/constants.js @@ -0,0 +1,27 @@ +import { VOICE_MODELS } from '../../../src/voice/voiceCost.js'; + +// Sourced from the shared voice-model registry so the client's cost estimate +// can never be computed against a different model than the session runs on. +const OPENAI_REALTIME_MODEL_DEFAULT = VOICE_MODELS.standard.id; + +const OPENAI_REALTIME_MODEL_MINI_DEFAULT = VOICE_MODELS.mini.id; + +const OPENAI_REALTIME_VOICE_DEFAULT = 'marin'; + +const OPENAI_REALTIME_REASONING_DEFAULT = 'low'; + +const OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT = 3000; + +const OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT = 0.5; + +const OPENAI_HUD_SUMMARY_MODEL_DEFAULT = 'gpt-5-nano'; + +export { + OPENAI_HUD_SUMMARY_MODEL_DEFAULT, + OPENAI_REALTIME_MODEL_MINI_DEFAULT, + OPENAI_REALTIME_MODEL_DEFAULT, + OPENAI_REALTIME_VOICE_DEFAULT, + OPENAI_REALTIME_REASONING_DEFAULT, + OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, + OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT, +}; diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js new file mode 100644 index 000000000..f7dd42154 --- /dev/null +++ b/server/providers/openai/debug-log.js @@ -0,0 +1,47 @@ +import { defaultSourceRoot } from '../common/source-root.js'; +import path from 'node:path'; +import { readRequestBody } from '../common/request.js'; +import fs from 'node:fs'; + +const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024; + +function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { + const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs'); + const REALTIME_DEBUG_LOG_FILE = path.join( + REALTIME_DEBUG_LOG_DIR, + 'realtime-conversations.jsonl', + ); + return async (req, res) => { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + try { + const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES); + const record = JSON.parse(body || '{}'); + fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true }); + fs.appendFileSync( + REALTIME_DEBUG_LOG_FILE, + `${JSON.stringify({ + loggedAt: new Date().toISOString(), + ...record, + })}\n`, + ); + res.statusCode = 204; + res.end(); + } catch (error) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + error: error?.message || 'Failed to write Realtime debug log', + }), + ); + } + }; +} + +export { createDebugLogHandler }; diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js new file mode 100644 index 000000000..54c065412 --- /dev/null +++ b/server/providers/openai/hud-summary.js @@ -0,0 +1,100 @@ +import { keylessHudSummaryResponse } from '../../../src/hudSummaryResponse.js'; +import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; +import { readRequestBody } from '../common/request.js'; +import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js'; + +function extractOpenAiResponseText(data) { + if (typeof data?.output_text === 'string' && data.output_text.trim()) { + return data.output_text.trim(); + } + if (!Array.isArray(data?.output)) return ''; + return data.output + .flatMap((item) => (Array.isArray(item?.content) ? item.content : [])) + .map((part) => part?.text || part?.output_text || '') + .join(' ') + .trim(); +} + +function toFiveWordHudSummary(value) { + return String(value || '') + .replace(/[^\p{L}\p{N}\s-]/gu, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 5) + .join(' '); +} + +async function handleHudSummary(req, res) { + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + const apiKey = process.env.OPENAI_API_KEY; + const keyless = keylessHudSummaryResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD + // fallback has no provider cost and resolves above without consuming a + // paid-endpoint quota slot. + if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return; + + try { + const body = await readRequestBody(req, 64 * 1024); + const context = JSON.parse(body || '{}'); + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: + process.env.OPENAI_HUD_SUMMARY_MODEL || + OPENAI_HUD_SUMMARY_MODEL_DEFAULT, + instructions: [ + "Write one concise intelligence-HUD summary for God's Eye View.", + 'Use only the supplied place, street, nearby-place, and enabled-layer text labels.', + 'Prefer the clearest named place and include a relevant enabled layer only when useful.', + 'Do not infer from coordinates or invent a place.', + 'Output exactly five words with no title, punctuation, markdown, or introductory phrase.', + ].join(' '), + input: JSON.stringify(context), + reasoning: { effort: 'minimal' }, + max_output_tokens: 100, + }), + }); + const data = await response.json().catch(() => ({})); + const summary = toFiveWordHudSummary(extractOpenAiResponseText(data)); + res.statusCode = response.ok && summary ? 200 : response.status || 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end( + JSON.stringify({ + summary: summary || null, + error: response.ok + ? null + : data.error?.message || 'OpenAI HUD summary request failed', + }), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + error: error?.message || 'OpenAI HUD summary request failed', + }), + ); + } +} + +export { handleHudSummary }; diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js new file mode 100644 index 000000000..ef291b9f4 --- /dev/null +++ b/server/providers/openai/instructions.js @@ -0,0 +1,73 @@ +const DEFAULT_ANNOTATION_GUIDANCE = + 'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.'; + +function realtimeInstructions( + annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE, +) { + return [ + "You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.", + 'Have a natural spoken conversation with the user while the mic session is active.', + 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', + 'Only control the app by calling the provided tools. Never invent tool names or arguments.', + 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', + 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', + 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', + 'For "what is this aircraft?" answers, read the callsign, operator, registration, type, and route only from get_entity_context selected.properties. Treat route, routeOrigin, and routeDestination as the only authoritative route fields. Every aircraft identity answer MUST explicitly cover operator, type, and route. When a route is present, repeat its endpoint codes exactly; do not expand airport codes into city names. For a missing field say exactly "Operator details are unavailable", "Aircraft type is unavailable", or "Route details are unavailable" as applicable. Never silently omit missing enrichment or infer it from the callsign.', + 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.', + 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', + 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', + 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', + 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', + 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', + 'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.', + 'If basemap context includes knownLandmarks, prefer the nearest known landmark by name for "what am I looking at" answers. For example, if knownLandmarks includes Eiffel Tower, say Eiffel Tower.', + 'At local zoom, use basemap nearbyPlaces, place.labels, viewportPlaces.visibleLabels, and viewportPlaces.streetLabels to identify the building, premises, roads, and named places visible around the screen target.', + 'If basemap context includes viewportPlaces, prefer dominantCountry, dominantRegion, and dominantLocality over raw coordinates.', + 'When basemap context includes viewportSamples or an inferred country, trust that over a single reverse-geocoded address. If most samples indicate Iran, say Iran, not the United States.', + 'When a viewport screenshot is attached after get_entity_context, read clearly legible street, building, and place labels from it and combine them with structured label context. Respect scene viewScale: at global/continental/regional scale, avoid naming a precise street/city from one center pixel.', + 'Do not mention disabled layers or stale selections.', + 'When a request requires a tool call, do not speak in the same response as the tool call. Call the tool first.', + 'When a single user request contains MULTIPLE changes (e.g. "switch to operator layout, use balanced detection at density 50, and switch to Bing aerial"), call ALL the corresponding tools — multiple tool calls in sequence — before speaking. Never confirm a partial subset. If a later tool fails, say which parts succeeded and which failed.', + 'After receiving tool output, speak exactly one short confirmation. Do not repeat the confirmation.', + 'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.', + // INSTRUCTION-ONLY mapping for the two globe-scale named views. + // + // Both are BROADER than the first-run tiles on purpose. A person + // naming layers out loud has chosen them; a tile is a first + // impression handed to a stranger. So voice keeps fires in the + // environmental view and keeps infrastructure entirely, while the + // launcher's ENVIRONMENTAL tile is quakes-only and has no + // infrastructure tile at all. See src/firstRunExperience.js for why. + // + // Fully expressible with tools that already exist, so + // GEV_REALTIME_TOOLS is deliberately untouched — deleting this one + // string is the whole rollback. + 'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.', + 'For visual filter requests, call set_visual_style with one of the allowed style IDs.', + 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', + 'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', + 'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.', + 'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.', + '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', + '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', + "After frame_overhead, speak ONLY from the tool result's count field — e.g. 'Framed fourteen aircraft, labels on'; never reassess or second-guess the count aloud.", + 'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', + 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', + 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', + 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', + 'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.', + 'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.', + 'Keep spoken confirmations short, e.g. "Opening datacenters" or "Flying to London".', + 'WHITEBOARD THE WORLD: whenever you describe or explain a specific place, building, campus, district, boundary, or a spatial relationship between places, call annotate_map to mark it visually as you talk — like sketching on the map. To call out a specific building, campus, compound, park, or district, use type=area (it traces and encloses the real footprint — a building gets a glowing volume, a district gets a draped outline). Use type=highlight only for a transient pulse on a precise spot that has no meaningful footprint, and type=pin to drop a labeled marker. Examples: "what is the Palace of Fine Arts?" → an AREA on it; "the old military base next to it" → an AREA on the Presidio; "ILM is right here" → a pin; "it sits next to the Marina" → an arrow from one to the other. Prefer place NAMES so the app resolves real positions and outlines; never invent coordinates or pixel locations.', + 'On every annotation, also set entityKind to what the thing IS when you know it: building (one structure), compound (campus/grounds/mall/park), district (neighborhood/area of a city), street (a named road), or point_feature (a monument, statue, memorial, plaque, fountain, or other small point landmark). entityKind is a FACT about the target, independent of the mark type you chose — monuments and statues are point_feature even when you use type=area; the app then anchors them as precise points instead of guessing at a footprint.', + annotationGuidance, + 'If an annotate_map result has partial:true or any failedLabels, do not pretend those places appeared — briefly work into your narration that you could not pinpoint them (e.g. "I couldn\'t place X"). If a route comes back as a direct line (no street route was found), describe it as a straight-line distance, not a walking/driving time. If an annotate_map result has capped:true, the map is full — ASK the user whether to clear before drawing more; do not clear unprompted. outlinePending:true is NOT a failure, but it is also NOT an outline: the anchor mark is placed and the boundary is still being traced in the background. Narrate it in progress — e.g. "tracing the boundary now" — and NEVER state the outline is already drawn or visible; it may yet come back as just a point. A later system item of type map_annotation_outline reports the final outcome per mark (status resolved or failed, with its label): use it to quietly confirm, or to correct yourself if you implied a boundary that stayed a point — an honest miss beats a misleading guess.', + 'PREFER NAMES. Only when you cannot name or geocode a place but you can clearly SEE the exact spot in the most recent viewport screenshot, fall back to screenX/screenY (normalized 0..1 from that image) to point at it; the app converts the pixel to a real world point. Never use screenX/screenY for something you could name.', + 'PATHS vs DISTANCES: for "walking/driving route from A to B" (or through several stops), use type=route with the ordered points and the matching mode (walking/driving/cycling) — the app draws the real street-following path on the map and reports distance and travel time, which you can read aloud. For "how far is X from Y", "is it nearby", or "X is next to Y", use type=arrow between the two — it draws a floating connector and shows the straight-line distance. Do NOT use route for a simple distance/proximity question.', + ].join('\n'); +} + +export { realtimeInstructions }; diff --git a/server/providers/openai/rate-limit.js b/server/providers/openai/rate-limit.js new file mode 100644 index 000000000..0cdecf00d --- /dev/null +++ b/server/providers/openai/rate-limit.js @@ -0,0 +1,39 @@ +import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; + +// Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later +// (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading +// process.env here at import time would always see them unset and silently stay unlimited even when +// configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env; +// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default). +let _openAiRateLimiter; + +/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */ +function openAiRateLimiter() { + if (_openAiRateLimiter === undefined) + _openAiRateLimiter = makeOptInRateLimiter( + process.env.GEV_RATELIMIT_OPENAI_PER_MIN, + ); + return _openAiRateLimiter; +} + +/** + * Apply an opt-in limiter to a request, writing a 429 when over the cap. + * When `limiter` is null (unlimited, the default) this is a no-op returning + * `true`, so the handler proceeds exactly as before. + * + * @param {((key:string)=>boolean)|null} limiter + * @param {import('http').IncomingMessage} req + * @param {import('http').ServerResponse} res + * @returns {boolean} True if the request may proceed; false if a 429 was sent. + */ +function enforceOptInRateLimit(limiter, req, res) { + if (!limiter) return true; // unlimited (default) — no behavior change + if (limiter(clientKey(req))) return true; + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return false; +} + +export { enforceOptInRateLimit, openAiRateLimiter }; diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js new file mode 100644 index 000000000..93fcae0f3 --- /dev/null +++ b/server/providers/openai/realtime.js @@ -0,0 +1,152 @@ +import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; +import { + resolveVoiceModel, + isKnownVoiceTier, +} from '../../../src/voice/voiceCost.js'; +import { + OPENAI_REALTIME_MODEL_MINI_DEFAULT, + OPENAI_REALTIME_MODEL_DEFAULT, + OPENAI_REALTIME_VOICE_DEFAULT, + OPENAI_REALTIME_REASONING_DEFAULT, + OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, + OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT, +} from './constants.js'; +import { realtimeInstructions } from './instructions.js'; +import { GEV_REALTIME_TOOLS } from './tools.js'; + +function createRealtimeTokenHandler({ annotationGuidance } = {}) { + return async (req, res) => { + if (req.method !== 'GET' && req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset. + if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return; + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' })); + return; + } + + // Voice model tier, requested by the client as ?tier=standard|mini. + // resolveVoiceModel is total: an unknown, empty, or hostile value + // resolves to `standard` instead of reaching OpenAI as a model id, so a + // bad querystring degrades to a normal session rather than a dead mic. + // The env overrides stay authoritative per tier (see .env.example) — + // a wrong upstream model id is then a config fix, not a code change. + const requestedTier = (() => { + try { + return new URL(req.url || '', 'http://localhost').searchParams.get( + 'tier', + ); + } catch { + return null; + } + })(); + const tier = resolveVoiceModel(requestedTier).tier; + const model = + tier === 'mini' + ? process.env.OPENAI_REALTIME_MODEL_MINI || + OPENAI_REALTIME_MODEL_MINI_DEFAULT + : process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT; + const voice = + process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT; + const effort = + process.env.OPENAI_REALTIME_REASONING_EFFORT || + OPENAI_REALTIME_REASONING_DEFAULT; + const contextTokenLimit = Math.round( + Math.max( + 1000, + Math.min( + 12000, + Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) || + OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, + ), + ), + ); + const contextRetentionRatio = Math.max( + 0.1, + Math.min( + 1, + Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) || + OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT, + ), + ); + const sessionConfig = { + session: { + type: 'realtime', + model, + reasoning: { effort }, + truncation: { + type: 'retention_ratio', + retention_ratio: contextRetentionRatio, + token_limits: { + post_instructions: contextTokenLimit, + }, + }, + audio: { + input: { + noise_reduction: { type: 'near_field' }, + turn_detection: { + type: 'semantic_vad', + eagerness: 'low', + create_response: true, + interrupt_response: false, + }, + }, + output: { voice }, + }, + instructions: realtimeInstructions(annotationGuidance), + tools: GEV_REALTIME_TOOLS, + tool_choice: 'auto', + }, + }; + + try { + const response = await fetch( + 'https://api.openai.com/v1/realtime/client_secrets', + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'OpenAI-Safety-Identifier': 'gev-local-dev', + }, + body: JSON.stringify(sessionConfig), + }, + ); + const body = await response.text(); + res.statusCode = response.status; + res.setHeader( + 'Content-Type', + response.headers.get('content-type') || 'application/json', + ); + // Which tier/model this secret was actually minted for. The upstream + // body is passed through untouched (the client parses it verbatim), so + // these headers are the authoritative echo — including the case where a + // bogus ?tier= was silently downgraded to standard. + res.setHeader('X-GEV-Voice-Tier', tier); + res.setHeader('X-GEV-Voice-Model', model); + if (requestedTier && !isKnownVoiceTier(requestedTier)) { + res.setHeader('X-GEV-Voice-Tier-Fallback', '1'); + } + res.end(body); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + error: error?.message || 'Failed to create Realtime token', + }), + ); + } + }; +} + +export { createRealtimeTokenHandler }; diff --git a/server/providers/openai/tools.js b/server/providers/openai/tools.js new file mode 100644 index 000000000..612bf3174 --- /dev/null +++ b/server/providers/openai/tools.js @@ -0,0 +1,978 @@ +const GEV_REALTIME_TOOLS = [ + { + type: 'function', + name: 'fly_to_location', + description: + "Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.", + parameters: { + type: 'object', + additionalProperties: false, + properties: { + locationId: { + type: 'string', + enum: [ + 'austin', + 'sf', + 'nyc', + 'tokyo', + 'london', + 'paris', + 'dubai', + 'dc', + ], + description: + 'Known city preset ID. Use when the requested place matches one of these cities.', + }, + query: { + type: 'string', + description: + 'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".', + }, + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + viewMode: { + type: 'string', + enum: ['close', 'overview'], + description: + 'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.', + }, + rangeM: { + type: 'number', + minimum: 100, + maximum: 20000000, + description: + 'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.', + }, + waitForArrival: { + type: 'boolean', + description: + 'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.', + }, + }, + }, + }, + { + type: 'function', + name: 'select_nearest_aircraft', + description: + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + layerId: { + type: 'string', + enum: ['flights', 'military'], + description: + 'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.', + }, + locationId: { + type: 'string', + enum: [ + 'austin', + 'sf', + 'nyc', + 'tokyo', + 'london', + 'paris', + 'dubai', + 'dc', + ], + description: + 'Known city preset ID when the place matches one of these cities.', + }, + locationQuery: { + type: 'string', + maxLength: 160, + description: 'Free-form destination when no locationId matches.', + }, + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + }, + required: ['layerId'], + }, + }, + { + type: 'function', + name: 'adjust_camera_zoom', + description: + 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + direction: { + type: 'string', + enum: ['in', 'out'], + }, + amount: { + type: 'string', + enum: ['little', 'medium', 'lot'], + description: + 'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".', + }, + }, + required: ['direction', 'amount'], + }, + }, + { + type: 'function', + name: 'zoom_to_globe', + description: + 'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.', + parameters: { + type: 'object', + additionalProperties: false, + properties: {}, + }, + }, + { + type: 'function', + name: 'set_layer_visibility', + description: "Enable or disable one registered God's Eye View data layer.", + parameters: { + type: 'object', + additionalProperties: false, + properties: { + layerId: { + type: 'string', + description: + 'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.', + enum: [ + 'flights', + 'military', + 'earthquakes', + 'satellites', + 'rocket-launches', + 'traffic', + 'cctv', + 'radio', + 'bikeshare', + 'ais-live-vessels', + 'local-datacenters', + 'local-dams', + 'telegeography-submarine-cables', + 'local-firms', + 'alpr-cameras', + ], + }, + enabled: { type: 'boolean' }, + }, + required: ['layerId', 'enabled'], + }, + }, + { + type: 'function', + name: 'show_data_layers_menu', + description: + 'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + layerId: { + type: 'string', + enum: [ + 'flights', + 'military', + 'earthquakes', + 'satellites', + 'traffic', + 'cctv', + 'radio', + 'bikeshare', + 'ais-live-vessels', + 'local-datacenters', + 'local-dams', + 'telegeography-submarine-cables', + 'local-firms', + 'alpr-cameras', + ], + description: 'Optional layer row to scroll into view and highlight.', + }, + }, + }, + }, + { + type: 'function', + name: 'set_panel_open', + description: 'Open or close a GEV UI panel/dropdown.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + panelId: { + type: 'string', + enum: [ + 'data-panel', + 'location-bar', + 'control-panel', + 'cctv-panel', + 'radio-panel', + 'scene-panel', + 'pp-toggles', + 'global-context-panel', + ], + }, + open: { type: 'boolean' }, + }, + required: ['panelId', 'open'], + }, + }, + { + type: 'function', + name: 'set_context_mode', + description: + 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + mode: { + type: 'string', + enum: ['off', 'contacts', 'flights', 'space-missions', 'missions'], + description: 'Use off to exit context mode.', + }, + }, + required: ['mode'], + }, + }, + { + type: 'function', + name: 'control_cockpit', + description: + 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + action: { + type: 'string', + enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'], + description: + 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + }, + targetLayer: { + type: 'string', + enum: [ + 'flights', + 'military', + 'ais-live-vessels', + 'military-installations', + ], + description: + 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + }, + aircraftClass: { + type: 'string', + description: + 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + }, + }, + required: ['action'], + }, + }, + { + type: 'function', + name: 'set_visual_style', + description: "Set the active God's Eye View visual filter/style.", + parameters: { + type: 'object', + additionalProperties: false, + properties: { + style: { + type: 'string', + enum: [ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ], + }, + }, + required: ['style'], + }, + }, + { + type: 'function', + name: 'get_entity_context', + description: + 'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + scope: { + type: 'string', + enum: ['auto', 'selected', 'in_view'], + description: + 'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.', + }, + layerId: { + type: 'string', + enum: [ + 'local-datacenters', + 'local-dams', + 'telegeography-submarine-cables', + 'local-firms', + ], + description: 'Optional layer filter for visible entity context.', + }, + limit: { + type: 'number', + minimum: 1, + maximum: 12, + }, + }, + }, + }, + { + type: 'function', + name: 'get_current_view_state', + description: + 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', + parameters: { + type: 'object', + additionalProperties: false, + properties: {}, + }, + }, + { + type: 'function', + name: 'set_hud', + description: + 'Control the intelligence HUD overlay: visibility and/or layout variant.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + visible: { + type: 'string', + enum: ['on', 'off', 'auto'], + description: 'auto restores style-driven show/hide.', + }, + layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] }, + }, + }, + }, + { + type: 'function', + name: 'set_detection', + description: + 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + enabled: { + type: 'boolean', + description: + 'false turns detection OFF; true restores the current density-derived profile.', + }, + mode: { type: 'string', enum: ['sparse', 'balanced', 'dense'] }, + densityPct: { + type: 'number', + description: + 'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.', + }, + allocationStrategy: { + type: 'string', + enum: ['elastic', 'weighted'], + description: + 'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.', + }, + }, + }, + }, + { + type: 'function', + name: 'set_map_stack', + description: + 'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + stack: { + type: 'string', + enum: [ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ], + description: + 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.', + }, + }, + required: ['stack'], + }, + }, + { + type: 'function', + name: 'set_post_processing', + description: + 'Control bloom and sharpen post-processing toggles and intensities.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + bloom: { + type: 'object', + additionalProperties: false, + properties: { + enabled: { type: 'boolean' }, + intensityPct: { + type: 'number', + description: '0-200 (UI percent).', + }, + }, + }, + sharpen: { + type: 'object', + additionalProperties: false, + properties: { + enabled: { type: 'boolean' }, + intensityPct: { + type: 'number', + description: '0-100 (UI percent).', + }, + }, + }, + }, + }, + }, + { + type: 'function', + name: 'control_scene', + description: + 'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + action: { + type: 'string', + enum: ['list', 'play', 'stop', 'next', 'status'], + }, + sceneId: { + type: 'string', + description: 'Scene id or (partial) title for play.', + }, + }, + required: ['action'], + }, + }, + { + type: 'function', + name: 'control_cctv', + description: + 'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + action: { + type: 'string', + enum: [ + 'enable', + 'disable', + 'select', + 'next', + 'prev', + 'nearest', + 'focus', + 'coverage', + 'viewshed', + 'adjust', + 'projection', + 'autohop', + ], + }, + cameraQuery: { + type: 'string', + description: 'Camera name or id for select.', + }, + enabled: { + type: 'boolean', + description: + 'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.', + }, + }, + required: ['action'], + }, + }, + { + type: 'function', + name: 'control_radio', + description: + 'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + action: { + type: 'string', + enum: [ + 'enable', + 'disable', + 'play', + 'resume', + 'pause', + 'stop', + 'next', + 'previous', + 'volume', + 'select', + 'status', + ], + description: + 'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.', + }, + volumePct: { + type: 'number', + minimum: 0, + maximum: 100, + description: + 'Required for volume; sets the persistent Radio playback volume.', + }, + category: { + type: 'string', + enum: [ + 'all', + 'news', + 'talk', + 'weather', + 'public-safety', + 'aviation-marine', + 'traffic-transit', + 'music', + ], + description: + 'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.', + }, + locationId: { + type: 'string', + enum: [ + 'austin', + 'sf', + 'nyc', + 'tokyo', + 'london', + 'paris', + 'dubai', + 'dc', + ], + description: 'Known nearby-city anchor for select.', + }, + locationQuery: { + type: 'string', + maxLength: 120, + description: + 'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.', + }, + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + country: { + type: 'string', + maxLength: 80, + description: + 'Country code or name filter, for example US or United States.', + }, + stationQuery: { + type: 'string', + maxLength: 120, + description: 'Optional station name/tag substring.', + }, + }, + required: ['action'], + }, + }, + { + type: 'function', + name: 'track_entity', + description: + 'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + description: + 'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.', + }, + layerId: { + type: 'string', + description: + 'Optional layer hint: flights | military | ais-live-vessels | satellites.', + }, + }, + required: ['query'], + }, + }, + { + type: 'function', + name: 'stop_tracking', + description: + 'Stop following the tracked aircraft/satellite and clear any selected vessel.', + parameters: { + type: 'object', + additionalProperties: false, + properties: {}, + }, + }, + { + type: 'function', + name: 'frame_overhead', + description: + 'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + target: { + type: 'string', + enum: ['flights', 'military', 'satellites', 'vessels'], + }, + radiusKm: { + type: 'number', + description: + 'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.', + }, + }, + required: ['target'], + }, + }, + { + type: 'function', + name: 'annotate_map', + description: + 'Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + annotations: { + type: 'array', + description: + 'One or more things to mark. Mark multiple related places together when describing them as a group.', + minItems: 1, + maxItems: 24, + items: { + type: 'object', + additionalProperties: false, + properties: { + type: { + type: 'string', + enum: ['pin', 'highlight', 'area', 'arrow', 'route', 'label'], + description: + 'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.', + }, + target: { + type: 'string', + maxLength: 200, + description: + 'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.', + }, + points: { + type: 'array', + description: + 'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).', + minItems: 2, + maxItems: 12, + items: { + type: 'object', + additionalProperties: false, + properties: { + target: { + type: 'string', + maxLength: 200, + description: 'Waypoint place name.', + }, + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + screenX: { type: 'number', minimum: 0, maximum: 1 }, + screenY: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }, + mode: { + type: 'string', + enum: ['walking', 'driving', 'cycling'], + description: + 'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.', + }, + latitude: { + type: 'number', + minimum: -90, + maximum: 90, + description: + 'Explicit latitude (use only if no good place name exists).', + }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + toTarget: { + type: 'string', + maxLength: 200, + description: 'For type=arrow: the destination place name.', + }, + toLatitude: { type: 'number', minimum: -90, maximum: 90 }, + toLongitude: { type: 'number', minimum: -180, maximum: 180 }, + label: { + type: 'string', + maxLength: 120, + description: + 'Short caption shown on the map (a few words). Optional.', + }, + color: { + type: 'string', + enum: ['primary', 'amber', 'cyan', 'green', 'red'], + description: + 'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.', + }, + footprint: { + type: 'boolean', + description: + 'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.', + }, + intent: { + type: 'string', + enum: ['the_thing', 'around_the_thing'], + description: + 'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.', + }, + entityKind: { + type: 'string', + enum: [ + 'building', + 'compound', + 'district', + 'street', + 'point_feature', + ], + description: + 'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).', + }, + screenX: { + type: 'number', + minimum: 0, + maximum: 1, + description: + 'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.', + }, + screenY: { + type: 'number', + minimum: 0, + maximum: 1, + description: + 'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.', + }, + toScreenX: { + type: 'number', + minimum: 0, + maximum: 1, + description: + 'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).', + }, + toScreenY: { + type: 'number', + minimum: 0, + maximum: 1, + description: + 'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).', + }, + }, + required: ['type'], + }, + }, + flyTo: { + type: 'boolean', + description: + 'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.', + }, + persist: { + type: 'boolean', + description: + 'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).', + }, + }, + required: ['annotations'], + }, + }, + { + type: 'function', + name: 'clear_annotations', + description: + 'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.', + parameters: { + type: 'object', + additionalProperties: false, + properties: {}, + }, + }, + { + type: 'function', + name: 'move_camera', + description: + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + motion: { + type: 'string', + enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], + }, + direction: { + type: 'string', + enum: ['left', 'right', 'up', 'down'], + description: + 'Required except for orbit (defaults right/clockwise) and stop.', + }, + speed: { type: 'string', enum: ['slow', 'normal', 'fast'] }, + mode: { + type: 'string', + enum: ['once', 'continuous'], + description: + 'once = bounded eased nudge (default); continuous = until stop/manual input.', + }, + }, + required: ['motion'], + }, + }, + { + type: 'function', + name: 'fly_route', + description: + 'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + label: { + type: 'string', + description: 'Match an existing route mark by (partial) label.', + }, + speed: { type: 'string', enum: ['slow', 'normal', 'fast'] }, + }, + }, + }, + { + type: 'function', + name: 'analyst_query', + description: + 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + layers: { + type: 'array', + items: { + type: 'string', + enum: [ + 'flights', + 'military', + 'ais-live-vessels', + 'local-firms', + 'earthquakes', + ], + }, + description: + 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.', + }, + scope: { + type: 'object', + additionalProperties: false, + description: + 'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.', + properties: { + kind: { + type: 'string', + enum: ['view', 'region', 'radius', 'anywhere'], + }, + name: { + type: 'string', + description: + 'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").', + }, + km: { type: 'number', description: 'For kind=radius.' }, + center: { + type: 'object', + additionalProperties: false, + properties: { lat: { type: 'number' }, lon: { type: 'number' } }, + }, + }, + }, + filters: { + type: 'array', + description: + 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).', + items: { + type: 'object', + additionalProperties: false, + properties: { + field: { type: 'string' }, + op: { + type: 'string', + enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'], + }, + value: {}, + }, + required: ['field', 'op', 'value'], + }, + }, + sortBy: { + type: 'string', + description: 'Field to rank by, or "distance" for nearest-first.', + }, + sortDir: { type: 'string', enum: ['asc', 'desc'] }, + limit: { type: 'number' }, + followUp: { + type: 'boolean', + description: + 'true = re-query the PREVIOUS result set instead of fresh data.', + }, + }, + }, + }, + { + type: 'function', + name: 'next_iss_pass', + description: + "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.", + parameters: { + type: 'object', + additionalProperties: false, + properties: { + latitude: { + type: 'number', + minimum: -90, + maximum: 90, + description: + 'Optional observer latitude. Omit to use the current camera position.', + }, + longitude: { + type: 'number', + minimum: -180, + maximum: 180, + description: + 'Optional observer longitude. Omit to use the current camera position.', + }, + minElevationDeg: { + type: 'number', + minimum: 5, + maximum: 60, + description: + 'Minimum peak elevation (deg) to count as a pass. Default 10.', + }, + }, + }, + }, +]; + +export { GEV_REALTIME_TOOLS }; diff --git a/server/providers/overpass.js b/server/providers/overpass.js new file mode 100644 index 000000000..824baff5a --- /dev/null +++ b/server/providers/overpass.js @@ -0,0 +1,219 @@ +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { readRequestBodyCapped } from './common/request.js'; +import { + OVERPASS_MAX_BODY_BYTES, + OVERPASS_MAX_CONCURRENT, +} from './overpass/constants.js'; +import { sanitizeOverpassBody } from './overpass/query.js'; +import { + resolveOverpassPreflight, + _overpassCache, + readOverpassDisk, + overpassDiskTtlMs, + readStaleOverpass, + trimOverpassCache, + writeOverpassDisk, +} from './overpass/cache.js'; +import { + overpassPayloadIsData, + fetchOverpassPayload, +} from './overpass/transport.js'; +import { installRouteMiddleware } from './places/routes.js'; + +/** @type {Map} In-flight Overpass requests keyed by normalized query body. */ +const _overpassInFlight = new Map(); + +let _overpassConcurrent = 0; + +const _overpassRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 90, + globalMax: 300, +}); + +/** + * Write a completed Overpass payload to the HTTP response. + * + * @param {import('http').ServerResponse} res - Node HTTP response. + * @param {{status:number,body:string,contentType:string,endpoint:string}} payload + * @param {string} [cacheStatus='MISS'] - 'HIT', 'MISS', or 'INFLIGHT'. + */ +function sendOverpassResponse(res, payload, cacheStatus = 'MISS') { + res.writeHead(payload.status, { + 'Content-Type': payload.contentType || 'application/json', + 'Cache-Control': 'public, max-age=15', + 'X-Overpass-Cache': cacheStatus, + 'X-Overpass-Upstream': payload.endpoint || 'unknown', + }); + res.end(payload.body || ''); +} + +/** + * Vite plugin: Overpass API proxy with response caching and request coalescing. + * + * Accepts POST requests at /api/overpass, normalizes the query body for + * cache keying, and fans out to multiple Overpass mirrors with per-upstream + * timeout and rate-limit detection. Successful responses are cached for + * OVERPASS_CACHE_MS. Concurrent identical queries share a single upstream + * request via the in-flight map. + * + * @returns {import('vite').Plugin} + */ +function overpassProxy() { + const installMiddleware = (server) => { + server.middlewares.use('/api/overpass', async (req, res) => { + // Hoisted out of the try so the catch's serve-stale lookup can see it + // (a body-read failure would otherwise hit an out-of-scope reference). + let cacheKey = null; + try { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + + // Collect POST body with a hard byte cap (Overpass QL queries are small) + let body; + try { + body = ( + await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES) + ).toString(); + } catch (err) { + if (err?.code === 'BODY_TOO_LARGE') { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Overpass query too large' })); + return; + } + throw err; + } + if (!body) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing Overpass query body' })); + return; + } + + // Validate + clamp the QL: reject unbounded/global queries and cap the + // server-side timeout so a tiny body can't request planet-scale work. + const sanitized = sanitizeOverpassBody(body); + if (!sanitized.ok) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: sanitized.error })); + return; + } + const safeBody = sanitized.body; + + // Normalize whitespace so semantically identical Overpass QL queries share cache entries + cacheKey = safeBody.replace(/\s+/g, ' ').trim(); + const preflight = await resolveOverpassPreflight({ + cacheKey, + memoryCache: _overpassCache, + inFlight: _overpassInFlight, + // Fresh-enough disk entries survive restarts and skip the public + // mirrors; boundary-class queries keep their month-long TTL. + readDisk: () => + readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), + allowUpstream: () => _overpassRateLimiter(clientKey(req)), + }); + if (preflight.source === 'RATE_LIMITED') { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '5', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + if (preflight.source !== 'UPSTREAM') { + // A coalesced caller sees the same failure as the original request + // and must get the same last-good fallback, not the raw refusal. + if (!overpassPayloadIsData(preflight.payload)) { + const stale = await readStaleOverpass(cacheKey); + if (stale) { + sendOverpassResponse(res, stale, 'STALE'); + return; + } + } + if (preflight.source === 'DISK') { + _overpassCache.set(cacheKey, preflight.payload); + trimOverpassCache(); + } + sendOverpassResponse(res, preflight.payload, preflight.source); + return; + } + + // From here onward the request is genuinely upstream-bound and has + // consumed one local limiter slot. Cache and dedupe hits above do not. + if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { + res.writeHead(503, { + 'Content-Type': 'application/json', + 'Retry-After': '2', + }); + res.end( + JSON.stringify({ + error: 'Overpass proxy busy — try again shortly', + }), + ); + return; + } + _overpassConcurrent += 1; + const requestPromise = fetchOverpassPayload(safeBody) + .then((payload) => { + // Only a 2xx is data. `< 500` cached every 4xx, so one mirror's + // refusal was written to memory AND disk — and boundary-class + // queries hold a month-long TTL, so a single 406 outlived the + // outage that caused it. + if (overpassPayloadIsData(payload)) { + const entry = { ...payload, cachedAt: Date.now() }; + _overpassCache.set(cacheKey, entry); + trimOverpassCache(); + writeOverpassDisk(cacheKey, entry); + } + return payload; + }) + .finally(() => { + _overpassConcurrent -= 1; + _overpassInFlight.delete(cacheKey); + }); + + _overpassInFlight.set(cacheKey, requestPromise); + const payload = await requestPromise; + // Degraded upstream (rate-limited on every mirror / 5xx / runtime + // error): last-good roads beat an empty layer — serve stale from + // memory or disk at ANY age before surfacing the failure. + if (!overpassPayloadIsData(payload)) { + const stale = await readStaleOverpass(cacheKey); + if (stale) { + sendOverpassResponse(res, stale, 'STALE'); + return; + } + } + sendOverpassResponse(res, payload, 'MISS'); + } catch (e) { + // Every mirror threw (network-level). Same serve-stale rule. + const stale = cacheKey ? await readStaleOverpass(cacheKey) : null; + if (stale) { + sendOverpassResponse(res, stale, 'STALE'); + return; + } + console.error('[Overpass Proxy]', e.message); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Overpass proxy error' })); + } + }); + + installRouteMiddleware(server.middlewares); + }; + return { + name: 'overpass-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} + +export { overpassProxy }; + +export { isOverpassBoundaryQuery } from './overpass/query.js'; +export { simplifyOverpassPayloadBody } from './overpass/geometry.js'; +export { readOverpassDisk } from './overpass/cache.js'; +export { resolveOverpassPreflight } from './overpass/cache.js'; +export { overpassPayloadIsData } from './overpass/transport.js'; +export { fetchOverpassPayload } from './overpass/transport.js'; diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js new file mode 100644 index 000000000..b4e53ab21 --- /dev/null +++ b/server/providers/overpass/cache.js @@ -0,0 +1,137 @@ +import { isOverpassBoundaryQuery } from './query.js'; +import { + OVERPASS_BOUNDARY_DISK_TTL_MS, + OVERPASS_DISK_TTL_MS, + OVERPASS_DISK_DIR, + OVERPASS_CACHE_MS, + OVERPASS_CACHE_MAX_ENTRIES, +} from './constants.js'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import { overpassPayloadIsData } from './transport.js'; + +/** @type {Map} */ +const _overpassCache = new Map(); + +/** Disk TTL for a query: boundary geometry keeps for a month, the rest 7 days. */ +function overpassDiskTtlMs(cacheKey) { + return isOverpassBoundaryQuery(cacheKey) + ? OVERPASS_BOUNDARY_DISK_TTL_MS + : OVERPASS_DISK_TTL_MS; +} + +/** Normalized Overpass query -> stable disk-cache file path. */ +function overpassDiskPath(cacheKey) { + return path.join( + OVERPASS_DISK_DIR, + `${createHash('sha1').update(cacheKey).digest('hex')}.json`, + ); +} + +/** + * Read a disk-cached Overpass payload. maxAgeMs Infinity = any age (the + * serve-stale path when every mirror is down). + * @returns {Promise} Payload with cachedAt, or null. + */ +async function readOverpassDisk(cacheKey, maxAgeMs) { + try { + const raw = await fsp.readFile(overpassDiskPath(cacheKey), 'utf8'); + const payload = JSON.parse(raw); + if ( + !payload || + typeof payload.body !== 'string' || + !Number.isFinite(payload.cachedAt) + ) + return null; + // Older versions persisted 4xx refusals with normal data TTLs. Ignore + // them on both fresh and stale reads so an upgrade can recover immediately. + if (!overpassPayloadIsData(payload)) return null; + if (Date.now() - payload.cachedAt > maxAgeMs) return null; + return payload; + } catch { + return null; + } +} + +/** Fire-and-forget disk write for a successful Overpass payload. */ +function writeOverpassDisk(cacheKey, payload) { + fsp + .mkdir(OVERPASS_DISK_DIR, { recursive: true }) + .then(() => + fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload)), + ) + .catch((err) => + console.warn( + '[Overpass Proxy] disk cache write failed:', + err?.message || err, + ), + ); +} + +/** + * Resolve every cache/coalescing layer before admitting a request to the local + * upstream rate limiter. The injected limiter callback is invoked exactly once + * for a complete cache miss and never for memory, in-flight, or disk hits. + * Exported so the admission ordering can be tested without a Vite server. + * + * @param {object} options + * @param {string} options.cacheKey + * @param {Map} options.memoryCache + * @param {Map>} options.inFlight + * @param {()=>Promise} options.readDisk + * @param {()=>boolean} options.allowUpstream + * @param {number} [options.now] + * @param {number} [options.cacheMs] + * @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>} + */ +async function resolveOverpassPreflight({ + cacheKey, + memoryCache, + inFlight, + readDisk, + allowUpstream, + now = Date.now(), + cacheMs = OVERPASS_CACHE_MS, +}) { + const cached = memoryCache.get(cacheKey); + if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', payload: cached }; + + const pending = inFlight.get(cacheKey); + if (pending) return { source: 'INFLIGHT', payload: await pending }; + + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk }; + + return allowUpstream() + ? { source: 'UPSTREAM', payload: null } + : { source: 'RATE_LIMITED', payload: null }; +} + +/** Return only last-good Overpass data, regardless of its age. */ +async function readStaleOverpass(cacheKey) { + const cached = _overpassCache.get(cacheKey); + return overpassPayloadIsData(cached) + ? cached + : readOverpassDisk(cacheKey, Infinity); +} + +/** Evict oldest Overpass cache entries until size is within the cap. */ +function trimOverpassCache() { + while (_overpassCache.size > OVERPASS_CACHE_MAX_ENTRIES) { + const oldestKey = _overpassCache.keys().next().value; + if (!oldestKey) break; + _overpassCache.delete(oldestKey); + } +} + +export { + readOverpassDisk, + resolveOverpassPreflight, + _overpassCache, + overpassDiskTtlMs, + readStaleOverpass, + trimOverpassCache, + writeOverpassDisk, +}; diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js new file mode 100644 index 000000000..aef0267eb --- /dev/null +++ b/server/providers/overpass/constants.js @@ -0,0 +1,134 @@ +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Overpass API proxy constants and cache state +// --------------------------------------------------------------------------- +/** Ordered list of Overpass API mirrors; tried sequentially on failure/rate-limit. */ +const OVERPASS_UPSTREAMS = [ + 'https://overpass-api.de/api/interpreter', + 'https://overpass.kumi.systems/api/interpreter', + 'https://lz4.overpass-api.de/api/interpreter', + // Community full-planet instance (privateforge nonprofit) — added 2026-07-30 + // when all three mirrors above refused this IP (likely a dev-traffic rate + // ban; refused connections fail in ms, so healthy mirrors above still win). + // Verified: planet coverage (Texas query), CORS *, ~5-20 s cold latency. + 'https://overpass.private.coffee/api/interpreter', +]; + +/** + * TTL for FRESH cached Overpass responses (ms). Road geometry is static for + * months — the original 45 s TTL forced a public-mirror round-trip on nearly + * every viewport revisit and left nothing to serve when the mirrors 502 + * (field-test 2026-07-17: all three mirrors down during US morning peak = + * "traffic takes forever to load"). 24 h in memory; the disk layer below + * keeps 7 days and also survives dev-server restarts. + */ +const OVERPASS_CACHE_MS = 86_400_000; + +/** Disk-cache TTL for Overpass responses (ms) — 7 days. */ +const OVERPASS_DISK_TTL_MS = 7 * 86_400_000; + +/** + * Disk-cache TTL for BOUNDARY-class queries (is_in / admin-relation pivots) — 30 + * days. Admin boundaries change ≈never, and their pivots are the most expensive + * queries the app issues (multi-MB coastline geometry, 10–25 s on public mirrors — + * field test 2026-07-23: outline latency + the Sicily miss). Keeping them a month + * means each boundary is fetched roughly once per machine, ever. + */ +const OVERPASS_BOUNDARY_DISK_TTL_MS = 30 * 86_400_000; + +/** Disk-cache directory for Overpass responses. */ +const OVERPASS_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'overpass'); + +/** Per-upstream fetch timeout (ms). */ +const OVERPASS_TIMEOUT_MS = 22000; + +/** Max entries in the Overpass response cache (LRU-like, oldest evicted first). */ +const OVERPASS_CACHE_MAX_ENTRIES = 120; + +// --- Abuse guards shared by the Overpass + route proxies -------------------- +/** Max accepted POST body for the Overpass proxy (Overpass QL queries are tiny). */ +const OVERPASS_MAX_BODY_BYTES = 24 * 1024; + +// 24 KB +/** + * Hard cap on a single Overpass upstream response we will buffer into memory. + * 32 MB (was 12 MB): a dense island/state admin boundary at full `out geom` + * fidelity — Sicilia's Mediterranean coastline — can exceed 12 MB, and clipping + * it read as a permanent "transient" failure (field test 2026-07-23, Sicily + * never traced). The buffered payload is SIMPLIFIED server-side before it is + * cached or sent (simplifyOverpassPayloadBody), so the raised cap does not + * raise what clients receive or what the disk stores. + */ +const OVERPASS_MAX_RESPONSE_BYTES = 32 * 1024 * 1024; + +// 32 MB +/** Only payloads at least this large go through geometry simplification. */ +const OVERPASS_SIMPLIFY_MIN_BYTES = 1_500_000; + +/** Only per-element geometry arrays with at least this many points are simplified. */ +const OVERPASS_SIMPLIFY_MIN_POINTS = 1200; + +/** + * Douglas-Peucker tolerance (degrees, ≈44 m of latitude). Region/state boundary + * rings are drawn at regional camera scale and the client simplifies again for + * draw, so ~44 m fidelity is invisible; building footprints never reach the + * point threshold above and pass through untouched. + */ +const OVERPASS_SIMPLIFY_TOLERANCE_DEG = 0.0004; + +/** Max concurrent in-flight upstream Overpass fetches across all distinct queries. */ +const OVERPASS_MAX_CONCURRENT = 6; + +/** Server-side timeout ceiling (seconds) we allow inside an Overpass QL query. */ +const OVERPASS_MAX_QL_TIMEOUT = 30; + +/** Max `around:` radius (m) — every app caller uses <= 1800 m. */ +const OVERPASS_MAX_AROUND_M = 50000; + +/** Max bbox span (degrees) — app bboxes are small viewport tiles. */ +const OVERPASS_MAX_BBOX_DEG = 12; + +/** + * Every Overpass element-type specifier, including the combined shortcuts + * (nwr/nw/nr/wr) and `rel`. Shared by the selector + area-element-deny regexes so + * they can't drift (a missing shortcut like `wr` was an area-scan bypass). + */ +const OVERPASS_ELEMENT_TYPES = 'node|way|relation|nwr|nw|nr|wr|rel'; + +/** Element-selector (incl. `area`) whose statements must be individually bounded. */ +const OVERPASS_SELECTOR_RE = new RegExp( + `\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`, +); + +/** An element selector bounded BY an area — the country-scan abuse shape. */ +const OVERPASS_AREA_ELEMENT_RE = new RegExp( + `\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`, + 'i', +); + +/** A single bbox 4-tuple `(s,w,n,e)` (non-global so it does not advance lastIndex). */ +const OVERPASS_BBOX_RE = + /\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/; + +export { + OVERPASS_BOUNDARY_DISK_TTL_MS, + OVERPASS_DISK_TTL_MS, + OVERPASS_DISK_DIR, + OVERPASS_CACHE_MS, + OVERPASS_CACHE_MAX_ENTRIES, + OVERPASS_MAX_BODY_BYTES, + OVERPASS_MAX_CONCURRENT, + OVERPASS_MAX_AROUND_M, + OVERPASS_MAX_BBOX_DEG, + OVERPASS_AREA_ELEMENT_RE, + OVERPASS_SELECTOR_RE, + OVERPASS_BBOX_RE, + OVERPASS_MAX_QL_TIMEOUT, + OVERPASS_SIMPLIFY_MIN_BYTES, + OVERPASS_SIMPLIFY_MIN_POINTS, + OVERPASS_SIMPLIFY_TOLERANCE_DEG, + OVERPASS_MAX_RESPONSE_BYTES, + OVERPASS_UPSTREAMS, + OVERPASS_TIMEOUT_MS, +}; diff --git a/server/providers/overpass/geometry.js b/server/providers/overpass/geometry.js new file mode 100644 index 000000000..684fd4497 --- /dev/null +++ b/server/providers/overpass/geometry.js @@ -0,0 +1,101 @@ +import { + OVERPASS_SIMPLIFY_MIN_BYTES, + OVERPASS_SIMPLIFY_MIN_POINTS, + OVERPASS_SIMPLIFY_TOLERANCE_DEG, +} from './constants.js'; + +/** Iterative Douglas-Peucker on [{lat,lon},...] (planar-degree approx — fine at + * the ~44 m tolerance used here). Endpoints always kept. */ +function douglasPeucker(points, toleranceDeg) { + const n = points.length; + if (n <= 2) return points; + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const stack = [[0, n - 1]]; + while (stack.length) { + const [a, b] = stack.pop(); + if (b - a < 2) continue; + const ax = points[a].lon; + const ay = points[a].lat; + const vx = points[b].lon - ax; + const vy = points[b].lat - ay; + const c2 = vx * vx + vy * vy; + let worst = -1; + let worstDist = toleranceDeg; + for (let i = a + 1; i < b; i++) { + const wx = points[i].lon - ax; + const wy = points[i].lat - ay; + let d; + if (c2 === 0) { + d = Math.hypot(wx, wy); + } else { + const t = Math.max(0, Math.min(1, (vx * wx + vy * wy) / c2)); + d = Math.hypot(wx - t * vx, wy - t * vy); + } + if (d > worstDist) { + worstDist = d; + worst = i; + } + } + if (worst >= 0) { + keep[worst] = 1; + stack.push([a, worst], [worst, b]); + } + } + const out = []; + for (let i = 0; i < n; i++) if (keep[i]) out.push(points[i]); + return out; +} + +/** Simplify one element's geometry array in place if it is big enough. */ +function simplifyElementGeometry(el, minPoints, toleranceDeg) { + if (Array.isArray(el?.geometry) && el.geometry.length >= minPoints) { + el.geometry = douglasPeucker(el.geometry, toleranceDeg); + } + if (Array.isArray(el?.members)) { + for (const member of el.members) { + if ( + Array.isArray(member?.geometry) && + member.geometry.length >= minPoints + ) { + member.geometry = douglasPeucker(member.geometry, toleranceDeg); + } + } + } +} + +/** + * Server-side geometry simplification for large Overpass `out geom` payloads. + * Region/state boundary pivots return multi-MB coastline rings whose fidelity + * nothing downstream needs (the client re-simplifies for draw); decimating them + * HERE shrinks the disk cache, the wire, and client parse time — and is what + * makes the raised read cap safe. Anything unparseable or below the thresholds + * passes through byte-identical. Exported for tests (opts override thresholds). + * + * @param {string} bodyText - Raw upstream JSON body. + * @returns {string} Possibly-simplified JSON body. + */ +function simplifyOverpassPayloadBody(bodyText, opts = {}) { + const minBytes = opts.minBytes ?? OVERPASS_SIMPLIFY_MIN_BYTES; + const minPoints = opts.minPoints ?? OVERPASS_SIMPLIFY_MIN_POINTS; + const toleranceDeg = opts.toleranceDeg ?? OVERPASS_SIMPLIFY_TOLERANCE_DEG; + if (typeof bodyText !== 'string' || bodyText.length < minBytes) + return bodyText; + let data; + try { + data = JSON.parse(bodyText); + } catch { + return bodyText; + } + if (!Array.isArray(data?.elements)) return bodyText; + for (const el of data.elements) + simplifyElementGeometry(el, minPoints, toleranceDeg); + try { + return JSON.stringify(data); + } catch { + return bodyText; + } +} + +export { simplifyOverpassPayloadBody }; diff --git a/server/providers/overpass/query.js b/server/providers/overpass/query.js new file mode 100644 index 000000000..b40266e22 --- /dev/null +++ b/server/providers/overpass/query.js @@ -0,0 +1,198 @@ +import { + OVERPASS_MAX_AROUND_M, + OVERPASS_MAX_BBOX_DEG, + OVERPASS_AREA_ELEMENT_RE, + OVERPASS_SELECTOR_RE, + OVERPASS_BBOX_RE, + OVERPASS_MAX_QL_TIMEOUT, +} from './constants.js'; + +/** + * Whether a normalized Overpass query is BOUNDARY-class (admin `is_in` lookups + * and area→relation pivots) — the static, expensive geometry that earns the + * 30-day disk TTL. The enclosing-compound sweep and road fetches keep the + * default TTL. Exported for tests. + */ +function isOverpassBoundaryQuery(cacheKey) { + return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || '')); +} + +/** + * Validate + clamp an Overpass form body. Defends the generic proxy against + * planet-scale abuse: requires exactly one `data` query in which EVERY element + * selector is individually spatially bounded (around / bbox / is_in / poly / + * area-set / pivot), rejects oversized radii and world-sized bboxes, and clamps + * every `[timeout:]` directive. Comments + quoted literals are stripped first so + * a fake bound inside a tag value can't satisfy the check. + * + * Every real app caller passes (annotations/locations/cctv use `around:`/`is_in`/ + * `area.`/`pivot`; traffic uses a small `(s,w,n,e)` bbox); a mixed query that + * pairs one bounded selector with a global one is rejected. + * + * @returns {{ok:true, body:string} | {ok:false, error:string}} + */ +/** + * Single-pass lexer: blank out quoted literals (→ empty quotes) and strip line + * and block comments — recognizing each in one walk so a comment marker INSIDE a + * quoted string is treated as string content, not a comment (and vice versa). + * Chained regex replaces get the ordering wrong (a quoted slash-slash would hide + * the rest of the line), which is exactly the bypass this avoids. + */ +function stripOverpassNoise(src) { + let out = ''; + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]; + if (c === '"' || c === "'") { + const quote = c; + i += 1; + while (i < n) { + if (src[i] === '\\') { + i += 2; + continue; + } // escaped char + if (src[i] === quote) { + i += 1; + break; + } // closing quote + i += 1; + } + out += quote + quote; // collapse the literal to empty quotes + continue; + } + if (c === '/' && src[i + 1] === '*') { + i += 2; + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i += 1; + i += 2; + out += ' '; + continue; + } + if (c === '/' && src[i + 1] === '/') { + i += 2; + while (i < n && src[i] !== '\n') i += 1; + out += ' '; + continue; + } + out += c; + i += 1; + } + return out; +} + +function sanitizeOverpassBody(rawBody) { + let params; + try { + params = new URLSearchParams(rawBody); + } catch { + return { ok: false, error: 'Malformed query body' }; + } + const all = params.getAll('data'); + if (all.length !== 1) + return { ok: false, error: 'Exactly one data query is required' }; + const data = all[0]; + if (!data || !data.trim()) + return { ok: false, error: 'Missing Overpass data query' }; + + // Blank quoted literals + strip comments in one lexer pass so a fake bound or a + // `//` inside a string can't hide an unbounded selector (or satisfy a bound). + const stripped = stripOverpassNoise(data); + + // Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the + // input-set form `around.set:r` — and parse the full numeric token so scientific + // notation (`5e7`) can't slip a planet-scale radius past the cap. + for (const m of stripped.matchAll(/around(?:\.\w+)?:\s*([\d.eE+-]+)/gi)) { + const radius = Number(m[1]); + if (!Number.isFinite(radius) || radius > OVERPASS_MAX_AROUND_M) { + return { ok: false, error: 'Overpass around radius too large' }; + } + } + // Reject world-sized / oversized bboxes. + for (const m of stripped.matchAll( + /\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g, + )) { + const s = Number(m[1]); + const w = Number(m[2]); + const n = Number(m[3]); + const e = Number(m[4]); + if ( + Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG || + Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG + ) { + return { ok: false, error: 'Overpass bbox too large' }; + } + } + + // Reject control-flow constructs the app never uses — their set/bound semantics + // are hard to validate statically. The app only uses plain selectors + is_in / + // area / pivot / recursion, so this denylist closes loop/transform escape hatches. + if (/\b(?:foreach|complete|retro|compare|convert|make)\b/i.test(stripped)) { + return { ok: false, error: 'Unsupported Overpass construct' }; + } + // `poly:` has unchecked extent and the app never uses it — reject outright + // (position-independent, so tag filters can't hide it). + if (/\bpoly\s*:/i.test(stripped)) { + return { ok: false, error: 'Overpass poly filter not allowed' }; + } + + // Every selector statement must be individually bounded, WITH set provenance: a + // set counts as a bound only if it was assigned (->.set) by an already-bounded + // statement. So `way[...]->.a` (global assigned to a set) is rejected, while the + // app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate. + const boundedSets = new Set(); + for (let stmt of stripped.split(';')) { + stmt = stmt.trim(); + if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue; + + // Strip output-set assignments (NOT input bounds), then strip bracket tag + // filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be + // misread as a spatial bound. Bounds live in (...) / function calls / set + // refs, never inside [...], so the probe loses nothing real. + const outSets = []; + const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { + outSets.push(name); + return ' '; + }); + const probe = body.replace(/\[[^\]]*\]/g, ' '); + + // Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter + // between the selector and the area filter (way["highway"](area.a)) can't hide + // it. An area has unbounded extent (could be a whole country); the app only + // SELECTS admin areas (area.set) and pivots (rel(pivot.x)), never node/way/ + // relation(area...). The probe collapses tags so `way (area.a)` is caught. + if (OVERPASS_AREA_ELEMENT_RE.test(probe)) { + return { + ok: false, + error: 'Overpass area-bounded element selector not allowed', + }; + } + + const hasSelector = OVERPASS_SELECTOR_RE.test(probe); + const inputSets = [...probe.matchAll(/(? m[1], + ); + const directBound = + /around:\s*\d/.test(probe) || + OVERPASS_BBOX_RE.test(probe) || + /is_in\s*\(/.test(probe) || // is_in(lat,lon) — the function form only + /\barea\s*\(/.test(probe); // area(id) — bounded as a set definition + + const setBound = inputSets.some((s) => boundedSets.has(s)); + const bounded = directBound || setBound; + + if (hasSelector && !bounded) { + return { ok: false, error: 'Overpass query has an unbounded selector' }; + } + // Only a bounded statement can mark its output sets as bounded. + if (bounded) for (const name of outSets) boundedSets.add(name); + } + + const clamped = data.replace( + /\[timeout:\s*(\d+)\s*\]/gi, + (_, n) => + `[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`, + ); + return { ok: true, body: `data=${encodeURIComponent(clamped)}` }; +} + +export { isOverpassBoundaryQuery, sanitizeOverpassBody }; diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js new file mode 100644 index 000000000..ff15ae0ff --- /dev/null +++ b/server/providers/overpass/transport.js @@ -0,0 +1,158 @@ +import { + OVERPASS_MAX_RESPONSE_BYTES, + OVERPASS_UPSTREAMS, + OVERPASS_TIMEOUT_MS, +} from './constants.js'; +import { readResponseTextCapped } from '../common/http.js'; +import { simplifyOverpassPayloadBody } from './geometry.js'; + +/** + * Detect whether an Overpass API response body indicates rate-limiting. + * + * Checks for known rate-limit phrases in the body text regardless of + * HTTP status code, since some mirrors return 200 with an error payload. + * + * @param {string} bodyText - Upstream response body. + * @returns {boolean} True if the body looks rate-limited. + */ +function overpassLooksRateLimited(bodyText) { + const text = String(bodyText || '').toLowerCase(); + return ( + text.includes('rate_limited') || + text.includes('quota of your ip address') || + text.includes('dispatcher_client::request_read_and_idx::rate_limited') || + text.includes('too many requests') + ); +} + +/** + * Detect an Overpass HTTP-200 body that is actually a runtime FAILURE (server-side + * timeout / out-of-memory) via its `remark`. These are transient upstream failures, + * not authoritative empty results, so they must not be returned or cached. + */ +function overpassLooksRuntimeError(bodyText) { + const text = String(bodyText || '').toLowerCase(); + return ( + text.includes('runtime error') || + text.includes('timed out') || + text.includes('out of memory') + ); +} + +/** + * True only for an upstream response that is actually Overpass data. + * + * The proxy caches on this and serves stale on its negation, so the two + * decisions cannot drift apart: a payload that is not data must never be + * written to the cache and must always be eligible for a stale replacement. + * @param {{status: number, rateLimited?: boolean, runtimeError?: boolean}} payload + * @returns {boolean} + */ +function overpassPayloadIsData(payload) { + const status = Number(payload?.status); + return ( + Number.isFinite(status) && + status >= 200 && + status < 300 && + !payload.rateLimited && + !payload.runtimeError + ); +} + +/** + * Try each mirror once, retaining response-size and per-mirror timeout caps. + * Refusals and body-level failures rotate; total failure returns the last + * rate-limit payload, otherwise the first refusal, or throws a network error. + * @param {string} body URL-encoded Overpass QL query body. + * @param {number} [maxResponseBytes] Endpoint-specific response cap. + * @param {object} [options] Server-only endpoint and I/O overrides for tests. + * @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>} + */ +async function fetchOverpassPayload( + body, + maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES, + { + endpoints = OVERPASS_UPSTREAMS, + fetchImpl = fetch, + readBody = readResponseTextCapped, + simplify = simplifyOverpassPayloadBody, + } = {}, +) { + let lastError = null; + let lastRateLimitPayload = null; + let lastRefusalPayload = null; + + for (const endpoint of endpoints) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS); + + try { + const upstream = await fetchImpl(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'gods-eye-view-overpass-proxy/1.0', + }, + body, + signal: controller.signal, + }); + + const responseBody = await readBody(upstream, maxResponseBytes); + const contentType = + upstream.headers.get('content-type') || 'application/json'; + const status = upstream.status; + const rateLimited = + status === 429 || overpassLooksRateLimited(responseBody); + const runtimeError = overpassLooksRuntimeError(responseBody); + const payload = { + status, + body: responseBody, + contentType, + endpoint, + rateLimited, + runtimeError, + }; + + if (rateLimited) { + lastRateLimitPayload = payload; + continue; + } + // A 200 body carrying a runtime error / timeout is a transient upstream + // failure — skip to the next mirror rather than returning or caching it. + if (runtimeError) { + lastError = new Error(`Overpass runtime error (${endpoint})`); + continue; + } + // Anything but 2xx is this mirror declining, not an answer. Only 5xx used + // to rotate, so a 4xx ended the fan-out and was returned — and cached — + // as data: overpass-api.de and its lz4 alias answer 406 to this proxy's + // User-Agent while kumi.systems and private.coffee answer 200 to the very + // same request, so every Overpass-backed layer failed on an Apache error + // page with two healthy mirrors untried. The first refusal is kept so a + // genuinely bad query still reports what upstream said, but only after + // every mirror has had the chance to answer it. + if (status < 200 || status >= 300) { + if (!lastRefusalPayload) lastRefusalPayload = payload; + lastError = new Error( + `Overpass upstream returned ${status} (${endpoint})`, + ); + continue; + } + + // Success: decimate giant boundary geometry before it reaches the cache, + // the disk, or the client (what makes the 32 MB read cap safe to hold). + payload.body = simplify(payload.body); + return payload; + } catch (error) { + lastError = error; + } finally { + clearTimeout(timeoutId); + } + } + + if (lastRateLimitPayload) return lastRateLimitPayload; + if (lastRefusalPayload) return lastRefusalPayload; + throw lastError || new Error('All Overpass upstreams failed'); +} + +export { overpassPayloadIsData, fetchOverpassPayload }; diff --git a/server/providers/places.js b/server/providers/places.js new file mode 100644 index 000000000..648be2aa8 --- /dev/null +++ b/server/providers/places.js @@ -0,0 +1,13 @@ +/** Place-search and route middleware for Node development servers. */ +export { googlePlacesContextProxy } from './places/google.js'; +export { + googleServerApiKey, + keylessGooglePlacesResponse, +} from './places/google-key.js'; +export { installRouteMiddleware } from './places/routes.js'; +export { + makeRateLimiter, + makeOptInRateLimiter, + clientKey, +} from './common/rate-limit.js'; +export { haversineKm } from './common/geo.js'; diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js new file mode 100644 index 000000000..353bc9e24 --- /dev/null +++ b/server/providers/places/google-key.js @@ -0,0 +1,26 @@ +import { resolveGoogleServerKey } from '../../../scripts/google-server-key.mjs'; + +/** + * Optional Google place context is an empty capability when no key is present, + * not a server outage. Returning 200 keeps a deliberately keyless session out + * of the browser error console while preserving an explicit configured flag. + */ +export function keylessGooglePlacesResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { configured: false, error: null, places: [] }, + }; +} + +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, the + * CCTV Street View fallback). These never reach the browser, so this key can + * be restricted by server IP and scoped to Places API + Street View Static + * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + + * Geocoding for the browser (#33). Splitting them is opt-in: unset, this + * falls back to the shared browser key and nothing changes. + */ +export function googleServerApiKey() { + return resolveGoogleServerKey(process.env); +} diff --git a/server/providers/places/google.js b/server/providers/places/google.js new file mode 100644 index 000000000..9dacd144b --- /dev/null +++ b/server/providers/places/google.js @@ -0,0 +1,282 @@ +import { + googleServerApiKey, + keylessGooglePlacesResponse, +} from './google-key.js'; +import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { + projectNearbyPlaces, + projectTextSearchPlaces, +} from '../../../src/data/placeProviderPayloads.js'; + +// Construct lazily after the standalone environment has loaded. +// undefined = not built yet; null = unlimited; fn = active limiter +let _googleRateLimiter; + +/** Google cost endpoint (nearby-places). Null = unlimited (default). */ +function googleRateLimiter() { + if (_googleRateLimiter === undefined) + _googleRateLimiter = makeOptInRateLimiter( + process.env.GEV_RATELIMIT_GOOGLE_PER_MIN, + ); + return _googleRateLimiter; +} + +/** Validate raw lat/lon presence and WGS84 bounds before consuming request quota. */ +export function validatePlacesCoordinates(searchParams) { + const rawLat = searchParams.get('lat'); + const rawLon = searchParams.get('lon'); + if (rawLat === null || rawLon === null || !rawLat.trim() || !rawLon.trim()) { + return { ok: false, error: 'lat and lon are required' }; + } + const latitude = Number(rawLat); + const longitude = Number(rawLon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return { ok: false, error: 'Valid lat and lon are required' }; + } + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + return { + ok: false, + error: 'lat must be within [-90, 90] and lon within [-180, 180]', + }; + } + return { ok: true, latitude, longitude }; +} + +/** Nearby place labels and view-biased text search, with request-time key resolution. */ +export function googlePlacesContextProxy({ + resolveApiKey = googleServerApiKey, +} = {}) { + function install(middlewares) { + middlewares.use('/api/google/nearby-places', async (req, res) => { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed', places: [] })); + return; + } + + // Keyless place context has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD + // summary route). + const apiKey = resolveApiKey(); + const keyless = keylessGooglePlacesResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const coordinates = validatePlacesCoordinates(requestUrl.searchParams); + if (!coordinates.ok) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: coordinates.error, places: [] })); + return; + } + const { latitude, longitude } = coordinates; + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. + // Inlined (not the shared helper) so the 429 body keeps this endpoint's + // `places: []` contract that the client expects on every error response. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end(JSON.stringify({ error: 'Rate limit exceeded', places: [] })); + return; + } + + const radiusM = Math.max( + 25, + Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250), + ); + + try { + const response = await fetch( + 'https://places.googleapis.com/v1/places:searchNearby', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.shortFormattedAddress', + 'places.location', + 'places.primaryType', + 'places.primaryTypeDisplayName', + 'places.types', + ].join(','), + }, + body: JSON.stringify({ + maxResultCount: 20, + rankPreference: 'DISTANCE', + locationRestriction: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + }), + }, + ); + const data = await response.json().catch(() => ({})); + const places = projectNearbyPlaces(data, latitude, longitude); + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end( + JSON.stringify({ + places, + error: response.ok + ? null + : data.error?.message || 'Google Places request failed', + }), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + error: error?.message || 'Google Places request failed', + places: [], + }), + ); + } + }); + + // Text Search: resolve a named landmark/POI to a real coordinate, biased to + // the view. Geocoding scatters obscure monument/POI names across the city; + // a view-biased Text Search lands on the actual feature. Same key, field + // mask, throttle, and `places: []` error contract as nearby-places above. + middlewares.use('/api/google/text-search', async (req, res) => { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed', places: [] })); + return; + } + + // Keyless place context has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD + // summary route). + const apiKey = resolveApiKey(); + const keyless = keylessGooglePlacesResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); + if (!textQuery) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ error: 'q, lat and lon are required', places: [] }), + ); + return; + } + const coordinates = validatePlacesCoordinates(requestUrl.searchParams); + if (!coordinates.ok) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: coordinates.error, places: [] })); + return; + } + const { latitude, longitude } = coordinates; + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. + // Inlined (like nearby-places) so the 429 body keeps the `places: []` + // contract the client expects on every error response. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end(JSON.stringify({ error: 'Rate limit exceeded', places: [] })); + return; + } + + const radiusM = Math.max( + 50, + Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000), + ); + + try { + const response = await fetch( + 'https://places.googleapis.com/v1/places:searchText', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.location', + 'places.viewport', + 'places.primaryType', + 'places.types', + ].join(','), + }, + body: JSON.stringify({ + textQuery, + locationBias: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + maxResultCount: 5, + }), + }, + ); + const data = await response.json().catch(() => ({})); + const places = projectTextSearchPlaces(data, latitude, longitude); + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end( + JSON.stringify({ + places, + error: response.ok + ? null + : data.error?.message || 'Google Places request failed', + }), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + error: error?.message || 'Google Places request failed', + places: [], + }), + ); + } + }); + } + + return { + name: 'google-places-context-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js new file mode 100644 index 000000000..04d20a74d --- /dev/null +++ b/server/providers/places/routes.js @@ -0,0 +1,133 @@ +import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; +import { haversineKm } from '../common/geo.js'; +import { readResponseTextCapped } from '../common/http.js'; +import { + normalizeRouteProfile, + projectRouteResult, +} from '../../../src/data/placeProviderPayloads.js'; + +/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ +const ROUTE_CACHE_MS = 600000; + +const _routeCache = new Map(); + +/** Hard cap on the OSRM route response we will buffer. */ +const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB + +/** Reject routes whose straight-line spans are obviously abusive (km). */ +const ROUTE_MAX_LEG_KM = 600; + +const ROUTE_MAX_TOTAL_KM = 2500; + +const _routeRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 60, + globalMax: 200, +}); + +export function installRouteMiddleware(middlewares) { + // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). + // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] + middlewares.use('/api/route', async (req, res) => { + const fail = (msg) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: msg })); + }; + try { + if (!_routeRateLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '5', + }); + res.end(JSON.stringify({ ok: false, error: 'rate limited' })); + return; + } + const url = new URL(req.url, 'http://localhost'); + const raw = (url.searchParams.get('profile') || 'foot').toLowerCase(); + const profile = normalizeRouteProfile(raw); + if (!profile) return fail('invalid profile'); + const osrmProfile = profile === 'car' ? 'driving' : profile; + const pairs = (url.searchParams.get('coords') || '') + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + if (pairs.length < 2 || pairs.length > 12) + return fail('need 2-12 coordinates'); + const clean = []; + const pts = []; + for (const pr of pairs) { + const parts = pr.split(','); + if (parts.length !== 2) return fail('invalid coordinate'); + const lon = Number(parts[0]); + const lat = Number(parts[1]); + if ( + !Number.isFinite(lon) || + !Number.isFinite(lat) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) { + return fail('invalid coordinate'); + } + clean.push(`${lon},${lat}`); + pts.push([lon, lat]); + } + // Reject obviously-abusive spans — a real walking/driving route is local, + // so a cross-continent request is either a bug or an attempt to drive + // heavy upstream OSRM work. + let totalKm = 0; + for (let i = 1; i < pts.length; i += 1) { + // pts are [lon, lat]; existing haversineKm takes (lat1, lon1, lat2, lon2). + const legKm = haversineKm( + pts[i - 1][1], + pts[i - 1][0], + pts[i][1], + pts[i][0], + ); + if (legKm > ROUTE_MAX_LEG_KM) return fail('route leg too long'); + totalKm += legKm; + } + if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long'); + const coords = clean.join(';'); + const cacheKey = `${profile}|${coords}`; + const now = Date.now(); + const cached = _routeCache.get(cacheKey); + if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(cached.payload)); + return; + } + const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + let osrm; + try { + const upstreamRes = await fetch(upstream, { + signal: controller.signal, + headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, + }); + if (!upstreamRes.ok) return fail('no route found'); + const ctype = upstreamRes.headers.get('content-type') || ''; + if (!ctype.includes('json')) return fail('no route found'); + const text = await readResponseTextCapped( + upstreamRes, + ROUTE_MAX_RESPONSE_BYTES, + ); + osrm = JSON.parse(text); + } finally { + clearTimeout(timer); + } + const route = osrm?.routes?.[0]; + if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) + return fail('no route found'); + const payload = projectRouteResult(route, profile); + _routeCache.set(cacheKey, { payload, cachedAt: now }); + if (_routeCache.size > 200) + _routeCache.delete(_routeCache.keys().next().value); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + } catch (e) { + console.error('[Route Proxy]', e?.message || e); + fail('route proxy error'); + } + }); +} diff --git a/server/providers/radio.js b/server/providers/radio.js new file mode 100644 index 000000000..5dd4b196f --- /dev/null +++ b/server/providers/radio.js @@ -0,0 +1,19 @@ +import { createRadioProxyMiddleware } from './radio/catalog.js'; +export { createRadioProxyMiddleware }; +export { isPublicRadioAddress } from './radio/transport.js'; +export { + normalizeRadioBrowserStation, + publicRadioStation, + publicRadioHttpsUrl, +} from './radio/stations.js'; +export function radioBrowserProxy() { + const middleware = createRadioProxyMiddleware(); + const install = (server) => { + server.middlewares.use('/api/radio', middleware); + }; + return { + name: 'radio-browser-proxy', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js new file mode 100644 index 000000000..36a3eb730 --- /dev/null +++ b/server/providers/radio/catalog.js @@ -0,0 +1,394 @@ +import { lookup as lookupDns } from 'node:dns/promises'; +import { randomUUID } from 'node:crypto'; + +import { readResponseTextCapped } from '../common/http.js'; +import { + normalizeRadioBrowserStation, + publicRadioStation, + cleanRadioText, +} from './stations.js'; +import { + radioMirrorOrigin, + radioProxyDestination, + resolveRadioProxyAddresses, + fetchPinnedRadioResponse, +} from './transport.js'; +import { + RADIO_DIRECTORY_CACHE_MS, + RADIO_DIRECTORY_STALE_MS, + RADIO_MIRROR_CACHE_MS, + RADIO_FETCH_TIMEOUT_MS, + RADIO_RESPONSE_MAX_BYTES, + RADIO_DIRECTORY_LIMIT, + RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES, + RADIO_CATALOG_HEALTHY_MIN_STATIONS, + RADIO_USER_AGENT, + RADIO_UUID_RE, + RADIO_FALLBACK_MIRRORS, +} from './constants.js'; +export async function mapRadioConcurrent(values, concurrency, mapper) { + const results = new Array(values.length); + let cursor = 0; + const workers = Array.from( + { length: Math.min(concurrency, values.length) }, + async () => { + for (;;) { + const index = cursor++; + if (index >= values.length) return; + results[index] = await mapper(values[index], index); + } + }, + ); + await Promise.all(workers); + return results; +} + +/** Create the testable Connect middleware backing `/api/radio`. */ +export function createRadioProxyMiddleware({ + fetchImpl = null, + lookupImpl = lookupDns, + now = Date.now, +} = {}) { + let mirrorCache = { origins: [...RADIO_FALLBACK_MIRRORS], cachedAt: 0 }; + let mirrorPromise = null; + let catalogCache = null; + let catalogGeneration = 0; + // The generation counter is process-local, so it restarts from 1 with the + // server. The instance token scopes each generation sequence: a client that + // sees a new instance must treat the catalog as a fresh sequence, never as a + // repeat ("still generation 1") or a regression ("generation went backward"). + const catalogInstance = randomUUID(); + let servedStationIds = new Set(); + let refreshPromise = null; + + async function fetchJson(url, maxBytes = RADIO_RESPONSE_MAX_BYTES) { + const destination = radioProxyDestination(url); + if (!destination) + throw new Error('Radio Browser destination is not permitted'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RADIO_FETCH_TIMEOUT_MS); + try { + const addresses = await resolveRadioProxyAddresses( + destination.hostname, + lookupImpl, + ); + const options = { + headers: { Accept: 'application/json', 'User-Agent': RADIO_USER_AGENT }, + signal: controller.signal, + redirect: 'manual', + }; + const response = fetchImpl + ? await fetchImpl(destination.href, options) + : await fetchPinnedRadioResponse(destination, options, addresses); + if (response.status >= 300 && response.status < 400) { + try { + await response.body?.cancel?.(); + } catch { + /* no-op */ + } + throw new Error('Radio Browser redirects are refused'); + } + if (!response.ok) + throw new Error(`Radio Browser returned ${response.status}`); + const text = await readResponseTextCapped(response, maxBytes); + return JSON.parse(text); + } finally { + clearTimeout(timer); + } + } + + async function mirrors() { + if (now() - mirrorCache.cachedAt < RADIO_MIRROR_CACHE_MS) + return mirrorCache.origins; + if (!mirrorPromise) { + mirrorPromise = (async () => { + try { + const rows = await fetchJson( + 'https://all.api.radio-browser.info/json/servers', + 256 * 1024, + ); + const discovered = [ + ...new Set( + (Array.isArray(rows) ? rows : []) + .map((row) => radioMirrorOrigin(row?.name)) + .filter(Boolean), + ), + ]; + if (discovered.length) { + mirrorCache = { + origins: [ + ...discovered, + ...RADIO_FALLBACK_MIRRORS.filter( + (origin) => !discovered.includes(origin), + ), + ], + cachedAt: now(), + }; + } + } catch { + mirrorCache = { ...mirrorCache, cachedAt: now() }; + } + return mirrorCache.origins; + })().finally(() => { + mirrorPromise = null; + }); + } + return mirrorPromise; + } + + async function fetchPath(pathname) { + let lastError = null; + for (const origin of await mirrors()) { + try { + return await fetchJson(`${origin}${pathname}`); + } catch (error) { + lastError = error; + } + } + throw lastError || new Error('No Radio Browser mirror is available'); + } + + async function refreshCatalog() { + const queries = [ + null, + 'news', + 'talk', + 'weather', + 'emergency', + 'scanner', + 'aviation', + 'marine', + 'traffic', + ]; + const outcomes = await mapRadioConcurrent( + queries, + 3, + async (tag, index) => { + const params = new URLSearchParams({ + has_geo_info: 'true', + is_https: 'true', + hidebroken: 'true', + order: 'clickcount', + reverse: 'true', + limit: index === 0 ? '1800' : '220', + }); + if (tag) params.set('tag', tag); + try { + const rows = await fetchPath(`/json/stations/search?${params}`); + if (!Array.isArray(rows)) + throw new Error('Radio Browser catalog payload was not an array'); + if ( + !rows.every( + (row) => + row && + typeof row === 'object' && + !Array.isArray(row) && + typeof row.stationuuid === 'string' && + typeof row.name === 'string' && + (typeof row.url_resolved === 'string' || + typeof row.url === 'string'), + ) + ) + throw new Error( + 'Radio Browser catalog contained a malformed station row', + ); + const stations = rows + .map(normalizeRadioBrowserStation) + .filter(Boolean); + const requestedTag = cleanRadioText(tag, 80) + .toLocaleLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const requestedTagCovered = + !requestedTag || + stations.some((station) => + station.tags.some( + (stationTag) => + stationTag === requestedTag || + stationTag.includes(requestedTag), + ), + ); + return { + // Query coverage is based on accepted rows, not merely a payload that + // happens to match the upstream schema. Specialist responses must + // also contain an accepted station tagged for the requested category. + succeeded: stations.length > 0 && requestedTagCovered, + stations, + }; + } catch { + return { succeeded: false, stations: [] }; + } + }, + ); + const resultSets = outcomes.map((outcome) => outcome.stations); + + const selected = []; + const seen = new Set(); + const take = (station) => { + if ( + !station || + seen.has(station.id) || + selected.length >= RADIO_DIRECTORY_LIMIT + ) + return; + seen.add(station.id); + selected.push(station); + }; + // Seed specialist station-tag queries before popularity fill so operational + // categories remain represented even when global click charts skew musical. + for (const rows of resultSets.slice(1)) rows.slice(0, 45).forEach(take); + resultSets + .flat() + .sort( + (a, b) => b.clickCount - a.clickCount || a.name.localeCompare(b.name), + ) + .forEach(take); + const timestamp = now(); + const successfulQueries = outcomes.filter( + (outcome) => outcome.succeeded, + ).length; + const broadQueryHealthy = + outcomes[0].succeeded && outcomes[0].stations.length > 0; + const healthReasons = []; + if (!broadQueryHealthy) healthReasons.push('broad-query-unhealthy'); + if (successfulQueries < RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES) + healthReasons.push('query-coverage-below-policy'); + if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) + healthReasons.push('station-coverage-below-policy'); + const degraded = healthReasons.length > 0; + const coverage = { + successfulQueries, + totalQueries: queries.length, + stationCount: selected.length, + healthyStationMinimum: RADIO_CATALOG_HEALTHY_MIN_STATIONS, + }; + const nextCatalog = { + cachedAt: timestamp, + updatedAt: new Date(timestamp).toISOString(), + stations: selected.map(publicRadioStation), + stationIds: new Set(selected.map((station) => station.id)), + degraded, + degradedReason: degraded ? healthReasons.join(',') : null, + coverage, + }; + if (degraded && catalogCache) { + const error = new Error( + 'Radio Browser catalog refresh did not meet health policy', + ); + error.radioCatalogDegraded = true; + error.radioDegradedReason = nextCatalog.degradedReason; + error.radioCoverage = coverage; + throw error; + } + if (degraded && !selected.length) { + const error = new Error( + 'Radio Browser catalog refresh returned no usable stations', + ); + error.radioCatalogDegraded = true; + error.radioDegradedReason = nextCatalog.degradedReason; + error.radioCoverage = coverage; + throw error; + } + if (degraded) { + servedStationIds = nextCatalog.stationIds; + return { ...nextCatalog, acceptedGeneration: null }; + } + catalogCache = { + ...nextCatalog, + acceptedGeneration: ++catalogGeneration, + }; + servedStationIds = catalogCache.stationIds; + return catalogCache; + } + + async function getCatalog() { + if ( + catalogCache && + now() - catalogCache.cachedAt < RADIO_DIRECTORY_CACHE_MS + ) { + return { ...catalogCache, stale: false }; + } + if (!refreshPromise) { + refreshPromise = refreshCatalog().finally(() => { + refreshPromise = null; + }); + } + try { + return { ...(await refreshPromise), stale: false }; + } catch (error) { + if ( + catalogCache && + now() - catalogCache.cachedAt <= RADIO_DIRECTORY_STALE_MS + ) { + return { + ...catalogCache, + stale: true, + degraded: true, + degradedReason: error?.radioDegradedReason || 'refresh-failed', + coverage: error?.radioCoverage || catalogCache.coverage, + }; + } + throw error; + } + } + + function sendJson(res, status, body) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); + } + + return async function radioProxyMiddleware(req, res) { + const requestUrl = new URL(req.url || '/', 'http://localhost'); + if (requestUrl.pathname === '/stations') { + if (req.method !== 'GET') { + res.writeHead(405, { Allow: 'GET', 'Cache-Control': 'no-store' }); + res.end(); + return; + } + try { + const catalog = await getCatalog(); + sendJson(res, 200, { + stations: catalog.stations, + updatedAt: catalog.updatedAt, + stale: catalog.stale, + degraded: Boolean(catalog.degraded), + degradedReason: catalog.degradedReason || null, + coverage: catalog.coverage || null, + acceptedGeneration: catalog.acceptedGeneration ?? null, + catalogInstance, + }); + } catch (error) { + sendJson(res, 503, { + error: 'Radio directory is temporarily unavailable', + degraded: Boolean(error?.radioCatalogDegraded), + degradedReason: error?.radioDegradedReason || null, + }); + } + return; + } + + const clickMatch = requestUrl.pathname.match(/^\/click\/([0-9a-f-]+)$/i); + if (clickMatch) { + if (req.method !== 'POST') { + res.writeHead(405, { Allow: 'POST', 'Cache-Control': 'no-store' }); + res.end(); + return; + } + const id = clickMatch[1].toLowerCase(); + if (!RADIO_UUID_RE.test(id) || !servedStationIds.has(id)) { + sendJson(res, 404, { error: 'Unknown radio station' }); + return; + } + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + void fetchPath(`/json/url/${id}`).catch(() => {}); + return; + } + + sendJson(res, 404, { error: 'Unknown radio route' }); + }; +} diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js new file mode 100644 index 000000000..996e2af06 --- /dev/null +++ b/server/providers/radio/constants.js @@ -0,0 +1,19 @@ +export const RADIO_DIRECTORY_CACHE_MS = 45 * 60 * 1000; +export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; +export const RADIO_MIRROR_CACHE_MS = 6 * 60 * 60 * 1000; +export const RADIO_FETCH_TIMEOUT_MS = 12_000; +export const RADIO_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; +export const RADIO_DIRECTORY_LIMIT = 750; +export const RADIO_CATALOG_MIN_SUCCESSFUL_QUERIES = 5; +export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( + RADIO_DIRECTORY_LIMIT / 2, +); +export const RADIO_USER_AGENT = + 'GodsEyeView/1.0 (Radio Browser directory client)'; +export const RADIO_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const RADIO_FALLBACK_MIRRORS = Object.freeze([ + 'https://de1.api.radio-browser.info', + 'https://de2.api.radio-browser.info', + 'https://nl1.api.radio-browser.info', +]); diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js new file mode 100644 index 000000000..4d9bcf35c --- /dev/null +++ b/server/providers/radio/stations.js @@ -0,0 +1,155 @@ +import { RADIO_UUID_RE } from './constants.js'; +import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js'; +export function cleanRadioText(value, maxLength) { + return String(value ?? '') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength) + .trim(); +} + +export function isNonGlobalIpv4(hostname) { + const pieces = hostname.split('.'); + if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) + return false; + const values = pieces.map(Number); + if (values.some((value) => value > 255)) return true; + const [a, b, c] = values; + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 88 && c === 99) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ); +} + +/** Return a normalized public HTTPS URL, or null for local/private targets. */ +export function publicRadioHttpsUrl(value) { + try { + const url = new URL(String(value ?? '')); + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if (url.protocol !== 'https:' || url.username || url.password || !hostname) + return null; + if ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + isNonGlobalIpv4(hostname) || + hostname.includes(':') + ) + return null; + url.hash = ''; + return url.href; + } catch { + return null; + } +} + +/** Normalize one Radio Browser station and omit favicons and unsafe streams. */ +export function normalizeRadioBrowserStation(raw) { + const id = cleanRadioText(raw?.stationuuid, 40).toLowerCase(); + const lat = + raw?.geo_lat === null || raw?.geo_lat === '' ? null : Number(raw?.geo_lat); + const lon = + raw?.geo_long === null || raw?.geo_long === '' + ? null + : Number(raw?.geo_long); + const codec = cleanRadioText(raw?.codec, 16).toUpperCase(); + const streamUrl = publicRadioHttpsUrl(raw?.url_resolved || raw?.url); + if ( + !RADIO_UUID_RE.test(id) || + Number(raw?.lastcheckok) !== 1 || + Number(raw?.hls) === 1 || + !Number.isFinite(lat) || + lat < -90 || + lat > 90 || + !Number.isFinite(lon) || + lon < -180 || + lon > 180 || + !/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(codec) || + !streamUrl + ) + return null; + + const name = cleanRadioText(raw?.name, 140); + if (!name) return null; + const tags = String(raw?.tags ?? '') + .split(',') + .map((tag) => + cleanRadioText(tag, 80) + .toLocaleLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + ) + .filter(Boolean) + .filter((tag, index, all) => all.indexOf(tag) === index) + .slice(0, 24); + const languages = String(raw?.language ?? '') + .split(',') + .map((language) => cleanRadioText(language, 40)) + .filter(Boolean) + .slice(0, 8); + const rawCountryCode = cleanRadioText(raw?.countrycode, 2).toUpperCase(); + const normalizedCode = normalizeRadioCountryInput(rawCountryCode); + const normalizedCountry = + normalizedCode.valid && !normalizedCode.empty + ? normalizedCode + : normalizeRadioCountryInput(cleanRadioText(raw?.country, 80)); + const bitrate = Number(raw?.bitrate); + return { + id, + name, + lat, + lon, + streamUrl, + homepage: publicRadioHttpsUrl(raw?.homepage), + tags, + languages, + state: cleanRadioText(raw?.state, 80), + country: + normalizedCountry.valid && !normalizedCountry.empty + ? normalizedCountry.name + : cleanRadioText(raw?.country, 80), + countryCode: normalizedCountry.valid ? normalizedCountry.code : '', + metadataTrust: 'untrusted-community', + codec, + bitrate: + Number.isInteger(bitrate) && bitrate >= 8 && bitrate <= 1024 + ? bitrate + : null, + clickCount: Math.max(0, Math.min(10_000_000, Number(raw?.clickcount) || 0)), + }; +} + +export function publicRadioStation(station) { + return { + id: station.id, + name: station.name, + lat: station.lat, + lon: station.lon, + streamUrl: station.streamUrl, + homepage: station.homepage, + tags: station.tags, + languages: station.languages, + state: station.state, + country: station.country, + countryCode: station.countryCode, + metadataTrust: station.metadataTrust, + codec: station.codec, + bitrate: station.bitrate, + }; +} diff --git a/server/providers/radio/transport.js b/server/providers/radio/transport.js new file mode 100644 index 000000000..7bbe33bcd --- /dev/null +++ b/server/providers/radio/transport.js @@ -0,0 +1,145 @@ +import https from 'node:https'; +import { Readable } from 'node:stream'; +import { isNonGlobalIpv4 } from './stations.js'; +export function radioMirrorOrigin(value) { + const hostname = String(value ?? '') + .toLowerCase() + .replace(/\.$/, ''); + if (!/^[a-z0-9-]+\.api\.radio-browser\.info$/.test(hostname)) return null; + return `https://${hostname}`; +} + +/** Return whether a resolved Radio Browser address is safe for an outbound request. */ +export function isPublicRadioAddress(value) { + const address = String(value ?? '') + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); + if (!address) return false; + if (!address.includes(':')) { + const ipv4 = address.split('.'); + return ( + ipv4.length === 4 && + ipv4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) && + !isNonGlobalIpv4(address) + ); + } + const pieces = address.split('::'); + if (pieces.length > 2) return false; + const left = pieces[0] ? pieces[0].split(':') : []; + const right = pieces[1] ? pieces[1].split(':') : []; + const missing = 8 - left.length - right.length; + if ( + (pieces.length === 1 && missing !== 0) || + (pieces.length === 2 && missing < 1) + ) + return false; + const groups = [...left, ...Array(Math.max(0, missing)).fill('0'), ...right]; + if ( + groups.length !== 8 || + groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group)) + ) + return false; + const numeric = groups.reduce( + (total, group) => (total << 16n) | BigInt(`0x${group}`), + 0n, + ); + const inCidr = (base, prefix) => { + const shift = 128n - BigInt(prefix); + return numeric >> shift === base >> shift; + }; + const base = (text) => + text + .split(':') + .reduce( + (total, group) => (total << 16n) | BigInt(`0x${group || '0'}`), + 0n, + ); + const cidr = (text, prefix) => inCidr(base(text), prefix); + return ( + cidr('2000:0:0:0:0:0:0:0', 3) && + !cidr('2001:0:0:0:0:0:0:0', 23) && + !cidr('2001:db8:0:0:0:0:0:0', 32) && + !cidr('2002:0:0:0:0:0:0:0', 16) && + !cidr('3fff:0:0:0:0:0:0:0', 20) + ); +} + +export function radioProxyDestination(value) { + let url; + try { + url = new URL(String(value)); + } catch { + return null; + } + const origin = radioMirrorOrigin(url.hostname); + if ( + !origin || + url.origin !== origin || + url.username || + url.password || + url.port || + url.hash + ) + return null; + const discovery = + url.hostname.toLowerCase() === 'all.api.radio-browser.info' && + url.pathname === '/json/servers' && + !url.search; + const directory = url.pathname === '/json/stations/search'; + const click = /^\/json\/url\/[0-9a-f-]+$/i.test(url.pathname) && !url.search; + return discovery || directory || click ? url : null; +} + +export async function resolveRadioProxyAddresses(hostname, lookupImpl) { + const resolved = await lookupImpl(hostname, { all: true, verbatim: true }); + const rows = Array.isArray(resolved) ? resolved : [resolved]; + const addresses = rows + .map((row) => ({ + address: String(row?.address || ''), + family: Number(row?.family) || undefined, + })) + .filter((row) => row.address); + if ( + !addresses.length || + addresses.some((row) => !isPublicRadioAddress(row.address)) + ) { + throw new Error('Radio Browser resolved to a forbidden address'); + } + return addresses; +} + +export function fetchPinnedRadioResponse(url, options, addresses) { + return new Promise((resolve, reject) => { + const address = addresses[0]; + const request = https.request( + url, + { + method: 'GET', + headers: options.headers, + signal: options.signal, + lookup(_hostname, lookupOptions, callback) { + if (lookupOptions?.all) callback(null, addresses); + else callback(null, address.address, address.family); + }, + }, + (response) => { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) + value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + resolve( + new Response(Readable.toWeb(response), { + status: response.statusCode || 500, + statusText: response.statusMessage || '', + headers, + }), + ); + }, + ); + request.on('error', reject); + request.end(); + }); +} diff --git a/server/providers/regional.js b/server/providers/regional.js new file mode 100644 index 000000000..6416d01b2 --- /dev/null +++ b/server/providers/regional.js @@ -0,0 +1,6 @@ +export { + regionalBriefProxy, + regionalBriefHasAnySource, +} from './regional/briefing.js'; +export { weatherEffectsProxy } from './regional/weather-effects.js'; +export { validRegionalPoint } from './regional/query.js'; diff --git a/server/providers/regional/briefing.js b/server/providers/regional/briefing.js new file mode 100644 index 000000000..a0a6e7cdb --- /dev/null +++ b/server/providers/regional/briefing.js @@ -0,0 +1,155 @@ +import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; +import { fetchRegionalPlace } from './place.js'; +import { fetchRegionalWeather } from './weather.js'; +import { fetchRegionalNews } from './news.js'; +import { validRegionalPoint } from './query.js'; +import { coalesceProxyRequest } from '../common/http.js'; + +// --------------------------------------------------------------------------- +// Regional cockpit briefing proxy +// --------------------------------------------------------------------------- +const REGIONAL_BRIEF_CACHE_MS = 5 * 60_000; + +const REGIONAL_BRIEF_STALE_MS = 60 * 60_000; + +const REGIONAL_BRIEF_MAX_CACHE = 120; + +const _regionalBriefCache = new Map(); + +const _regionalBriefInFlight = new Map(); + +const _regionalBriefRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 30, + globalMax: 90, +}); + +function trimRegionalBriefCache() { + while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) { + const oldest = _regionalBriefCache.keys().next().value; + if (oldest === undefined) break; + _regionalBriefCache.delete(oldest); + } +} + +/** True when at least one regional source produced usable data. */ +function regionalBriefHasAnySource({ place, weather, news } = {}) { + return Boolean(place || weather || (news && news.status !== 'unavailable')); +} + +function regionalBriefProxy() { + async function refresh(point, key) { + const [placeResult, weatherResult] = await Promise.allSettled([ + fetchRegionalPlace(point), + fetchRegionalWeather(point), + ]); + const place = placeResult.status === 'fulfilled' ? placeResult.value : null; + const weather = + weatherResult.status === 'fulfilled' ? weatherResult.value : null; + const news = await fetchRegionalNews(place); + if (!regionalBriefHasAnySource({ place, weather, news })) { + throw new Error('All regional briefing sources unavailable'); + } + const payload = { + status: + place && weather && news.status !== 'unavailable' ? 'ready' : 'partial', + retrievedAt: new Date().toISOString(), + coordinates: point, + place, + placeStatus: place ? 'ready' : 'unavailable', + weather, + weatherStatus: weather ? 'ready' : 'unavailable', + newsStatus: news.status, + newsQuery: news.query, + newsSource: news.source, + articles: news.articles, + }; + _regionalBriefCache.set(key, { payload, cachedAt: Date.now() }); + trimRegionalBriefCache(); + return payload; + } + + function install(middlewares) { + middlewares.use('/api/regional-brief', async (req, res) => { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + if (!_regionalBriefRateLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '', 'http://localhost'); + const point = validRegionalPoint(url.searchParams); + if (!point) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Valid latitude and longitude are required', + }), + ); + return; + } + const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`; + const now = Date.now(); + const cached = _regionalBriefCache.get(key); + if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60', + 'X-Regional-Brief': 'HIT', + }); + res.end(JSON.stringify({ ...cached.payload, status: 'cached' })); + return; + } + const request = coalesceProxyRequest(_regionalBriefInFlight, key, () => + refresh(point, key), + ); + try { + const payload = await request.promise; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60', + 'X-Regional-Brief': request.shared ? 'INFLIGHT' : 'MISS', + }); + res.end(JSON.stringify(payload)); + } catch { + if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Regional-Brief': 'STALE', + }); + res.end(JSON.stringify({ ...cached.payload, status: 'stale' })); + return; + } + res.writeHead(503, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'Regional briefing is temporarily unavailable', + }), + ); + } + }); + } + + return { + name: 'regional-brief-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { regionalBriefHasAnySource, regionalBriefProxy }; diff --git a/server/providers/regional/http.js b/server/providers/regional/http.js new file mode 100644 index 000000000..9d8a7bc1e --- /dev/null +++ b/server/providers/regional/http.js @@ -0,0 +1,46 @@ +import { + readResponseJsonCapped, + readResponseTextCapped, +} from '../common/http.js'; + +const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +async function fetchRegionalJson( + url, + { + headers = {}, + timeoutMs = 9000, + maxBytes = REGIONAL_MAX_RESPONSE_BYTES, + } = {}, +) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal, headers }); + if (!response.ok) throw new Error(`Upstream returned ${response.status}`); + return readResponseJsonCapped(response, maxBytes); + } finally { + clearTimeout(timeout); + } +} + +async function fetchRegionalText( + url, + { + headers = {}, + timeoutMs = 9000, + maxBytes = REGIONAL_MAX_RESPONSE_BYTES, + } = {}, +) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal, headers }); + if (!response.ok) throw new Error(`Upstream returned ${response.status}`); + return readResponseTextCapped(response, maxBytes); + } finally { + clearTimeout(timeout); + } +} + +export { fetchRegionalJson, fetchRegionalText }; diff --git a/server/providers/regional/news.js b/server/providers/regional/news.js new file mode 100644 index 000000000..1a4954986 --- /dev/null +++ b/server/providers/regional/news.js @@ -0,0 +1,112 @@ +import { fetchRegionalText, fetchRegionalJson } from './http.js'; +import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js'; + +function decodeRssText(value) { + return String(value || '') + .replace(//g, '$1') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function rssTag(block, tag) { + return decodeRssText( + new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec( + block, + )?.[1] || '', + ); +} + +function normalizeRssArticles(xml, limit = 5) { + const seen = new Set(); + const articles = []; + for (const match of String(xml || '').matchAll( + /([\s\S]*?)<\/item>/gi, + )) { + const item = match[1]; + const title = rssTag(item, 'title').slice(0, 180); + const url = rssTag(item, 'link'); + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + continue; + } + if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue; + const source = rssTag(item, 'source'); + const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`; + if (seen.has(signature)) continue; + seen.add(signature); + const rawDate = rssTag(item, 'pubDate'); + articles.push({ + title, + url: parsedUrl.href, + domain: source || parsedUrl.hostname.replace(/^www\./, ''), + publishedAt: Number.isNaN(Date.parse(rawDate)) + ? null + : new Date(rawDate).toISOString(), + sourceCountry: null, + }); + if (articles.length >= limit) break; + } + return articles; +} + +async function fetchRegionalNews(place) { + const query = place?.locality || place?.region || place?.country; + if (!query) + return { status: 'unavailable', query: null, articles: [], source: null }; + const rssParams = new URLSearchParams({ + q: String(query).replace(/["\\]/g, ' ').trim(), + hl: 'en-US', + gl: 'US', + ceid: 'US:en', + }); + try { + const xml = await fetchRegionalText( + `https://news.google.com/rss/search?${rssParams}`, + { + headers: { 'User-Agent': 'GodsEyeView/0.1' }, + timeoutMs: 12_000, + }, + ); + const articles = normalizeRssArticles(xml, 5); + if (articles.length) + return { status: 'ready', query, articles, source: 'Google News RSS' }; + } catch { + /* fall through to the existing free index */ + } + const params = new URLSearchParams({ + query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`, + mode: 'artlist', + format: 'json', + maxrecords: '5', + sort: 'datedesc', + timespan: '48h', + }); + try { + const payload = await fetchRegionalJson( + `https://api.gdeltproject.org/api/v2/doc/doc?${params}`, + { + headers: { 'User-Agent': 'GodsEyeView/0.1' }, + timeoutMs: 12_000, + }, + ); + const articles = normalizeRegionalArticles(payload, 5); + return { + status: articles.length ? 'ready' : 'empty', + query, + articles, + source: 'GDELT fallback', + }; + } catch { + return { status: 'unavailable', query, articles: [], source: null }; + } +} + +export { fetchRegionalNews }; diff --git a/server/providers/regional/place.js b/server/providers/regional/place.js new file mode 100644 index 000000000..2ee706015 --- /dev/null +++ b/server/providers/regional/place.js @@ -0,0 +1,37 @@ +import { fetchRegionalJson } from './http.js'; +import { normalizeRegionalPlace } from '../../../src/data/regionalBrief.js'; + +let _nominatimQueue = Promise.resolve(); + +let _nominatimLastRequestAt = 0; + +function fetchRegionalPlace(point) { + const task = _nominatimQueue.then(async () => { + const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt)); + if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); + _nominatimLastRequestAt = Date.now(); + const params = new URLSearchParams({ + format: 'jsonv2', + lat: point.latitude.toFixed(5), + lon: point.longitude.toFixed(5), + zoom: '10', + addressdetails: '1', + 'accept-language': 'en', + }); + const payload = await fetchRegionalJson( + `https://nominatim.openstreetmap.org/reverse?${params}`, + { + headers: { + 'User-Agent': + 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)', + Referer: 'https://github.com/bilawalsidhu/gods-eye-view', + }, + }, + ); + return normalizeRegionalPlace(payload); + }); + _nominatimQueue = task.catch(() => null); + return task; +} + +export { fetchRegionalPlace }; diff --git a/server/providers/regional/query.js b/server/providers/regional/query.js new file mode 100644 index 000000000..07700546d --- /dev/null +++ b/server/providers/regional/query.js @@ -0,0 +1,12 @@ +import { requiredFiniteQueryNumber } from '../common/query.js'; + +function validRegionalPoint(params) { + const latitude = requiredFiniteQueryNumber(params, 'latitude'); + const longitude = requiredFiniteQueryNumber(params, 'longitude'); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) + return null; + return { latitude, longitude }; +} + +export { validRegionalPoint }; diff --git a/server/providers/regional/weather-effects.js b/server/providers/regional/weather-effects.js new file mode 100644 index 000000000..7cf5cb4f3 --- /dev/null +++ b/server/providers/regional/weather-effects.js @@ -0,0 +1,128 @@ +import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; +import { fetchRegionalWeather } from './weather.js'; +import { validRegionalPoint } from './query.js'; +import { coalesceProxyRequest } from '../common/http.js'; + +const WEATHER_EFFECTS_CACHE_MS = 5 * 60_000; + +const WEATHER_EFFECTS_STALE_MS = 30 * 60_000; + +const WEATHER_EFFECTS_MAX_CACHE = 180; + +const _weatherEffectsCache = new Map(); + +const _weatherEffectsInFlight = new Map(); + +const _weatherEffectsRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 45, + globalMax: 120, +}); + +function trimWeatherEffectsCache() { + while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) { + const oldest = _weatherEffectsCache.keys().next().value; + if (oldest === undefined) break; + _weatherEffectsCache.delete(oldest); + } +} + +function weatherEffectsProxy() { + async function refresh(point, key) { + const weather = await fetchRegionalWeather(point); + if (!weather) throw new Error('Weather observation unavailable'); + const payload = { + status: 'ready', + retrievedAt: new Date().toISOString(), + coordinates: point, + weather, + }; + _weatherEffectsCache.set(key, { payload, cachedAt: Date.now() }); + trimWeatherEffectsCache(); + return payload; + } + + function install(middlewares) { + middlewares.use('/api/weather-effects', async (req, res) => { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + if (!_weatherEffectsRateLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '', 'http://localhost'); + const point = validRegionalPoint(url.searchParams); + if (!point) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Valid latitude and longitude are required', + }), + ); + return; + } + const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`; + const now = Date.now(); + const cached = _weatherEffectsCache.get(key); + if (cached && now - cached.cachedAt <= WEATHER_EFFECTS_CACHE_MS) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60', + 'X-Weather-Effects': 'HIT', + }); + res.end(JSON.stringify({ ...cached.payload, status: 'cached' })); + return; + } + const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () => + refresh(point, key), + ); + try { + const payload = await request.promise; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60', + 'X-Weather-Effects': request.shared ? 'INFLIGHT' : 'MISS', + }); + res.end(JSON.stringify(payload)); + } catch { + if (cached && now - cached.cachedAt <= WEATHER_EFFECTS_STALE_MS) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Weather-Effects': 'STALE', + }); + res.end(JSON.stringify({ ...cached.payload, status: 'stale' })); + return; + } + res.writeHead(503, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'Weather effects are temporarily unavailable', + }), + ); + } + }); + } + + return { + name: 'weather-effects-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { weatherEffectsProxy }; diff --git a/server/providers/regional/weather.js b/server/providers/regional/weather.js new file mode 100644 index 000000000..2b7ef8b4b --- /dev/null +++ b/server/providers/regional/weather.js @@ -0,0 +1,27 @@ +import { fetchRegionalJson } from './http.js'; +import { normalizeRegionalWeather } from '../../../src/data/regionalBrief.js'; + +const WEATHER_EFFECTS_MAX_RESPONSE_BYTES = 512 * 1024; + +async function fetchRegionalWeather(point) { + const params = new URLSearchParams({ + latitude: point.latitude.toFixed(5), + longitude: point.longitude.toFixed(5), + current: + 'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility', + timezone: 'UTC', + }); + try { + const payload = await fetchRegionalJson( + `https://api.open-meteo.com/v1/forecast?${params}`, + { + maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES, + }, + ); + return normalizeRegionalWeather(payload); + } catch { + return null; + } +} + +export { fetchRegionalWeather }; diff --git a/server/providers/space.js b/server/providers/space.js new file mode 100644 index 000000000..bf2a2da4e --- /dev/null +++ b/server/providers/space.js @@ -0,0 +1,7 @@ +/** Satellite and launch-feed middleware for Node development servers. */ +export { celestrakProxy } from './space/celestrak.js'; +export { + rocketLaunchesProxy, + LL2_CACHE_TTL_MS, + launchLibraryRequestHeaders, +} from './space/launch-library.js'; diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js new file mode 100644 index 000000000..a76cc590d --- /dev/null +++ b/server/providers/space/celestrak.js @@ -0,0 +1,139 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; + +/** + * Vite plugin: CelesTrak TLE proxy. + * + * CelesTrak does not send CORS headers, so this middleware fetches + * satellite TLE data server-side and forwards it to the browser. + * Upstream URL: https://celestrak.org/NORAD/elements/gp.php + * + * @returns {import('vite').Plugin} + */ +/** + * CelesTrak GP/TLE proxy with a memory + disk cache. + * Upstream: https://celestrak.org/NORAD/elements/gp.php?GROUP=&FORMAT=tle + * CelesTrak asks clients not to re-fetch GP data more than ~every 2 h and + * throttles offenders; every dev reload used to refetch every group. Cache TTL + * 6 h; on upstream failure the freshest stale copy is served (a stale TLE + * beats an empty satellites layer). Pattern mirrors openSkyProxy's + * cache+serve-stale. Adapted from skylight's TleStore (MIT). + */ +export function celestrakProxy() { + const TLE_TTL_MS = 6 * 3600_000; + const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const mem = new Map(); // group -> { at: epochMs, body: string } + const inflight = new Map(); // group -> Promise<{at, body}|null> + + const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`); + + async function readDisk(group) { + try { + const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8')); + if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at)) + return parsed; + } catch { + /* no disk cache yet */ + } + return null; + } + + async function writeDisk(group, entry) { + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8'); + } catch (err) { + console.warn('[celestrak-proxy] cache write failed'); + } + } + + async function fetchUpstream(group) { + const url = celestrakTleUrl(group); + const res = await fetch(url.toString(), { + signal: AbortSignal.timeout(20000), + // CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a + // descriptive User-Agent with a contact point. + headers: { + 'User-Agent': + 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', + }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.text(); + // An upstream error page parses to zero TLEs — treat as failure, keep cache. + if (!/^1 /m.test(body)) throw new Error('no TLE lines in response'); + return { at: Date.now(), body }; + } + + const installMiddleware = (server) => { + server.middlewares.use('/api/celestrak', async (req, res) => { + const group = String(req.url || '') + .replace(/^\//, '') + .split('?')[0]; + if (!/^[a-z0-9-]+$/i.test(group)) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('invalid group'); + return; + } + const send = (status, body, cacheStatus) => { + // Guard against a double-send (e.g. a throw AFTER a response already + // went out routing into the catch's send): writeHead after headersSent + // throws "Cannot set headers after they are sent". + if (res.headersSent) return; + res.writeHead(status, { + 'Content-Type': 'text/plain', + 'x-tle-cache': cacheStatus, + }); + res.end(body); + }; + try { + const now = Date.now(); + let entry = mem.get(group); + if (!entry) { + entry = await readDisk(group); + if (entry) mem.set(group, entry); + } + if (entry && now - entry.at < TLE_TTL_MS) { + send(200, entry.body, 'HIT'); + return; + } + // Stale or missing → refresh, single-flight per group. + if (!inflight.has(group)) { + inflight.set( + group, + fetchUpstream(group) + .then(async (fresh) => { + mem.set(group, fresh); + await writeDisk(group, fresh); + return fresh; + }) + .catch((err) => { + console.warn( + '[celestrak-proxy] refresh failed — serving cache if any', + ); + return null; + }) + .finally(() => inflight.delete(group)), + ); + } + const fresh = await inflight.get(group); + if (fresh) { + send(200, fresh.body, 'MISS'); + } else if (entry) { + send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + } else { + send(502, 'celestrak fetch failed and no cache available', 'NONE'); + } + } catch (err) { + console.error('[celestrak-proxy] request failed'); + send(500, 'celestrak proxy error', 'ERROR'); + } + }); + }; + return { + name: 'celestrak-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js new file mode 100644 index 000000000..50825df03 --- /dev/null +++ b/server/providers/space/launch-library.js @@ -0,0 +1,144 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { + readResponseTextCapped, + coalesceProxyRequest, +} from '../common/http.js'; +import { launchLibraryRecentUrl } from '../../../src/data/spaceProviderRequests.js'; + +export const LL2_CACHE_TTL_MS = 15 * 60_000; + +/** Build LL2 request headers without exposing its optional token client-side. */ +export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) { + const normalized = String(token || '').trim(); + return { + Accept: 'application/json', + ...(normalized ? { Authorization: `Token ${normalized}` } : {}), + }; +} + +/** Proxy the public Launch Library 2 recent-launch feed server-side. */ +export function rocketLaunchesProxy() { + const ttlMs = LL2_CACHE_TTL_MS; + const maxResponseBytes = 12 * 1024 * 1024; + const maxDiskCacheBytes = 24 * 1024 * 1024; + const cachePath = path.join( + process.cwd(), + '.gev-cache', + 'launch-library-2-v2.3.json', + ); + let cache = null; + let diskLoaded = false; + const inFlight = new Map(); + + async function loadDiskCache() { + if (diskLoaded) return; + diskLoaded = true; + try { + const stat = await fsp.stat(cachePath); + if (stat.size > maxDiskCacheBytes) + throw new Error('cache file too large'); + const parsed = JSON.parse(await fsp.readFile(cachePath, 'utf8')); + if (Number.isFinite(parsed?.at) && typeof parsed?.body === 'string') { + const body = JSON.parse(parsed.body); + if (Array.isArray(body?.results)) cache = parsed; + } + } catch { + /* first run or invalid cache */ + } + } + + async function saveDiskCache(entry) { + try { + await fsp.mkdir(path.dirname(cachePath), { recursive: true }); + await fsp.writeFile(cachePath, JSON.stringify(entry), 'utf8'); + } catch (error) { + console.warn('[launch-library-proxy] cache write failed'); + } + } + + function send(res, status, body, cacheState) { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store', + 'X-GEV-Cache': cacheState, + }); + res.end(body); + } + + async function refreshUpstream() { + const end = new Date(); + const url = launchLibraryRecentUrl(end); + const upstream = await fetch(url, { + signal: AbortSignal.timeout(20000), + headers: launchLibraryRequestHeaders(), + }); + const body = await readResponseTextCapped(upstream, maxResponseBytes); + if (!upstream.ok) { + const error = new Error(`upstream HTTP ${upstream.status}`); + error.upstreamStatus = upstream.status; + throw error; + } + const parsed = JSON.parse(body); + if (!Array.isArray(parsed?.results)) + throw new Error('malformed upstream response'); + const fresh = { at: Date.now(), body }; + cache = fresh; + void saveDiskCache(fresh); + return fresh; + } + + function install(middlewares) { + middlewares.use('/api/launches', async (req, res) => { + if (req.method !== 'GET') { + send(res, 405, JSON.stringify({ error: 'Method Not Allowed' }), 'NONE'); + return; + } + await loadDiskCache(); + const now = Date.now(); + if (cache && now - cache.at < ttlMs) { + send(res, 200, cache.body, 'HIT'); + return; + } + const stale = cache; + const request = coalesceProxyRequest( + inFlight, + 'recent-launches', + refreshUpstream, + ); + try { + const fresh = await request.promise; + send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); + } catch (error) { + // Log only a bounded status, never upstream bodies, URLs, or credentials. + const status = Number.isInteger(error?.upstreamStatus) + ? error.upstreamStatus + : 502; + if (!request.shared) + console.warn( + `[launch-library-proxy] refresh failed (HTTP ${status})${stale ? ' — serving stale cache' : ''}`, + ); + if (stale) { + send(res, 200, stale.body, 'STALE-ERROR'); + return; + } + send( + res, + status, + JSON.stringify({ error: 'Launch Library 2 unavailable' }), + 'NONE', + ); + } + }); + } + + return { + name: 'rocket-launches-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} diff --git a/server/providers/terrain.js b/server/providers/terrain.js new file mode 100644 index 000000000..e58a63ea5 --- /dev/null +++ b/server/providers/terrain.js @@ -0,0 +1,219 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; + +import { + fetchTerrainChunkWithRetry, + parseTerrainPoints, + resolveTerrainHeightRequest, + terrainPointKey, + validTerrainResult, +} from '../../src/data/terrainHeightsProxy.js'; + +/** + * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height + * lookups, keyless. Upstream: https://terrain.reearth.land/heights.json + * (UPSTREAM_CHUNK points per call — sized against measured latency, see + * below). Terrain doesn't move, so results are cached to + * disk with a long TTL (30 days) — mirrors celestrakProxy's memory+disk + * cache and serve-stale shape. Cache entries and stale fallback are keyed per + * 5dp point, so reordered and partially overlapping batches reuse prior work. + * Only missing/stale points go upstream; the response is rebuilt in exact + * request order. Larger requests are chunked sequentially, and one failing + * chunk does not discard the chunks that resolved. + */ +export function terrainHeightsProxy() { + const TTL_MS = 30 * 24 * 3600_000; + const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json'); + // Sized against measured upstream latency, not the documented page cap. + // Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12, + // a 2x swing within one hour). 256 points therefore costs 22-48 s and blows + // the 30 s attempt timeout whenever the service is slow — the long-standing + // cause of "[terrain-heights-proxy] refresh incomplete" (owner incident + // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of + // that range. + const UPSTREAM_CHUNK = 64; + const MAX_POINTS = 2000; + + /** @type {Map} keyed by canonical 5dp lon/lat. */ + const mem = new Map(); + /** @type {Map>>} single-flight per missing-point subset. */ + const inflight = new Map(); + let diskLoaded = false; + let diskDirty = false; + + /** Load the on-disk cache into memory once, lazily (first request only). */ + async function loadDiskOnce() { + if (diskLoaded) return; + diskLoaded = true; + try { + const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); + const pointEntries = + parsed?.version === 2 && + parsed.points && + typeof parsed.points === 'object' + ? parsed.points + : null; + if (pointEntries) { + for (const [key, entry] of Object.entries(pointEntries)) { + if ( + entry && + Number.isFinite(entry.at) && + validTerrainResult(entry.result) + ) { + mem.set(key, entry); + } + } + } else if (parsed && typeof parsed === 'object') { + // One-time migration from the former raw-batch cache. Zip only real, + // positionally present results; an absent value never becomes height 0. + for (const [rawPoints, entry] of Object.entries(parsed)) { + const points = parseTerrainPoints(rawPoints); + if ( + !points || + !entry || + !Number.isFinite(entry.at) || + !Array.isArray(entry.results) + ) + continue; + for (let i = 0; i < points.length; i += 1) { + const result = entry.results[i]; + if (!validTerrainResult(result)) continue; + const key = terrainPointKey(points[i]); + const existing = mem.get(key); + if (!existing || entry.at > existing.at) + mem.set(key, { at: entry.at, result }); + } + } + diskDirty = mem.size > 0; + } + } catch { + /* no disk cache yet */ + } + // Periodic flush, same shape as adsbdbProxy: coalesce writes instead of + // hitting disk on every request. + setInterval(async () => { + if (!diskDirty) return; + diskDirty = false; + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + const obj = { version: 2, points: Object.fromEntries(mem.entries()) }; + await fsp.writeFile(CACHE_PATH, JSON.stringify(obj), 'utf8'); + } catch (err) { + diskDirty = true; // retry next tick + console.warn('[terrain-heights-proxy] cache write failed'); + } + }, 15_000).unref?.(); + } + + /** + * Fetch all missing chunks sequentially, UPSTREAM_CHUNK points per call. + * + * The first try keeps its empirically required 30s timeout; network errors, + * 429, and 5xx receive up to three jittered retries sharing a 10s added-time + * budget, with Retry-After honored within that bound. + * + * A chunk that still fails yields nulls for its own positions rather than + * rejecting: with several chunks per request, throwing would discard every + * chunk that did resolve and re-fetch it on the next poll. The resolver + * caches the resolved positions and reports the null ones as omitted. Total + * failure (nothing resolved) still rejects, so the real upstream error + * reaches the caller instead of a generic omission count. + * @param {Array<[number, number]>} points + * @returns {Promise>} + */ + async function fetchUpstreamAll(points) { + const results = []; + let firstError = null; + for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) { + const chunk = points.slice(i, i + UPSTREAM_CHUNK); + let chunkResults = []; + try { + chunkResults = await fetchTerrainChunkWithRetry(chunk); + } catch (error) { + firstError = firstError || error; + } + // Keep later chunks aligned even if a malformed upstream response omits + // trailing positions. The resolver will reject each null individually. + for (let j = 0; j < chunk.length; j += 1) + results.push(chunkResults[j] ?? null); + } + if (firstError && results.every((result) => result == null)) + throw firstError; + return results; + } + + /** Coalesce concurrent requests for the same canonical missing-point list. */ + function fetchMissingSingleFlight(points) { + const key = points.map(terrainPointKey).join(';'); + if (!inflight.has(key)) { + const request = fetchUpstreamAll(points).finally(() => { + if (inflight.get(key) === request) inflight.delete(key); + }); + inflight.set(key, request); + } + return inflight.get(key); + } + + const installMiddleware = (server) => { + server.middlewares.use('/api/terrain/heights', async (req, res) => { + const send = (status, bodyObj) => { + if (res.headersSent) return; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(bodyObj)); + }; + try { + await loadDiskOnce(); + const parsedUrl = new URL(req.url || '', 'http://internal'); + const rawPoints = parsedUrl.searchParams.get('points'); + const points = parseTerrainPoints(rawPoints); + if (!points) { + send(400, { + error: + 'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers', + }); + return; + } + if (points.length > MAX_POINTS) { + send(500, { + error: `too many points (${points.length}); max ${MAX_POINTS} per request`, + }); + return; + } + + const outcome = await resolveTerrainHeightRequest({ + points, + cache: mem, + fetchMissing: fetchMissingSingleFlight, + ttlMs: TTL_MS, + }); + if (outcome.cacheChanged) diskDirty = true; + if (outcome.upstreamError) { + console.warn( + '[terrain-heights-proxy] refresh incomplete' + + ' — serving stale points when available', + ); + } else if (outcome.absentPoints > 0) { + // Not a refresh failure. The upstream answered every position and + // returned a null height for a few of them (~0.16%, transient); + // the next poll re-asks and the client meanwhile resolves those + // through its bundled geoid. Informational, not actionable. + console.info( + `[terrain-heights-proxy] ${outcome.absentPoints}` + + ` of ${outcome.requestedPoints} position(s) had no upstream` + + ' height this poll — retrying next cycle', + ); + } + send(outcome.status, outcome.body); + } catch (err) { + console.error('[terrain-heights-proxy] request failed'); + send(500, { error: 'terrain heights proxy error' }); + } + }); + }; + return { + name: 'terrain-heights-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/traffic.js b/server/providers/traffic.js new file mode 100644 index 000000000..fda118753 --- /dev/null +++ b/server/providers/traffic.js @@ -0,0 +1,266 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; + +import { + isValidTileCoord as isValidTomTomTile, + utcDayKey as tomtomUtcDayKey, + normalizeBudget as normalizeTomTomBudget, + isOverBudget as isTomTomOverBudget, +} from '../../src/data/tomtomTiles.js'; + +/** + * TomTom traffic-flow vector-tile proxy with a daily budget governor. + * + * Upstream: https://api.tomtom.com/traffic/map/4/tile/flow/relative/{z}/{x}/{y}.pbf + * (style `relative`; the response is an UNCOMPRESSED Mapbox Vector Tile, layer + * "Traffic flow"). The key comes from TOMTOM_API_KEY server-side only — the + * browser fetches same-origin `/api/tomtom/flow/{z}/{x}/{y}.pbf`. + * + * Cache: memory + disk (.gev-cache/tomtom/), TTL 120 s (traffic is fresh + * data), single-flight per tile, serve-stale-on-failure — the celestrakProxy + * pattern. Cache hits never count against the budget. + * + * Budget governor (mirrors the OpenSky credit-governor philosophy — last-good + * data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json, + * keyed by UTC date, reset on day change) counts upstream fetch attempts + * against a soft cap (TOMTOM_DAILY_TILE_BUDGET, default 40,000 of the free + * tier's ~50k/day). Over the cap the proxy serves stale tiles when available, + * else 429 {error:'budget'}. + * + * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: + * status reports hasKey:false and the tile endpoint 503s {error:'no_key'} + * without touching upstream — the traffic layer then stays in simulation mode. + * + * @returns {import('vite').Plugin} + */ +export function tomtomProxy() { + const TILE_TTL_MS = 120_000; + const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom'); + const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json'); + const DEFAULT_DAILY_BUDGET = 40000; + const MEM_MAX_ENTRIES = 256; + const UPSTREAM_TIMEOUT_MS = 15000; + + /** @type {Map} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */ + const mem = new Map(); + /** @type {Map>} single-flight per tile. */ + const inflight = new Map(); + + /** @type {{date:string, count:number}|null} lazily-loaded persistent counter. */ + let budget = null; + let budgetLoaded = false; + + function dailyBudgetLimit() { + const raw = Number.parseInt(process.env.TOMTOM_DAILY_TILE_BUDGET || '', 10); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DAILY_BUDGET; + } + + async function loadBudgetOnce() { + if (budgetLoaded) return; + budgetLoaded = true; + try { + const parsed = JSON.parse(await fsp.readFile(BUDGET_PATH, 'utf8')); + if ( + parsed && + typeof parsed.date === 'string' && + Number.isFinite(parsed.count) + ) { + budget = parsed; + } + } catch { + /* no budget file yet */ + } + } + + async function persistBudget() { + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + await fsp.writeFile(BUDGET_PATH, JSON.stringify(budget), 'utf8'); + } catch (err) { + console.warn('[tomtom-proxy] budget write failed:', err?.message || err); + } + } + + /** Roll the counter to today (UTC) and return it. */ + function currentBudget() { + budget = normalizeTomTomBudget(budget, tomtomUtcDayKey()); + return budget; + } + + /** Count one upstream fetch attempt against today's budget (async persist). */ + function recordUpstreamFetch() { + currentBudget().count += 1; + void persistBudget(); + } + + const tilePath = (key) => + path.join(CACHE_DIR, `flow-${key.replaceAll('/', '-')}.pbf`); + + /** Disk-cache read; tile age comes from the file's mtime. */ + async function readDiskTile(key) { + try { + const [stat, buf] = await Promise.all([ + fsp.stat(tilePath(key)), + fsp.readFile(tilePath(key)), + ]); + return { at: stat.mtimeMs, buf }; + } catch { + return null; + } + } + + async function writeDiskTile(key, buf) { + try { + await fsp.mkdir(CACHE_DIR, { recursive: true }); + await fsp.writeFile(tilePath(key), buf); + } catch (err) { + console.warn( + `[tomtom-proxy] tile cache write failed for ${key}:`, + err?.message || err, + ); + } + } + + /** LRU-ish memory insert (Map preserves insertion order; evict the oldest). */ + function memSet(key, entry) { + if (!mem.has(key) && mem.size >= MEM_MAX_ENTRIES) { + const oldest = mem.keys().next().value; + mem.delete(oldest); + } + mem.set(key, entry); + } + + async function fetchUpstream(z, x, y) { + const url = + 'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' + + `${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`; + recordUpstreamFetch(); // attempts count — upstream bills the request either way + const res = await fetch(url, { + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length === 0) throw new Error('empty tile body'); + return buf; + } + + const installMiddleware = (server) => { + server.middlewares.use('/api/tomtom', async (req, res) => { + // Sanitized responses only (proxy/security baseline): no upstream + // error details, and never echo the key or the upstream URL. + const sendJson = (status, obj, extraHeaders = {}) => { + if (res.headersSent) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...extraHeaders, + }); + res.end(JSON.stringify(obj)); + }; + const sendTile = (buf, cacheStatus) => { + if (res.headersSent) return; + res.writeHead(200, { + 'Content-Type': 'application/x-protobuf', + 'Cache-Control': 'no-store', + 'x-tomtom-cache': cacheStatus, + }); + res.end(buf); + }; + + try { + await loadBudgetOnce(); + const urlPath = String(req.url || '').split('?')[0]; + + if (urlPath === '/status') { + const hasKey = Boolean(process.env.TOMTOM_API_KEY); + const b = currentBudget(); + sendJson(200, { + hasKey, + dailyCount: b.count, + budget: dailyBudgetLimit(), + date: b.date, + }); + return; + } + + const m = urlPath.match(/^\/flow\/(\d+)\/(\d+)\/(\d+)\.pbf$/); + if (!m) { + sendJson(404, { error: 'not_found' }); + return; + } + const z = Number(m[1]); + const x = Number(m[2]); + const y = Number(m[3]); + if (!isValidTomTomTile(z, x, y)) { + sendJson(400, { error: 'invalid_tile' }); + return; + } + if (!process.env.TOMTOM_API_KEY) { + sendJson(503, { error: 'no_key' }); + return; + } + + const key = `${z}/${x}/${y}`; + const now = Date.now(); + + let entry = mem.get(key); + if (!entry) { + entry = await readDiskTile(key); + if (entry) memSet(key, entry); + } + // Fresh cache hit — never counts against the budget. + if (entry && now - entry.at < TILE_TTL_MS) { + sendTile(entry.buf, 'HIT'); + return; + } + + // Budget governor: over the soft cap, last-good data beats a dead layer. + if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) { + if (entry) { + sendTile(entry.buf, 'STALE-BUDGET'); + } else { + sendJson(429, { error: 'budget' }); + } + return; + } + + // Stale or missing → refresh, single-flight per tile. + if (!inflight.has(key)) { + inflight.set( + key, + fetchUpstream(z, x, y) + .then(async (buf) => { + const fresh = { at: Date.now(), buf }; + memSet(key, fresh); + await writeDiskTile(key, buf); + return fresh; + }) + .catch((err) => { + console.warn( + `[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`, + ); + return null; + }) + .finally(() => inflight.delete(key)), + ); + } + const fresh = await inflight.get(key); + if (fresh) { + sendTile(fresh.buf, 'MISS'); + } else if (entry) { + sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty + } else { + sendJson(502, { error: 'upstream' }); + } + } catch (err) { + console.warn('[tomtom-proxy] error:', err?.message || err); + sendJson(500, { error: 'proxy' }); + } + }); + }; + return { + name: 'tomtom-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, + }; +} diff --git a/server/providers/vessels/ais-live.js b/server/providers/vessels/ais-live.js new file mode 100644 index 000000000..2f8f8a25f --- /dev/null +++ b/server/providers/vessels/ais-live.js @@ -0,0 +1,406 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { createAisStreamAdapter } from '../../../src/data/aisStreamAdapter.js'; +import { parseSilenceTimeoutEnv } from '../../../src/data/aisWatchdog.js'; +import { clampInt } from '../common/query.js'; +import { + AISSTREAM_CACHE_MAX, + AISSTREAM_STALE_MS, + ingestAisStreamEnvelope, + readAisTrack, + aisStreamRows, + newestAisPositionAt, +} from './ais-store.js'; +// --------------------------------------------------------------------------- +// AISStream live vessel cache state +// --------------------------------------------------------------------------- +const AISSTREAM_URL = 'wss://stream.aisstream.io/v0/stream'; +const AISSTREAM_DEFAULT_BBOXES = [ + [ + [-90, -180], + [90, 180], + ], +]; +const AISSTREAM_DEFAULT_MESSAGE_TYPES = [ + 'PositionReport', + 'StandardClassBPositionReport', + 'ExtendedClassBPositionReport', + 'ShipStaticData', + 'StaticDataReport', +]; +// Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is +// REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within +// ~2 min, but recycling the socket is throttled so recovery can never become a +// reconnect cycle against AISStream's one-connection-per-key limit. +const AISSTREAM_SILENCE_REPORT_MS = 120_000; +/** Recycle threshold as a multiple of the report threshold. */ +const AISSTREAM_RECYCLE_RATIO = 2.5; +const AISSTREAM_BACKOFF_MS = Object.freeze([5_000, 15_000, 60_000, 300_000]); +/** Slow retry cadence once the ladder is spent and the feed reads DOWN. */ +const AISSTREAM_DOWN_RETRY_MS = 900_000; +/** + * Probe cadence while AISStream is rejecting the key. Retrying cannot fix a + * bad credential, so this exists only to recover from an upstream-side + * mistake — it must never approach the ladder's pace. + */ +const AISSTREAM_AUTH_PROBE_MS = 3_600_000; +/** How often the watchdog re-evaluates without request traffic. */ +const AISSTREAM_TICK_MS = 15_000; + +/** + * @type {ReturnType|null} + * Module-lifetime: it owns the socket-generation namespace, which must never + * restart across a dev-server reload (see aisStreamAdapter.js ownership rules). + */ +let _aisAdapter = null; +/** @type {{silenceWatch:boolean,reportMs:number,recycleMs:number,url:string}|null} */ +let _aisWatchdogPolicy = null; +/** @type {number|null} */ +let _aisStreamTickTimer = null; +/** Set by dispose so the next ensure() re-derives budgets from a reloaded .env. */ +let _aisNeedsRearm = false; +/** @type {Function|null|undefined} `ws` constructor; null = unavailable, undefined = not yet probed. */ +let _aisWebSocketImpl; + +/** + * Vite plugin: AISStream live vessel cache. + * + * AISStream does not support browser CORS and requires a private API key, so + * the Vite server keeps one backend websocket open and exposes a same-origin + * JSON snapshot to the Cesium layer. + */ +export function aisLiveProxy() { + function install(middlewares) { + middlewares.use('/api/ais-live', async (req, res) => { + try { + ensureAisStreamConnection(); + const incoming = new URL(req.url || '', 'http://localhost'); + + // Track sub-route MUST be handled before the rows snapshot — this + // mount prefix-matches every subpath, so without this branch + // /api/ais-live/track would be silently answered with vessel rows. + if ( + incoming.pathname === '/track' || + incoming.pathname.startsWith('/track/') + ) { + const mmsi = String(incoming.searchParams.get('mmsi') || '').trim(); + res.statusCode = /^\d{5,10}$/.test(mmsi) ? 200 : 400; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + if (res.statusCode !== 200) { + res.end( + JSON.stringify({ + error: 'mmsi query param required', + samples: [], + }), + ); + return; + } + res.end( + JSON.stringify({ + mmsi, + samples: readAisTrack(mmsi), + source: 'AISStream (accumulated since server start)', + retainedSec: Math.floor(AISSTREAM_STALE_MS / 1000), + }), + ); + return; + } + + const maxRows = clampInt( + incoming.searchParams.get('maxRows'), + 1, + AISSTREAM_CACHE_MAX, + AISSTREAM_CACHE_MAX, + ); + const rows = aisStreamRows(maxRows); + + const feed = aisStreamStatusSnapshot(); + + res.statusCode = process.env.AISSTREAM_API_KEY ? 200 : 503; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end( + JSON.stringify({ + rows, + source: 'AISStream', + status: feed.status, + error: feed.error, + refreshing: feed.status !== 'live', + newestPositionAt: newestAisPositionAt(rows), + lastMessageAt: feed.lastMessageAt, + // Honest-failure metadata: how long the feed has been quiet, which + // recovery attempt we are on, and when the next one lands. + silentForMs: feed.silentForMs, + reconnectAttempt: feed.reconnectAttempt, + nextAttemptAt: feed.nextAttemptAt, + staleAfterMs: feed.staleAfterMs, + watchdog: feed.watchdog, + }), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end( + JSON.stringify({ + error: error?.message || 'AIS live stream error', + rows: [], + }), + ); + } + }); + } + + return { + name: 'ais-live-proxy', + configureServer(server) { + install(server.middlewares); + startAisStreamWatchdogTick(); + // Vite restarts the server in-process on a config change while this + // module's state survives; without teardown each reload stacks another + // interval and another socket. + server.httpServer?.on('close', disposeAisStream); + }, + configurePreviewServer(server) { + install(server.middlewares); + startAisStreamWatchdogTick(); + server.httpServer?.on('close', disposeAisStream); + }, + // Middleware-mode backstop: there is no httpServer to hang 'close' on. + closeBundle() { + disposeAisStream(); + }, + }; +} + +/** + * Load the `ws` constructor once. + * + * Node's built-in WebSocket cannot be used here: it has no terminate(), and + * its close() waits forever for a close frame a black-holed peer never sends + * (verified in src/data/aisWatchdogTransport.test.mjs). A socket parked in + * CLOSING keeps holding AISStream's single per-key connection, which is how + * the reverted watchdog wedged. + * + * Loaded lazily rather than imported at the top of this file so a missing + * optional dependency degrades the vessel feed honestly instead of breaking + * the whole dev server and build. + * + * @returns {Function|null} + */ +function aisWebSocketImpl() { + if (_aisWebSocketImpl !== undefined) return _aisWebSocketImpl; + try { + _aisWebSocketImpl = createRequire(import.meta.url)('ws'); + } catch (error) { + _aisWebSocketImpl = null; + console.warn( + '[AISStream] `ws` is unavailable; the live vessel feed is off.', + error?.message || '', + ); + } + return _aisWebSocketImpl; +} + +/** + * Resolve the watchdog policy from the environment, once. + * + * Read lazily because module evaluation happens before Vite's loadEnv() copies + * .env into process.env — the reverted watchdog read these at import time and + * silently ignored every .env value, including its own kill switch. + * + * A custom subscription (one harbor, one message type) can be legitimately + * silent for minutes, so the silence watch only self-arms for the default + * worldwide subscription. An operator with a narrow filter opts back in by + * setting AISSTREAM_SILENCE_TIMEOUT_MS to a value sized for that filter; 0 is + * an explicit kill switch. + */ +function aisWatchdogPolicy() { + if (_aisWatchdogPolicy) return _aisWatchdogPolicy; + const customSubscription = Boolean( + process.env.AISSTREAM_BOUNDING_BOXES || process.env.AISSTREAM_MESSAGE_TYPES, + ); + const override = parseSilenceTimeoutEnv( + process.env.AISSTREAM_SILENCE_TIMEOUT_MS, + (message) => console.warn(message), + ); + const reportMs = + override.kind === 'timeout' ? override.value : AISSTREAM_SILENCE_REPORT_MS; + _aisWatchdogPolicy = { + silenceWatch: + override.kind === 'off' + ? false + : override.kind === 'timeout' || !customSubscription, + reportMs, + recycleMs: Math.round(reportMs * AISSTREAM_RECYCLE_RATIO), + // Overridable so the watchdog can be exercised end-to-end against a local + // stand-in upstream without opening a connection to AISStream (which + // allows only one per key). + url: process.env.AISSTREAM_URL || AISSTREAM_URL, + }; + return _aisWatchdogPolicy; +} + +/** + * The transport adapter, built on first use and kept for the module lifetime. + * + * Never rebuilt: it owns the socket-generation namespace, and a restarted + * namespace would let a pre-disposal handler act on its successor's socket. + */ +function aisAdapter() { + if (_aisAdapter) return _aisAdapter; + _aisAdapter = createAisStreamAdapter({ + createSocket: (url) => { + const WebSocketCtor = aisWebSocketImpl(); + if (!WebSocketCtor) throw new Error('ws transport unavailable'); + return new WebSocketCtor(url); + }, + resolveUrl: () => aisWatchdogPolicy().url, + buildSubscription: aisStreamSubscription, + ingestEnvelope: ingestAisStreamEnvelope, + warn: (message) => console.warn(message), + }); + _aisAdapter.setWatchdogOptions(aisWatchdogBudgets()); + return _aisAdapter; +} + +/** Watchdog budgets derived from the resolved environment policy. */ +function aisWatchdogBudgets() { + const policy = aisWatchdogPolicy(); + return { + staleMs: policy.reportMs, + recycleAfterMs: policy.recycleMs, + backoffMs: [...AISSTREAM_BACKOFF_MS], + downRetryMs: AISSTREAM_DOWN_RETRY_MS, + authProbeMs: AISSTREAM_AUTH_PROBE_MS, + }; +} + +/** + * Fingerprint the credential so a key change can clear the terminal + * auth-failed state. Only a truncated digest is kept — never the key. + */ +function aisKeyFingerprint() { + const key = process.env.AISSTREAM_API_KEY; + if (!key) return null; + return createHash('sha256').update(String(key)).digest('hex').slice(0, 12); +} + +/** + * Drive the watchdog once. Called on every /api/ais-live request and on the + * background interval, so recovery does not depend on browser traffic. + */ +function ensureAisStreamConnection() { + const adapter = aisAdapter(); + if (_aisNeedsRearm) { + // Post-dispose re-arm, now that the restarted server's .env is loaded. The + // adapter keeps its generation namespace across this. + _aisNeedsRearm = false; + adapter.setWatchdogOptions(aisWatchdogBudgets()); + } + const policy = aisWatchdogPolicy(); + adapter.ensure({ + hasKey: Boolean(process.env.AISSTREAM_API_KEY), + hasTransport: Boolean(aisWebSocketImpl()), + silenceWatch: policy.silenceWatch, + keyFingerprint: aisKeyFingerprint(), + }); +} +/** Status metadata for /api/ais-live, safe to call before the first connect. */ +function aisStreamStatusSnapshot() { + const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null; + if (snapshot) return snapshot; + return { + status: process.env.AISSTREAM_API_KEY ? 'idle' : 'missing-key', + error: process.env.AISSTREAM_API_KEY + ? null + : 'AISSTREAM_API_KEY is not set', + lastMessageAt: null, + silentForMs: null, + reconnectAttempt: 0, + nextAttemptAt: null, + watchdog: 'armed', + staleAfterMs: AISSTREAM_SILENCE_REPORT_MS, + }; +} + +/** + * Start the background watchdog tick. Unref'd so it never holds the dev server + * open, and idempotent so a Vite in-process restart cannot stack intervals. + */ +function startAisStreamWatchdogTick() { + if (_aisStreamTickTimer) return; + _aisStreamTickTimer = setInterval(() => { + try { + ensureAisStreamConnection(); + } catch (error) { + console.warn('[AISStream] watchdog tick failed', error?.message || ''); + } + }, AISSTREAM_TICK_MS); + _aisStreamTickTimer.unref?.(); +} + +/** + * Tear down every timer and socket this module owns. + * + * Vite restarts the dev server in-process on a config change while module + * state survives, so without this each reload stacked another interval and + * another reconnect chain. The cached policy is dropped too, so a restart + * re-reads .env. + * + * The adapter instance itself is deliberately KEPT: it owns the socket + * generation namespace, which must stay monotonic across restarts so a + * pre-disposal handler can never collide with a post-disposal socket. + */ +function disposeAisStream() { + if (_aisStreamTickTimer) { + clearInterval(_aisStreamTickTimer); + _aisStreamTickTimer = null; + } + if (_aisAdapter) _aisAdapter.dispose(); + // Drop the cached policy and re-arm LAZILY. Re-deriving budgets here would + // read process.env before the restarted server's loadEnv() has repopulated + // it, caching the outgoing configuration; the next ensure() runs after that. + _aisWatchdogPolicy = null; + _aisNeedsRearm = true; +} + +function aisStreamSubscription() { + return { + APIKey: process.env.AISSTREAM_API_KEY, + BoundingBoxes: parseJsonEnv( + 'AISSTREAM_BOUNDING_BOXES', + AISSTREAM_DEFAULT_BBOXES, + ), + FilterMessageTypes: parseCsvOrJsonEnv( + 'AISSTREAM_MESSAGE_TYPES', + AISSTREAM_DEFAULT_MESSAGE_TYPES, + ), + }; +} + +function parseJsonEnv(key, fallback) { + const value = process.env[key]; + if (!value) return fallback; + try { + return JSON.parse(value); + } catch { + console.warn(`[AISStream] Invalid ${key}; using default.`); + return fallback; + } +} + +function parseCsvOrJsonEnv(key, fallback) { + const value = process.env[key]; + if (!value) return fallback; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : fallback; + } catch { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + } +} diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js new file mode 100644 index 000000000..4a53464f6 --- /dev/null +++ b/server/providers/vessels/ais-store.js @@ -0,0 +1,288 @@ +import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js'; +export const AISSTREAM_CACHE_MAX = 50000; +export const AISSTREAM_STALE_MS = 30 * 60 * 1000; +// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m +// precision, fine for 25m thinning) + Uint32 epoch seconds ≈ 12B/sample; +// 64 samples × 50k MMSIs worst case ≈ 38MB. Tracks exist only while the dev +// server runs — this is "recent path", not voyage history. +const AIS_TRACK_SAMPLES = 64; +const AIS_TRACK_MIN_GAP_SEC = 30; +const AIS_TRACK_MIN_MOVE_M = 25; +/** @type {Map} */ +const _aisStreamVessels = new Map(); +/** @type {Map} */ +const _aisStreamStatic = new Map(); +/** @type {Map} mmsi -> track ring buffer */ +const _aisStreamTracks = new Map(); +/** @type {Map} mmsi -> first fix awaiting second (lazy buffer allocation) */ +const _aisStreamTrackPending = new Map(); + +/** + * Store one parsed AIS envelope. + * + * The return value is the feed's ONLY liveness proof, so it is true strictly + * when the envelope carried a real AIS record. Malformed frames and error + * envelopes never reach here — the adapter classifies those — and a JSON + * object without an MMSI proves nothing about the feed. + * + * @param {Object} envelope Parsed, non-error AIS envelope. + * @returns {boolean} True when an AIS record was recognised. + */ +export function ingestAisStreamEnvelope(envelope) { + // Single shared recognition rule (also used by the adapter's tests), so the + // liveness predicate that ships is the one under test. An envelope carrying + // only an MMSI is not proof the feed works. + if (!isRecognizedAisEnvelope(envelope)) return false; + + const messageType = envelope?.MessageType; + const message = envelope?.Message?.[messageType] || {}; + const metadata = envelope?.MetaData || envelope?.Metadata || {}; + const mmsi = stringValue( + metadata.MMSI ?? message.UserID ?? message.UserId ?? message.Mmsi, + ); + if (!mmsi) return false; + + if (messageType === 'ShipStaticData' || messageType === 'StaticDataReport') { + const staticData = { + name: vesselNameFromAis(metadata, message, _aisStreamStatic.get(mmsi)), + type: vesselTypeFromAis(message, _aisStreamStatic.get(mmsi)), + destination: stringValue(message.Destination), + imo: stringValue(message.ImoNumber ?? message.IMO), + }; + _aisStreamStatic.set(mmsi, staticData); + mergeAisStaticIntoLiveVessel(mmsi, staticData); + } + + const lat = numberValue( + metadata.latitude ?? metadata.Latitude ?? message.Latitude, + ); + const lon = numberValue( + metadata.longitude ?? metadata.Longitude ?? message.Longitude, + ); + // A positionless but well-formed record (static data) is still the feed + // delivering AIS traffic, so it counts as liveness. + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true; + + const staticData = _aisStreamStatic.get(mmsi) || {}; + _aisStreamVessels.set(mmsi, { + lat, + lon, + name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`, + mmsi, + imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), + type: vesselTypeFromAis(message, staticData), + destination: stringValue(message.Destination ?? staticData.destination), + speed: numberValue(message.Sog ?? message.SOG), + course: numberValue(message.Cog ?? message.COG), + heading: normalizedHeading(message.TrueHeading ?? message.Heading), + last_position_UTC: normalizeAisTimestamp( + metadata.time_utc ?? metadata.TimeUtc, + ), + // Use the AIS message's own report time, not server ingest wall-clock — + // trail spacing and dead reckoning depend on true fix epochs. + last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), + _updatedAt: Date.now(), + }); + + appendAisTrackSample( + mmsi, + lat, + lon, + aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), + ); + + pruneAisStreamCache(); + return true; +} + +/** + * Parses an AISStream UTC timestamp into epoch seconds (fallback: now). + */ +function aisEpochSeconds(value) { + const ms = Date.parse(normalizeAisTimestamp(value)); + return Number.isFinite(ms) + ? Math.floor(ms / 1000) + : Math.floor(Date.now() / 1000); +} + +/** + * Appends a thinned position sample to a vessel's track ring buffer. + * Buffers allocate lazily on the second fix (most MMSIs are seen once); + * samples are kept only when >=AIS_TRACK_MIN_GAP_SEC and + * >=AIS_TRACK_MIN_MOVE_M from the previous stored sample, so anchored + * vessels collapse to a single point. + */ +function appendAisTrackSample(mmsi, lat, lon, epochSec) { + let track = _aisStreamTracks.get(mmsi); + if (!track) { + const pending = _aisStreamTrackPending.get(mmsi); + if (!pending) { + _aisStreamTrackPending.set(mmsi, { lat, lon, epochSec }); + return; + } + if (epochSec - pending.epochSec < AIS_TRACK_MIN_GAP_SEC) return; + if ( + approxMetersBetween(pending.lat, pending.lon, lat, lon) < + AIS_TRACK_MIN_MOVE_M + ) + return; + track = { + lats: new Float32Array(AIS_TRACK_SAMPLES), + lons: new Float32Array(AIS_TRACK_SAMPLES), + times: new Uint32Array(AIS_TRACK_SAMPLES), + head: 0, + len: 0, + }; + _aisStreamTracks.set(mmsi, track); + _aisStreamTrackPending.delete(mmsi); + writeAisTrackSample(track, pending.lat, pending.lon, pending.epochSec); + writeAisTrackSample(track, lat, lon, epochSec); + return; + } + + const lastIdx = (track.head - 1 + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES; + const lastEpoch = track.times[lastIdx]; + if (epochSec - lastEpoch < AIS_TRACK_MIN_GAP_SEC) return; + if ( + approxMetersBetween(track.lats[lastIdx], track.lons[lastIdx], lat, lon) < + AIS_TRACK_MIN_MOVE_M + ) + return; + writeAisTrackSample(track, lat, lon, epochSec); +} + +function writeAisTrackSample(track, lat, lon, epochSec) { + track.lats[track.head] = lat; + track.lons[track.head] = lon; + track.times[track.head] = epochSec; + track.head = (track.head + 1) % AIS_TRACK_SAMPLES; + track.len = Math.min(track.len + 1, AIS_TRACK_SAMPLES); +} + +/** + * Reads a vessel's accumulated track in chronological order. + * @returns {Array<{lat:number,lon:number,t:number}>} + */ +export function readAisTrack(mmsi) { + const track = _aisStreamTracks.get(mmsi); + if (!track || !track.len) return []; + const samples = []; + const start = + (track.head - track.len + AIS_TRACK_SAMPLES) % AIS_TRACK_SAMPLES; + for (let i = 0; i < track.len; i++) { + const idx = (start + i) % AIS_TRACK_SAMPLES; + samples.push({ + lat: track.lats[idx], + lon: track.lons[idx], + t: track.times[idx], + }); + } + return samples; +} + +/** Equirectangular distance approximation — plenty for 25m thinning. */ +function approxMetersBetween(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * 111320; + const dLon = + (lon2 - lon1) * 111320 * Math.cos(((lat1 + lat2) / 2) * (Math.PI / 180)); + return Math.hypot(dLat, dLon); +} + +function mergeAisStaticIntoLiveVessel(mmsi, staticData) { + const existing = _aisStreamVessels.get(mmsi); + if (!existing) return; + if (staticData.name && (!existing.name || existing.name === `MMSI ${mmsi}`)) + existing.name = staticData.name; + if (staticData.type && !existing.type) existing.type = staticData.type; + if (staticData.destination && !existing.destination) + existing.destination = staticData.destination; + if (staticData.imo && !existing.imo) existing.imo = staticData.imo; +} + +function vesselNameFromAis(metadata, message, staticData = {}) { + return stringValue( + metadata.ShipName ?? + message.Name ?? + message.ShipName ?? + message.ReportA?.Name ?? + staticData.name, + ); +} + +function vesselTypeFromAis(message, staticData = {}) { + return stringValue( + message.Type ?? + message.ShipType ?? + message.ReportB?.ShipType ?? + staticData.type, + ); +} + +export function aisStreamRows(maxRows) { + const cutoff = Date.now() - AISSTREAM_STALE_MS; + const rows = []; + for (const row of _aisStreamVessels.values()) { + if (row._updatedAt >= cutoff) rows.push(row); + } + rows.sort((a, b) => b._updatedAt - a._updatedAt); + return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row); +} + +function pruneAisStreamCache() { + const cutoff = Date.now() - AISSTREAM_STALE_MS; + for (const [mmsi, row] of _aisStreamVessels) { + if (row._updatedAt < cutoff) { + _aisStreamVessels.delete(mmsi); + _aisStreamTracks.delete(mmsi); + _aisStreamTrackPending.delete(mmsi); + } + } + // Pending single-fix entries for vessels never seen again must not leak + const pendingCutoffSec = Math.floor(cutoff / 1000); + for (const [mmsi, pending] of _aisStreamTrackPending) { + if (pending.epochSec < pendingCutoffSec) + _aisStreamTrackPending.delete(mmsi); + } + if (_aisStreamVessels.size <= AISSTREAM_CACHE_MAX) return; + const ordered = [..._aisStreamVessels.entries()].sort( + (a, b) => a[1]._updatedAt - b[1]._updatedAt, + ); + for (const [mmsi] of ordered.slice( + 0, + _aisStreamVessels.size - AISSTREAM_CACHE_MAX, + )) { + _aisStreamVessels.delete(mmsi); + _aisStreamTracks.delete(mmsi); + _aisStreamTrackPending.delete(mmsi); + } +} + +export function newestAisPositionAt(rows) { + return rows[0]?.last_position_UTC || null; +} + +function stringValue(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); +} + +function numberValue(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function normalizedHeading(value) { + const heading = numberValue(value); + return heading !== null && heading >= 0 && heading <= 360 ? heading : null; +} + +function normalizeAisTimestamp(value) { + const text = stringValue(value); + if (!text) return new Date().toISOString(); + const normalized = text.replace(' +0000 UTC', 'Z').replace(' UTC', 'Z'); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) + ? new Date().toISOString() + : date.toISOString(); +} diff --git a/server/standalone/api-not-found.js b/server/standalone/api-not-found.js new file mode 100644 index 000000000..1f7332609 --- /dev/null +++ b/server/standalone/api-not-found.js @@ -0,0 +1,17 @@ +/** Finish unmatched API requests before Vite's HTML fallback. Install last. */ +export function apiNotFoundPlugin() { + const install = (server) => { + server.middlewares.use('/api', (_req, res) => { + res.writeHead(404, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Unknown API route' })); + }); + }; + return { + name: 'api-not-found', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js new file mode 100644 index 000000000..37516cbe2 --- /dev/null +++ b/server/standalone/key-setup.js @@ -0,0 +1,344 @@ +import { + knownKeySetupEnvVars, + admitKeySetupRequest, + isKeySetupExternallyManaged, + keySetupStatus, + validateKeySetupUpdates, + upsertDotenvValues, +} from '../../src/keySetupCore.mjs'; +import { defaultSourceRoot } from '../providers/common/source-root.js'; +import path from 'node:path'; +import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs'; +import fs from 'node:fs'; +import { parseEnv as parseDotenvText } from 'node:util'; +import { randomUUID } from 'node:crypto'; +import { hardenCredentialFile } from '../../src/keySetupHardening.mjs'; + +/** + * Which launcher started this process, captured at MODULE LOAD — before the + * config factory's loadEnv() copies dotenv files into process.env. Provider + * Settings uses this to decide which credential store it owns, so it must + * reflect the real launcher (scripts/pinokio-start.mjs sets it) and never a + * value a project `.env` could inject. + */ +const LAUNCHER_AT_BOOT = process.env.GEV_LAUNCHER; + +/** + * Provider values present before Vite loads the checkout's dotenv files. + * Memoized on globalThis: a panel save sets its values live on process.env and + * then calls server.restart(), which re-evaluates this config IN-PROCESS. + * Recomputing the snapshot there would classify the panel's own keys as + * external (read-only) until the whole process is relaunched. + */ +const PROVIDER_ENV_AT_BOOT = (globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??= + Object.freeze( + Object.fromEntries( + [...knownKeySetupEnvVars()].map((name) => [ + name, + String(process.env[name] ?? '').trim(), + ]), + ), + )); + +/** + * `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so + * it supplies an explicit names-only provenance marker for values inherited + * from its parent shell. Plain Vite launches use the raw boot snapshot above; + * Pinokio deliberately treats its app-scoped ENVIRONMENT as authoritative. + */ +const DEV_FRESH_EXTERNAL_KEYS_AT_BOOT = new Set( + String(process.env.GEV_KEY_SETUP_EXTERNAL_KEYS ?? '') + .split(',') + .map((name) => name.trim()) + .filter((name) => knownKeySetupEnvVars().has(name)), +); + +/** + * In-app key setup ("POWER UP" panel) — dev-server only. + * + * GET /api/setup/status → which keys are configured, as presence plus a + * source classification. Never a value or suffix. The panel renders itself entirely from + * this payload, so the key registry stays in one place (src/keySetupCore.mjs). + * POST /api/setup/keys → validate {ENV_VAR: value} pairs and upsert them into + * the repo-root .env (created if absent), set process.env live, then restart + * the dev server so the client-exposed defines re-inject and the page + * reloads itself. Pasting a key in the app IS the whole setup — no + * hand-edited env files. + * + * Loopback-only on purpose: with HOST=0.0.0.0 the app can be shared on a LAN, + * and a guest must be able to neither write the host's .env nor probe which + * keys exist. Prod builds never register this middleware (apply: 'serve'), so + * the panel's status fetch fails and the client removes the whole surface. + */ +function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { + const respond = (res, statusCode, payload) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + // A credential-status response must never be cached by a proxy or the disk + // cache, and the surface must never be framed (clickjacking a same-origin + // REMOVE/replace past the Origin check). + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + res.end(JSON.stringify(payload)); + }; + // Which store this launch owns. A Pinokio-managed launch (marker set by + // scripts/pinokio-start.mjs) writes the app-scoped pinokio/ENVIRONMENT that + // applyPinokioEnvironment() treats as authoritative; every other launch + // writes the repo-root .env that Vite's loadEnv reads. The panel never + // touches a store some other workflow owns. + // The launcher marker is read from the BOOT environment captured before + // Vite's loadEnv merges dotenv files into process.env — otherwise a stray + // `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a + // plain `npm run dev` to write the Pinokio store it never loaded. + const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio'; + const storeName = () => + pinokioManaged() ? 'pinokio-environment' : 'env-file'; + const storePath = () => + path.join( + sourceRoot, + ...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']), + ); + // Read the store, distinguishing "no store yet" from "cannot read this + // store". Only ENOENT means empty. Every other failure — a permission error, + // an I/O fault, an undecodable file — must ABORT the save: upserting into a + // wrongly-empty string and atomically replacing the file would destroy every + // other provider key the user had configured. + const readStore = () => { + try { + // The Pinokio launcher deliberately supports a UTF-16 ENVIRONMENT (a + // Windows editor or the native Configure panel can write one). Reuse its + // own encoding-aware decoder so a panel write can never mistake UTF-16 + // bytes for UTF-8, corrupt the file, and wedge the next launch. We always + // write back UTF-8, which is exactly what the launcher normalizes to. + if (pinokioManaged()) return readPinokioEnvironmentSource(storePath()); + return fs.readFileSync(storePath(), 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return ''; // The first saved key births the file. + const unreadable = new Error( + 'the existing configuration could not be read, so nothing was changed', + ); + unreadable.code = 'GEV_STORE_UNREADABLE'; + throw unreadable; + } + }; + // Status must never fail because the store is unreadable — it reports the + // LIVE environment, and an unreadable store only costs file/external + // attribution. Persistence uses readStore() directly and refuses instead. + const storeValues = () => { + try { + return parseDotenvText(readStore()); + } catch { + return {}; + } + }; + // The gate itself is pure and unit-tested (admitKeySetupRequest in + // src/keySetupCore.mjs) — this just feeds it the request. + const admit = (req) => + admitKeySetupRequest({ + method: req.method, + remoteAddress: req.socket?.remoteAddress, + hostHeader: req.headers?.host, + protocol: req.socket?.encrypted ? 'https:' : 'http:', + origin: req.headers?.origin, + contentType: req.headers?.['content-type'], + proxyHeaders: req.headers || {}, + env: process.env, + }); + // Is this env var supplied by a workflow OTHER than this panel's store? Boot + // provenance closes the equal-value ambiguity: an exported X remains + // external even when the editable store independently contains X. + const isExternallyManaged = (name, inStore) => { + const wasExternalAtBoot = pinokioManaged() + ? false + : LAUNCHER_AT_BOOT === 'dev-fresh' + ? DEV_FRESH_EXTERNAL_KEYS_AT_BOOT.has(name) + : PROVIDER_ENV_AT_BOOT[name] !== ''; + return isKeySetupExternallyManaged({ + effectiveValue: process.env[name], + storedValue: inStore[name], + wasExternalAtBoot, + }); + }; + const providerStatus = () => { + const inStore = storeValues(); + const status = keySetupStatus(process.env); + for (const key of status.keys) { + // 'file' = this panel's own store holds exactly this value (replace/remove + // offered); 'external' = supplied by env/Keychain/another workflow + // (read-only — the panel must never rewrite or delete it). + key.managed = key.set + ? key.envVars.some((name) => isExternallyManaged(name, inStore)) + ? 'external' + : 'file' + : null; + } + return { ...status, store: storeName() }; + }; + // Atomically replace the store's content: fresh same-dir temp created 0600 + // with the exclusive flag, fsync, rename over the target. Closes the window + // where writeFileSync leaves a 0644 file holding a real key before any later + // chmod, and the truncate-in-place data-loss path. + const persistStore = (text) => { + const filepath = storePath(); + // Never write THROUGH a symlink into a credential path. + try { + if (fs.lstatSync(filepath).isSymbolicLink()) { + throw new Error( + 'refusing to write a credential store that is a symlink', + ); + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; // absent is fine — first save. + } + // Random suffix, not the pid: a stale temp from a failed rename would + // otherwise make every later save in this process fail EEXIST forever. + const tmp = path.join( + path.dirname(filepath), + `.${path.basename(filepath)}.${randomUUID().slice(0, 8)}.tmp`, + ); + const fd = fs.openSync(tmp, 'wx', 0o600); + let staged = false; + try { + // Restrict the EMPTY temp file BEFORE the secret touches it. On Windows + // a fresh file inherits the directory's ACL (world-readable under a + // C:-rooted Pinokio home) and the 0600 open mode is a no-op — and NTFS + // renames carry the file object's ACL with it, so hardening the temp IS + // hardening the final file. Ordering this before the write means a + // hardening failure aborts with the previous store fully intact and the + // secret never on disk unprotected — no rollback path to get wrong. + if (!hardenCredentialFile(tmp)) { + const error = new Error( + 'could not restrict the credential file to your account; nothing was saved', + ); + error.code = 'GEV_HARDEN_FAILED'; + throw error; + } + // writeSync may write fewer bytes than asked; loop until the whole + // buffer lands or a truncated store gets fsynced and renamed into place. + const buffer = Buffer.from(text, 'utf8'); + let written = 0; + while (written < buffer.length) { + written += fs.writeSync(fd, buffer, written, buffer.length - written); + } + fs.fsyncSync(fd); + staged = true; + } finally { + fs.closeSync(fd); + if (!staged) fs.rmSync(tmp, { force: true }); + } + try { + fs.renameSync(tmp, filepath); + } catch (error) { + // Never strand a staged secret on disk when the swap itself fails. + fs.rmSync(tmp, { force: true }); + throw error; + } + }; + return { + name: 'gev-key-setup', + // serve AND not preview: `vite preview` resolves with command 'serve' too, + // so a bare apply:'serve' would still configure under preview. The endpoints + // only install via configureServer (never configurePreviewServer), so they + // are absent from preview today — but pinning apply here makes that a + // guarantee rather than an accident of which hook a future edit uses. + apply: (_config, { command, isPreview }) => + command === 'serve' && !isPreview, + configureServer(server) { + server.middlewares.use('/api/setup/status', (req, res) => { + if (req.method !== 'GET') + return respond(res, 405, { error: 'Method not allowed' }); + const admission = admit(req); + if (!admission.ok) + return respond(res, admission.status, { error: admission.error }); + respond(res, 200, providerStatus()); + }); + server.middlewares.use('/api/setup/keys', (req, res) => { + if (req.method !== 'POST') + return respond(res, 405, { error: 'Method not allowed' }); + const admission = admit(req); + if (!admission.ok) + return respond(res, admission.status, { error: admission.error }); + let body = ''; + let overflowed = false; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 8192) { + overflowed = true; + req.destroy(); + } + }); + req.on('end', () => { + if (overflowed) + return respond(res, 413, { error: 'Request too large' }); + let parsed; + try { + parsed = JSON.parse(body || '{}'); + } catch { + return respond(res, 400, { error: 'Invalid JSON' }); + } + const verdict = validateKeySetupUpdates(parsed); + if (!verdict.ok) return respond(res, 400, { error: verdict.error }); + // Neither a replace NOR a removal may touch an externally-supplied + // credential (shell env, Keychain, another workflow). This backs the + // UI's read-only "configured externally" state with a real contract — + // and it must guard replace too, not just remove: a clickjacked or + // scripted same-origin POST could otherwise overwrite the live value. + const inStore = storeValues(); + for (const name of Object.keys(verdict.updates)) { + if (isExternallyManaged(name, inStore)) { + return respond(res, 409, { + error: `${name} is configured outside Provider Settings and can only be changed where it was set`, + }); + } + } + try { + persistStore(upsertDotenvValues(readStore(), verdict.updates)); + } catch (error) { + // The hardening failure carries its own honest, path-free message — + // "saved world-readable" must never be reported as a generic write + // error. Everything else returns a fixed message (a raw filesystem + // error can carry an absolute path; that stays in the server log). + if ( + error?.code === 'GEV_HARDEN_FAILED' || + error?.code === 'GEV_STORE_UNREADABLE' + ) { + return respond(res, 500, { + error: `The key was not saved: ${error.message}`, + }); + } + return respond(res, 500, { + error: `Could not write the ${storeName()} store`, + }); + } + // Live for the server-side proxies immediately; the restart below is + // what re-injects the client-exposed defines (Google, Cesium ion). + // Removal sets '' rather than deleting: an empty value stays falsy + // through loadEnv after restart, matching the Pinokio launcher's own + // blank-field semantics. + for (const [name, value] of Object.entries(verdict.updates)) { + process.env[name] = value === null ? '' : value; + } + respond(res, 200, { + ok: true, + saved: Object.keys(verdict.updates), + status: providerStatus(), + restarting: true, + }); + // One deliberate restart, after the response has flushed. Vite's own + // .env watcher may fire too; a second queued restart is harmless. + setTimeout(() => { + server.restart().catch((error) => { + console.warn( + '[KeySetup] Dev-server restart failed:', + error?.message || error, + ); + }); + }, 250); + }); + }); + }, + }; +} + +export { keySetupEndpoint }; diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js new file mode 100644 index 000000000..5479a1c14 --- /dev/null +++ b/server/standalone/vite.config.js @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig, loadEnv } from 'vite'; +import { createBrowserViteConfig } from '../../build/vite.js'; +import { localProviderPlugins } from '../providers/local.js'; +import { apiNotFoundPlugin } from './api-not-found.js'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); + +/** Load this checkout's configuration and attach its local provider middleware. */ +export default defineConfig(({ mode }) => { + const loaded = loadEnv(mode, root, ''); + for (const [key, value] of Object.entries(loaded)) { + if (process.env[key] === undefined) process.env[key] = value; + } + return createBrowserViteConfig({ + plugins: [...localProviderPlugins(), apiNotFoundPlugin()], + googleApiKey: process.env.GOOGLE_MAPS_API_KEY, + cesiumToken: process.env.CESIUM_ION_TOKEN, + host: process.env.HOST, + port: process.env.PORT, + }); +}); diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 200cdbac0..3c964c402 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -94,6 +94,7 @@ export function normalizeTargetKey(target) { } export function createAnnotationEngine({ + placeSearch, viewer, renderer, outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS, @@ -101,6 +102,7 @@ export function createAnnotationEngine({ }) { /** @type {Map} live annotations keyed by id */ const annotations = new Map(); + let destroyed = false; let tickHandle = null; let assistFlightUntil = 0; // camera-assist debounce (see ensureMarksVisible) // Generation token + in-flight fetch controllers guard against clear / new-topic @@ -174,7 +176,7 @@ export function createAnnotationEngine({ } function ensureTicking() { - if (tickHandle != null) return; + if (destroyed || tickHandle != null) return; const tick = () => { const now = performance.now(); let changed = false; @@ -222,6 +224,7 @@ export function createAnnotationEngine({ * @returns {Promise<{ok, drawn, failed, ids, results}>} */ async function annotate(requests, opts = {}) { + if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' }; const list = Array.isArray(requests) ? requests : [requests]; if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work @@ -391,7 +394,7 @@ export function createAnnotationEngine({ for (const pt of points) { const name = pt.target ?? pt.name ?? null; const r = await resolveTarget({ - viewer, + placeSearch, viewer, target: name, latitude: pt.latitude, longitude: pt.longitude, @@ -434,7 +437,7 @@ export function createAnnotationEngine({ } if (type === 'arrow') { const from = await resolveTarget({ - viewer, + placeSearch, viewer, target: spec.target, latitude: spec.latitude, longitude: spec.longitude, @@ -444,7 +447,7 @@ export function createAnnotationEngine({ signal, }); const to = await resolveTarget({ - viewer, + placeSearch, viewer, target: spec.toTarget, latitude: spec.toLatitude, longitude: spec.toLongitude, @@ -469,7 +472,7 @@ export function createAnnotationEngine({ } const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint); return resolveTarget({ - viewer, + placeSearch, viewer, target: spec.target, latitude: spec.latitude, longitude: spec.longitude, @@ -865,6 +868,17 @@ export function createAnnotationEngine({ const engine = { annotate, clear, + destroy() { + if (destroyed) return; + destroyed = true; + try { clear(); } finally { + if (tickHandle != null) cancelAnimationFrame(tickHandle); + tickHandle = null; + outlineListeners.clear(); + releaseContinuousRender('annotations'); + renderer.destroy(); + } + }, fadeOutAll, count: () => annotations.size, list: () => Array.from(annotations.values()), @@ -875,7 +889,7 @@ export function createAnnotationEngine({ * upgrade task, AFTER the mark was mutated in place. Returns an unsubscribe fn. */ onOutlineEvent(listener) { - if (typeof listener !== 'function') return () => {}; + if (destroyed || typeof listener !== 'function') return () => {}; outlineListeners.add(listener); return () => outlineListeners.delete(listener); }, @@ -901,16 +915,22 @@ export function createAnnotationEngine({ async tour() { clear(); flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 }); + if (destroyed) return { ok: false }; await wait(3200); await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2600); await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2600); flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 }); + if (destroyed) return { ok: false }; await wait(3200); await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2800); await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true }); + if (destroyed) return { ok: false }; await wait(2600); await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, @@ -922,6 +942,7 @@ export function createAnnotationEngine({ }; function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) { + if (destroyed) return; try { viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 8d7a405a5..92909c048 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -1,3 +1,4 @@ +import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; // Voice-annotation resilience contract tests — pure logic, no network, no browser. // // Locks the 2026-07-21 field-test fixes: @@ -385,7 +386,7 @@ test('targetKey: empty / absent targets stay null (coord and pixel specs never p assert.equal(normalizeTargetKey(undefined), null); }); -test('outline upgrade updates the rendered element in place without remove/add', async (t) => { +test('outline upgrade updates the rendered element in place without remove/add', { timeout: 5000 }, async (t) => { const originalRequestAnimationFrame = globalThis.requestAnimationFrame; const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; const originalWindow = globalThis.window; @@ -466,7 +467,7 @@ test('outline upgrade updates the rendered element in place without remove/add', sync() {}, }; const viewer = {}; - const engine = createAnnotationEngine({ viewer, renderer }); + const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) }); const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve)); const result = await engine.annotate([{ @@ -668,3 +669,44 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing assert.equal(retry.drawn, 1); assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath'); }); + +test('destroy releases the renderer and prevents subsequent annotation work', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + let destroyed = 0; + renderer.destroy = () => { destroyed++; }; + const engine = createAnnotationEngine({ viewer: {}, renderer }); + const drawn = await engine.annotate([{ type: 'pin', longitude: -97.74, latitude: 30.27 }]); + assert.equal(drawn.drawn, 1); + engine.destroy(); + const additions = calls.add; + const result = await engine.annotate([{ type: 'pin', target: 'Must not resolve' }]); + assert.equal(result.error, 'destroyed'); + assert.equal(calls.add, additions); + assert.equal(engine.count(), 0); + engine.destroy(); + assert.equal(destroyed, 1); + assert.ok(!getRenderGovernorDiagnostics().holds.includes('annotations')); +}); + +test('a late annotation resolver cannot redraw after destruction', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + renderer.destroy = () => {}; + let release; + let signal; + const engine = createAnnotationEngine({ + viewer: {}, renderer, + resolveTarget: (options) => { + signal = options.signal; + return new Promise((resolve) => { release = resolve; }); + }, + }); + const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }]); + engine.destroy(); + assert.equal(signal.aborted, true); + release({ lon: -97.74, lat: 30.27 }); + await pending; + assert.equal(calls.add, 0); + assert.equal(engine.count(), 0); +}); diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 3bcc143c2..87b6514ae 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -2,6 +2,7 @@ import * as Cesium from 'cesium'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; import { lookupNaturalRegionOutline, findNaturalRegion } from '../data/naturalEarthRegions.js'; import { registerDynamicCredit, NATURAL_EARTH_CREDIT } from '../data/dataCredits.js'; +import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; /** @@ -19,7 +20,6 @@ import { isPickedWorldPosition } from '../data/scenePick.js'; * raw geometry ring instead of a bounding box, which is what an outline needs. */ -const geocodeCache = new Map(); const footprintCache = new Map(); const monumentCache = new Map(); // OSM monuments/memorials near a view center, keyed by rounded coord const enclosingAreaCache = new Map(); // smallest enclosing named non-building polygon, keyed by ~1km coord bucket @@ -90,6 +90,7 @@ function linkAbort(controller, externalSignal) { * }>} */ export async function resolveAnnotationTarget({ + placeSearch = unavailablePlaceSearch, viewer, target, latitude, longitude, footprint = false, intent = 'the_thing', entityKind = null, labelHint = null, deferFootprint = false, screenX, screenY, signal, }) { @@ -140,7 +141,7 @@ export async function resolveAnnotationTarget({ } } if (source !== 'places') { - const geocoded = await geocodePlace(query, viewportBias(viewer), signal); + const geocoded = await geocodePlace(query, viewportBias(viewer), signal, placeSearch); if (geocoded) { lat = geocoded.lat; lon = geocoded.lon; @@ -395,7 +396,11 @@ export async function resolveAnnotationTarget({ // (which classifies building-vs-area and stitches compound multipolygons). // Point-like targets use the strict 'point' selection: exact-ish name + monument // scale, else no polygon at all — the honest point beats a locality-word match. - fp = await fetchFootprint(lat, lon, matchName, scope, signal, pointLike ? 'point' : 'loose'); + // Without a feature-name component, match the original ask and require + // identity or containment before replacing its geocoded anchor. + const footprintMode = pointLike ? 'point' + : (fromGeocode && !usablePrimary ? 'anchored' : 'loose'); + fp = await fetchFootprint(lat, lon, matchName, scope, signal, footprintMode); // A "grounds/compound/campus" phrase ("Texas Capitol grounds") names an ENCLOSING area. The // primary footprint above returns the BUILDING (the dome) or null — neither is the grounds. The // real enclosing polygon (e.g. "Capitol Square", leisure=park) IS in OSM but only surfaces via a @@ -598,50 +603,16 @@ function ringAreaM2(ring) { return Math.abs(area) / 2; } -/** - * Forward-geocode a place name via Google Geocoding, biased to the current - * viewport so "the marina" resolves near where the user is looking. - */ -async function geocodePlace(query, biasRect, signal) { - const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY; - if (!apiKey) return null; - - const cacheKey = `${query.toLowerCase()}|${biasRect || ''}`; - const cached = cacheRead(geocodeCache, cacheKey); - if (cached !== undefined) return cached; - - let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; - if (biasRect) url += `&bounds=${biasRect}`; - - try { - const response = await fetch(url, { signal }); - const data = await response.json(); - if (data.status !== 'OK' || !data.results?.length) { - // ZERO_RESULTS is a definitive not-found (cacheable); OVER_QUERY_LIMIT / - // REQUEST_DENIED / UNKNOWN_ERROR are transient → don't poison the cache. - negCache(geocodeCache, cacheKey, signal, data?.status === 'ZERO_RESULTS'); - return null; - } - const result = data.results[0]; - const place = { - lat: result.geometry.location.lat, - lon: result.geometry.location.lng, - label: shortLabel(result.formatted_address), - // The CANONICAL name of the resolved feature (e.g. "Mission District", - // "Texas State Capitol") — used for OSM name-matching instead of the raw - // utterance, so incidental tokens ("...Texas", "...Austin") can't win. - primaryName: extractPrimaryName(result), - types: result.types || [], - // Geocode viewport (sw/ne box framing the feature), normalized to the Places - // low/high shape — sizes grounds discs and flyTo framing for geocode anchors. - viewport: normalizeGeocodeViewport(result.geometry?.bounds || result.geometry?.viewport), - }; - cacheWrite(geocodeCache, cacheKey, place); - return place; - } catch { - negCache(geocodeCache, cacheKey, signal, false); // network/abort — transient - return null; - } +/** Resolve a name through the supplied service; geometry selection stays here. */ +async function geocodePlace(query, biasRect, signal, placeSearch) { + const { place } = await placeSearch.geocode(query, { bias: biasRect, signal }); + signal?.throwIfAborted(); + if (!place) return null; + return { + lat: place.lat, lon: place.lng, label: shortLabel(place.label), + primaryName: place.name || null, types: place.types, + viewport: normalizeGeocodeViewport(place.viewport), + }; } /** Geocoding returns {southwest:{lat,lng},northeast:{lat,lng}}; normalize to the Places @@ -712,24 +683,6 @@ async function placesTextSearch(query, centerLat, centerLon, radiusM, signal) { } } -/** - * The canonical name of the geocoded feature: the address component whose own - * types match the result's feature type (e.g. the `neighborhood` component for a - * neighborhood result), falling back to the first component / leading label - * token. This is the user's INTENT, stripped of the trailing admin context that - * makes the raw utterance match the wrong-scope OSM feature. - */ -function extractPrimaryName(result) { - const resultTypes = new Set((result.types || []).map((t) => String(t).toLowerCase())); - const comps = Array.isArray(result.address_components) ? result.address_components : []; - for (const c of comps) { - const ct = (c.types || []).map((t) => String(t).toLowerCase()); - if (ct.some((t) => t !== 'political' && resultTypes.has(t))) return c.long_name; - } - if (comps[0]?.long_name) return comps[0].long_name; - return String(result.formatted_address || '').split(',')[0].trim() || null; -} - /** * Map the Google geocode `types` to a resolution SCOPE so we fetch the right * OSM feature at the right size. Country-agnostic: scope only selects the query @@ -1222,6 +1175,7 @@ function perpDistance(p, a, b) { * 'loose' — default word-overlap scoring (generic POIs/compounds). * 'strict' — named, non-building, district-sized areas only (neighborhood fallback). * 'point' — point-like targets: (almost) exactly-named, monument-scale polygons only. + * 'anchored' — address-only geocodes: require name identity or anchor containment. * * @returns {Promise, kind: 'building'|'area', heightM: number|null }>} */ @@ -1487,7 +1441,7 @@ const POINTLIKE_AREA_CAP_M2 = 60_000; /** * Pick the best OSM polygon for a query from raw Overpass elements. `mode` selects the - * acceptance contract ('loose' | 'strict' | 'point' — see fetchFootprint). Exported for + * acceptance contract ('loose' | 'strict' | 'point' | 'anchored' — see fetchFootprint). Exported for * the unit tests, which pin the mode contracts with fixtures captured from live data. */ export function selectFootprint(elements, targetLat, targetLon, query, mode = 'loose') { @@ -1539,6 +1493,10 @@ export function selectFootprint(elements, targetLat, targetLon, query, mode = 'l } const contains = pointInPolygon(targetLon, targetLat, coords); + if (mode === 'anchored' && !contains) { + const intentCoverage = queryWords.size ? nameOverlap / queryWords.size : 0; + if (!named || intentCoverage < 0.5 || completeness < 0.6) continue; + } const centroid = ringCentroid(coords.map((p) => [p.lon, p.lat])); const distanceM = centroid ? approximateDistanceM(targetLat, targetLon, centroid.lat, centroid.lon) @@ -1928,7 +1886,7 @@ function shortLabel(formattedAddress) { * @param {AbortSignal} [signal] * @returns {Promise<{name:string, ring:Array<[number,number]>}|null>} */ -export async function resolveRegionRingForQuery(name, signal) { +export async function resolveRegionRingForQuery(name, signal, placeSearch = unavailablePlaceSearch) { const q = String(name || '').trim(); if (!q) return null; const ne = await findNaturalRegion(q).catch(() => null); @@ -1938,7 +1896,7 @@ export async function resolveRegionRingForQuery(name, signal) { const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0]; if (ring?.length >= 3) return { name: ne.name, ring }; } - const geo = await geocodePlace(q, null, signal).catch(() => null); + const geo = await geocodePlace(q, null, signal, placeSearch).catch(() => null); if (!geo) return null; const scope = scopeFromTypes(geo.types); if (!['country', 'state', 'county', 'city'].includes(scope)) return null; diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 1ab378683..452d6997c 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -1,3 +1,5 @@ +import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; +import { createPlaceSearch } from '../search/placeSearch.js'; // Footprint-selection contract tests — pure fixtures, no network, no browser. // // Locks the field-test-7 monument fix (docs/field-test-rootcause-2026-06-30.md §1): @@ -12,7 +14,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - resolveAnnotationTarget, + resolveAnnotationTarget as resolveTarget, selectFootprint, refineScope, isGroundsLikeAsk, @@ -362,3 +364,261 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township 'both township-level admin result types stay guarded', ); }); + +// The Capitol geocoder response contains its coordinates but only ADDRESS names. +// Synthetic footprints reproduce the real Capitol/YOTEL selection without live APIs. +const CAPITOL_ANCHOR = { lat: 38.8899389, lon: -77.0090505 }; +function capitolViewer() { + return { camera: { positionCartographic: { + latitude: CAPITOL_ANCHOR.lat * Math.PI / 180, + longitude: CAPITOL_ANCHOR.lon * Math.PI / 180, + height: 1000, + } } }; +} +function installCapitolMocks(t, elements, components = [ + { long_name: 'Washington', types: ['locality', 'political'] }, + { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, +]) { + installGoogleMocks(t, async (url) => { + if (String(url).startsWith('https://maps.googleapis.com/')) { + return { json: async () => ({ status: 'OK', results: [{ + formatted_address: 'Washington, DC 20004, USA', + types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], + address_components: components, + geometry: { location: { lat: CAPITOL_ANCHOR.lat, lng: CAPITOL_ANCHOR.lon } }, + }] }) }; + } + assert.equal(String(url), '/api/overpass'); + return { ok: true, status: 200, json: async () => ({ elements }) }; + }); +} + +for (const [target, deferFootprint] of [ + ['United States Capitol', true], + ['United States Capitol, Washington, DC', false], +]) { + test(`address-only geocode retains landmark identity: ${target}`, async t => { + const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); + const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' }); + installCapitolMocks(t, [hotel, capitol]); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint }); + assert.ok(result); + const outline = deferFootprint ? await result.resolveOutline() : result; + assert.deepEqual(outline.ring, capitol.geometry.map(p => [p.lon, p.lat])); + assert.equal(outline.footprintKind, 'building'); + }); +} + +test('address-only landmark without a matching outline keeps its geocoded point', async t => { + const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' }); + installCapitolMocks(t, [hotel]); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true }); + assert.ok(result); + assert.deepEqual([result.lat, result.lon], [CAPITOL_ANCHOR.lat, CAPITOL_ANCHOR.lon]); + assert.equal(await result.resolveOutline(), null); +}); + +test('missing address components do not turn a formatted city address into the landmark name', async t => { + const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); + const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'YOTEL Washington DC' }); + installCapitolMocks(t, [hotel, capitol], []); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'United States Capitol building', footprint: true }); + assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat])); +}); + +test('a genuine geocoded feature name still canonicalizes an alternate user name', async t => { + const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); + const decoy = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'Congress meeting building' }); + installCapitolMocks(t, [decoy, capitol], [{ long_name: 'United States Capitol', types: ['landmark'] }]); + const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true }); + assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat])); +}); + +// ── Keyless geocoding ──────────────────────────────────────────────────────── +// +// `geocodePlace` was Google-only: no key returned null, and so did a key whose +// Geocoding API is not enabled (Google answers HTTP 200 with REQUEST_DENIED). +// Either way the annotation silently failed to place. Both now fall through to +// Photon. These drive the second case, because it reaches the SAME fallback +// through a running Google branch — the no-key branch cannot be driven from +// `node --test`, since the key expression reads `import.meta.env`, which only +// Vite defines. + +/** Photon's GeoJSON shape, trimmed to the properties the adapter consumes. */ +function photonFeature({ name, lat, lon, tags = {}, extent = null, ...rest }) { + return { + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { name, ...tags, ...rest, ...(extent ? { extent } : {}) }, + }; +} + +const GOOGLE_FOUND_NOTHING = { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }; + +test('keyless: a key that geocodes to nothing still anchors the annotation', async (t) => { + const requests = []; + installGoogleMocks(t, async (url) => { + requests.push(String(url)); + if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + return { + ok: true, + json: async () => ({ + features: [photonFeature({ + name: 'Lady Bird Lake', + lat: 30.25, + lon: -97.73, + city: 'Austin', + state: 'Texas', + country: 'United States', + tags: { osm_key: 'natural', osm_value: 'water' }, + // Photon orders extent [west, north, east, south] — a naive [w,s,e,n] + // read would invert the box and still look like a valid viewport. + extent: [-97.8, 30.28, -97.68, 30.24], + })], + }), + }; + }); + + const resolved = await resolveAnnotationTarget({ + viewer: closeViewportViewer(), + target: 'Lady Bird Lake', + }); + + assert.ok(resolved, 'a Google miss must not leave the annotation unplaced'); + assert.equal(resolved.source, 'geocode'); + assert.deepEqual([resolved.lat, resolved.lon], [30.25, -97.73]); + // The label is shortened the same way a Google `formatted_address` is. + assert.equal(resolved.label, 'Lady Bird Lake'); + // Photon's extent reaches the framing code in the Places `{low,high}` shape, + // right way up — this is what sizes a grounds disc and the flyTo box. + assert.deepEqual(resolved.viewport, { + low: { latitude: 30.24, longitude: -97.8 }, + high: { latitude: 30.28, longitude: -97.68 }, + }); + // Google is asked first and exactly once; one Photon call answers it. + assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); +}); + +test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s words', async (t) => { + // The reason `primaryName` is carried out of Photon at all. The ask names a + // landmark AND its surroundings; the geocoded feature is called just "Tejano + // Monument". Score the OSM candidates on the raw utterance and the locality + // tokens win — this is the Thompson-Austin bug (field test 7) reached through + // the keyless path. Score them on the canonical name and the monument wins. + const decoy = squareWay(ANCHOR, -250, -250, 3000, { + building: 'yes', tourism: 'hotel', name: 'Texas Capitol Austin Visitor Center', + }); + const monument = squareWay(ANCHOR, 5, 5, 300, { tourism: 'artwork', name: 'Tejano Monument' }); + + installGoogleMocks(t, async (url, init) => { + const href = String(url); + if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) }; + if (href.startsWith('https://photon.komoot.io/')) { + return { + ok: true, + json: async () => ({ + features: [photonFeature({ + name: 'Tejano Monument', + lat: ANCHOR.lat, + lon: ANCHOR.lon, + city: 'Austin', + tags: { osm_key: 'historic', osm_value: 'memorial' }, + })], + }), + }; + } + assert.equal(href, '/api/overpass'); + assert.equal(init?.method, 'POST'); + return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ elements: [decoy, monument] }) }; + }); + + const resolved = await resolveAnnotationTarget({ + viewer: closeViewportViewer(), + target: 'the monument near the Texas Capitol, Austin', + footprint: true, + }); + + assert.ok(resolved.ring, 'the monument polygon is monument-scale and exactly named — it must outline'); + // The anchor re-centres on the chosen polygon, so its position IS the verdict: + // the monument sits on the anchor, the decoy 354 m south-west of it. + assert.ok( + approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon) < 50, + `outlined a polygon ${Math.round(approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon))} m from the monument`, + ); +}); + +/** Metres between two coordinates — enough precision for a few hundred metres. */ +function approximateMetres(lat1, lon1, lat2, lon2) { + const mLat = 111320; + const dy = (lat1 - lat2) * mLat; + const dx = (lon1 - lon2) * mLat * Math.cos((lat1 * Math.PI) / 180); + return Math.hypot(dx, dy); +} + +test('keyless: a Photon outage is not remembered as "no such place"', async (t) => { + // Google answering ZERO_RESULTS is a verdict about GOOGLE. Photon holds + // plenty of places Google does not — that asymmetry is why this fallback + // exists — so a Google verdict plus a Photon timeout must leave the query + // open. Caching it would keep an annotation unplaceable for the rest of the + // session, on a network that has since recovered. + const photonUp = { + ok: true, + json: async () => ({ + features: [photonFeature({ + name: 'Waller Creek', lat: 30.2665, lon: -97.7385, city: 'Austin', + tags: { osm_key: 'waterway', osm_value: 'stream' }, + })], + }), + }; + let photonReachable = false; + const requests = []; + installGoogleMocks(t, async (url) => { + requests.push(String(url)); + if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (!photonReachable) throw new Error('network down'); + return photonUp; + }); + + const target = 'Waller Creek'; + assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null); + const duringOutage = requests.length; + + photonReachable = true; + const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }); + + assert.ok(retried, 'the same target must resolve once the network is back'); + assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]); + assert.ok( + requests.length > duringOutage, + 'the retry was served from a poisoned cache instead of asking again', + ); +}); + +test('a not-found is remembered only when every source consulted gave a verdict', async () => { + // The full truth table, because the no-key row is the one the behavioural + // tests above cannot reach: `import.meta.env` only exists under Vite, so + // `node --test` can never take the branch that skips Google entirely. + const cases = [ + // no key: Photon is the only source, so Photon alone decides + [{ googleConsulted: false, photonAnswered: true }, true], + [{ googleConsulted: false, photonAnswered: false }, false], + // with a key: Google must have said "no such place", AND Photon must have replied + [{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: true }, true], + [{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: false }, false], + // Google declined (REQUEST_DENIED / OVER_QUERY_LIMIT) — never a verdict + [{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: true }, false], + [{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: false }, false], + ]; + for (const [sources, expected] of cases) { + const providers = [sources.googleConsulted ? Boolean(sources.googleSaysNoSuchPlace) : true, sources.photonAnswered] + .map((answered) => ({ geocode: async () => ({ place: null, answered }) })); + assert.equal((await createPlaceSearch({ providers }).geocode('miss')).answered, expected, JSON.stringify(sources)); + } + // An omitted Google verdict defaults to "did not say no such place". + +}); + +function resolveAnnotationTarget(options) { + return resolveTarget({ placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }), ...options }); +} diff --git a/src/annotations/index.js b/src/annotations/index.js index 1a1d73606..a8047c700 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -10,13 +10,13 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; * footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver, * and voice tool wiring are identical to the other two branches. */ -export function initAnnotations({ viewer, tileset = null }) { +export function initAnnotations({ viewer, tileset = null, placeSearch }) { // World-space footprint draping; clamped marks can use the photoreal tiles. if (tileset) { try { tileset.enableCollision = true; } catch { /* older tileset */ } } const renderer = createHybridAnnotationRenderer(viewer); - const engine = createAnnotationEngine({ viewer, renderer }); + const engine = createAnnotationEngine({ viewer, renderer, placeSearch }); window.__gevAnnotations = engine; return engine; } diff --git a/src/app/application.js b/src/app/application.js new file mode 100644 index 000000000..b9c37a8d2 --- /dev/null +++ b/src/app/application.js @@ -0,0 +1,152 @@ +const START_ORDER = ['scene', 'controls', 'data', 'tools']; +// Controls cancel restoration while the data manager and viewer still exist. +const STOP_ORDER = ['tools', 'controls', 'data', 'scene']; + +/** + * Own application startup and teardown without importing an application entry. + * Constructors receive earlier components, an AbortSignal and defer(cleanup). + * Register cleanup immediately after acquiring each resource, before any await. + * @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors + * @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}} + */ +export function createApplication({ + createScene, + createControls, + createData, + createTools, +}) { + const factories = { + scene: createScene, + controls: createControls, + data: createData, + tools: createTools, + }; + for (const phase of START_ORDER) { + if (typeof factories[phase] !== 'function') + throw new TypeError(`Missing ${phase} constructor`); + } + const controller = new AbortController(); + const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []])); + const components = {}; + const listeners = new Set(); + let state = Object.freeze({ status: 'created', phase: null }); + let startPromise; + let destroyPromise; + let cleanupPromise; + + function publish(status, phase = null) { + state = Object.freeze({ status, phase }); + for (const listener of [...listeners]) { + try { + listener(state); + } catch { + console.error('Application state listener failed'); + } + } + } + + function cleanup() { + cleanupPromise ||= Promise.resolve().then(async () => { + const errors = []; + for (const phase of STOP_ORDER) { + while (cleanups[phase].length) { + try { + await cleanups[phase].pop()(); + } catch (error) { + errors.push(error); + } + } + delete components[phase]; + } + if (errors.length) + throw new AggregateError(errors, 'Application cleanup failed'); + }); + return cleanupPromise; + } + + async function initialize() { + try { + for (const phase of START_ORDER) { + controller.signal.throwIfAborted(); + publish('starting', phase); + controller.signal.throwIfAborted(); + let acceptingCleanup = true; + try { + components[phase] = await factories[phase]({ + ...components, + signal: controller.signal, + defer(dispose) { + if (!acceptingCleanup || typeof dispose !== 'function') { + throw new TypeError( + 'Register cleanup during component construction', + ); + } + cleanups[phase].push(dispose); + }, + }); + } finally { + acceptingCleanup = false; + } + controller.signal.throwIfAborted(); + } + publish('ready'); + controller.signal.throwIfAborted(); + return Object.freeze({ ...components }); + } catch (error) { + controller.abort(); + let failure = error; + try { + await cleanup(); + } catch (cleanupError) { + failure = new AggregateError( + [error, cleanupError], + 'Application startup and cleanup failed', + ); + } + if (!destroyPromise) publish('failed'); + throw failure; + } + } + + return Object.freeze({ + start() { + if (destroyPromise) + return Promise.reject(new Error('Application has been destroyed')); + // Assign before notifying subscribers, including reentrant start/destroy. + startPromise ||= Promise.resolve().then(initialize); + return startPromise; + }, + destroy() { + if (destroyPromise) return destroyPromise; + destroyPromise = Promise.resolve().then(async () => { + try { + await startPromise?.catch(() => {}); + await cleanup(); + publish('destroyed'); + } catch (error) { + publish('failed'); + throw error; + } finally { + listeners.clear(); + } + }); + controller.abort(); + publish('destroying'); + return destroyPromise; + }, + getState: () => state, + getComponents: () => Object.freeze({ ...components }), + subscribe(listener) { + if (typeof listener !== 'function') + throw new TypeError('Expected a state listener'); + if (state.status === 'destroyed') return () => {}; + listeners.add(listener); + try { + listener(state); + } catch { + console.error('Application state listener failed'); + } + return () => listeners.delete(listener); + }, + }); +} diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs new file mode 100644 index 000000000..441667a12 --- /dev/null +++ b/src/app/application.test.mjs @@ -0,0 +1,219 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createApplication } from './application.js'; + +const phases = ['Scene', 'Controls', 'Data', 'Tools']; +function fixture(overrides = {}) { + const events = []; + const constructors = Object.fromEntries( + phases.map((phase) => [ + `create${phase}`, + ({ defer }) => { + events.push(`start:${phase}`); + defer(() => events.push(`stop:${phase}`)); + return { name: phase }; + }, + ]), + ); + return { events, app: createApplication({ ...constructors, ...overrides }) }; +} +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test('importing and constructing the package is inactive without browser globals', () => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + globalThis.fetch = () => { throw new Error('unexpected request'); }; + globalThis.setTimeout = () => { throw new Error('unexpected timer'); }; + const { createApplication } = await import('gods-eye-view/application'); + const fail = () => { throw new Error('unexpected construction'); }; + const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail }); + if (app.getState().status !== 'created') process.exit(1); + `, + ], + { cwd: new URL('../../', import.meta.url), encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); +}); + +test('constructors are validated before allocating anything', () => { + assert.throws(() => createApplication({}), /Missing scene constructor/); +}); + +test('startup shares one promise and teardown keeps data alive for controls', async () => { + const { app, events } = fixture({ + createTools({ scene, controls, data, defer }) { + assert.equal(scene.name, 'Scene'); + assert.equal(controls.name, 'Controls'); + assert.equal(data.name, 'Data'); + defer(() => events.push('stop:Tools')); + return { name: 'Tools' }; + }, + }); + const states = []; + app.subscribe((state) => states.push(`${state.status}:${state.phase}`)); + const started = app.start(); + assert.equal(started, app.start()); + const components = await started; + assert.ok(Object.isFrozen(components)); + assert.equal(components.tools.name, 'Tools'); + assert.equal(app.getState().status, 'ready'); + const stopped = app.destroy(); + assert.equal(stopped, app.destroy()); + await stopped; + assert.deepEqual(events.slice(-4), [ + 'stop:Tools', + 'stop:Controls', + 'stop:Data', + 'stop:Scene', + ]); + assert.deepEqual(app.getComponents(), {}); + assert.equal(states.at(-1), 'destroyed:null'); + await assert.rejects(app.start(), /destroyed/); +}); + +for (const phase of phases) { + test(`failure during ${phase} releases partially acquired resources`, async () => { + const { app, events } = fixture({ + [`create${phase}`]({ defer }) { + defer(() => events.push('partial:released')); + throw new Error('broken constructor'); + }, + }); + await assert.rejects(app.start(), /broken constructor/); + assert.equal( + events.filter((value) => value === 'partial:released').length, + 1, + ); + assert.equal(app.getState().status, 'failed'); + assert.deepEqual(app.getComponents(), {}); + await app.destroy(); + assert.equal( + events.filter((value) => value === 'partial:released').length, + 1, + ); + }); +} + +test('destroy before startup prevents every constructor', async () => { + const { app, events } = fixture(); + const started = app.start(); + const failed = assert.rejects(started, { name: 'AbortError' }); + await app.destroy(); + await failed; + assert.deepEqual(events, []); +}); + +test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => { + const gate = deferred(); + const entered = deferred(); + let observedSignal; + const { app, events } = fixture({ + async createScene({ signal, defer }) { + observedSignal = signal; + entered.resolve(); + await gate.promise; + defer(() => events.push('late:released')); + return {}; + }, + }); + const started = app.start(); + const failed = assert.rejects(started, { name: 'AbortError' }); + await entered.promise; + let settled = false; + const stopped = app.destroy().then(() => { + settled = true; + }); + assert.equal(observedSignal.aborted, true); + await Promise.resolve(); + assert.equal(settled, false); + gate.resolve(); + await stopped; + await failed; + assert.deepEqual(events, ['late:released']); +}); + +test('cleanup errors are reported after all remaining resources are attempted', async () => { + const { app, events } = fixture({ + createTools({ defer }) { + defer(() => events.push('tools:earlier')); + defer(() => { + throw new Error('dispose failed'); + }); + return {}; + }, + }); + await app.start(); + await assert.rejects(app.destroy(), (error) => { + assert.ok(error instanceof AggregateError); + assert.equal(error.errors[0].message, 'dispose failed'); + return true; + }); + assert.deepEqual(events.slice(-4), [ + 'tools:earlier', + 'stop:Controls', + 'stop:Data', + 'stop:Scene', + ]); + assert.equal(app.getState().status, 'failed'); +}); + +test('subscribers can destroy during startup without allowing the next constructor', async () => { + const { app, events } = fixture(); + app.subscribe((state) => { + if (state.phase === 'controls') void app.destroy(); + }); + await assert.rejects(app.start(), { name: 'AbortError' }); + await app.destroy(); + assert.deepEqual(events, ['start:Scene', 'stop:Scene']); +}); + +test('destroy during ready notification rejects startup and tears everything down', async () => { + const { app } = fixture(); + app.subscribe((state) => { + if (state.status === 'ready') void app.destroy(); + }); + await assert.rejects(app.start(), { name: 'AbortError' }); + await app.destroy(); + assert.equal(app.getState().status, 'destroyed'); +}); + +test('cleanup registration closes when a constructor settles', async () => { + let register; + const { app } = fixture({ + createScene({ defer }) { + register = defer; + return {}; + }, + }); + await app.start(); + assert.throws(() => register(() => {}), /during component construction/); + await app.destroy(); +}); + +test('different application instances do not share lifecycle state', async () => { + const first = fixture(); + const second = fixture(); + await Promise.all([first.app.start(), second.app.start()]); + await first.app.destroy(); + assert.equal(second.app.getState().status, 'ready'); + assert.equal(second.events.length, 4); + await second.app.destroy(); +}); + +test('the separate viewer export imports without constructing a browser viewer', async () => { + const { createApplicationViewer } = + await import('gods-eye-view/application/viewer'); + assert.equal(typeof createApplicationViewer, 'function'); + assert.throws(() => createApplicationViewer({}), /containers are required/); +}); diff --git a/src/app/mapLifecycle.test.mjs b/src/app/mapLifecycle.test.mjs new file mode 100644 index 000000000..ef015bf52 --- /dev/null +++ b/src/app/mapLifecycle.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapStackController } from '../mapStackController.js'; +import { + installRenderGovernor, + uninstallRenderGovernor, + holdContinuousRender, + governorRequestRender, + getRenderGovernorDiagnostics, +} from '../renderGovernor.js'; + +test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { + let resolveProvider; + let removed = 0; + const changes = []; + const controller = new MapStackController( + {}, + { onChange: (state) => changes.push(state.status) }, + ); + controller._getImageryProvider = () => + new Promise((resolve) => { + resolveProvider = resolve; + }); + controller._removeImageryErrorListener = () => { + removed++; + }; + const switching = controller.setStack('osm'); + controller.destroy(); + resolveProvider({ provider: {} }); + await switching; + assert.equal(removed, 1); + assert.deepEqual(changes, ['switching']); + await controller.setStack('osm'); + controller.destroy(); + assert.equal(removed, 1); +}); + +test('uninstall only releases the current viewer and makes later render requests inactive', () => { + let renders = 0; + const viewer = { + scene: { + requestRender: () => { + renders++; + }, + }, + }; + installRenderGovernor(viewer); + holdContinuousRender('fixture'); + uninstallRenderGovernor({}); + assert.equal(getRenderGovernorDiagnostics().installed, true); + uninstallRenderGovernor(viewer); + const before = renders; + governorRequestRender('late'); + assert.equal(renders, before); + assert.equal(getRenderGovernorDiagnostics().installed, false); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); +}); diff --git a/src/app/startupCamera.test.mjs b/src/app/startupCamera.test.mjs new file mode 100644 index 000000000..721f9964b --- /dev/null +++ b/src/app/startupCamera.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { flyToAustin } from '../camera.js'; + +test('teardown before the initial camera delay prevents a late flight', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let flights = 0; + let cancelled = 0; + const stop = flyToAustin({ + isDestroyed: () => false, + camera: { + setView() {}, + flyTo() { + flights++; + }, + cancelFlight() { + cancelled++; + }, + }, + }); + stop(); + t.mock.timers.tick(1000); + assert.equal(flights, 0); + assert.equal(cancelled, 1); +}); diff --git a/src/app/stateChannel.js b/src/app/stateChannel.js new file mode 100644 index 000000000..93403b044 --- /dev/null +++ b/src/app/stateChannel.js @@ -0,0 +1,111 @@ +/** Copy plain product data so a subscriber cannot mutate its owner's state. */ +function copyState(value, ancestors = new Set()) { + if (value === null) return value; + if (typeof value !== 'object') { + if (['string', 'number', 'boolean', 'undefined'].includes(typeof value)) + return value; + throw new TypeError('Product state must contain plain data'); + } + if (ancestors.has(value)) throw new TypeError('Circular product state'); + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) { + throw new TypeError('Product state must contain plain records'); + } + const next = new Set(ancestors).add(value); + const copy = Array.isArray(value) + ? value.map((item) => copyState(item, next)) + : Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + copyState(item, next), + ]), + ); + return Object.freeze(copy); +} + +/** + * Publish immutable snapshots and ordinary action outcomes synchronously. + * Reentrant publications retain order. New subscribers start at current state; + * removed subscribers and destroyed channels cannot receive queued work. + */ +export function createStateChannel(readSnapshot) { + const listeners = new Set(); + const pending = []; + let revision = 0; + let delivering = false; + let destroyed = false; + let lastSnapshot = null; + const getSnapshot = () => { + if (!destroyed) lastSnapshot = copyState(readSnapshot()); + return lastSnapshot; + }; + const deliver = (entry, notification) => { + try { + entry.listener(notification); + } catch { + console.error('Product state listener failed'); + } + }; + return Object.freeze({ + getSnapshot, + subscribe(listener, { emitCurrent = true } = {}) { + if (typeof listener !== 'function') + throw new TypeError('Expected a state listener'); + if (destroyed) return () => {}; + const state = emitCurrent ? getSnapshot() : null; + const entry = { listener, revision }; + listeners.add(entry); + if (emitCurrent) + deliver( + entry, + Object.freeze({ + state, + change: null, + revision, + initial: true, + }), + ); + return () => listeners.delete(entry); + }, + publish(change = null) { + if (destroyed) return false; + pending.push( + Object.freeze({ + state: getSnapshot(), + change: copyState(change), + revision: ++revision, + initial: false, + }), + ); + if (delivering) return true; + delivering = true; + try { + while (!destroyed && pending.length) { + const notification = pending.shift(); + for (const entry of [...listeners]) { + if ( + destroyed || + !listeners.has(entry) || + entry.revision >= notification.revision + ) + continue; + entry.revision = notification.revision; + deliver(entry, notification); + } + } + } finally { + delivering = false; + } + return true; + }, + destroy() { + if (destroyed) return; + destroyed = true; + listeners.clear(); + pending.length = 0; + }, + }); +} diff --git a/src/app/stateChannel.test.mjs b/src/app/stateChannel.test.mjs new file mode 100644 index 000000000..1cb34339c --- /dev/null +++ b/src/app/stateChannel.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createStateChannel } from './stateChannel.js'; + +test('snapshots and action data are immutable copies of live product state', () => { + const live = { selected: { id: 'a' }, items: ['a'] }; + const channel = createStateChannel(() => live); + const received = []; + channel.subscribe((event) => received.push(event)); + const change = { type: 'selected', item: { id: 'b' } }; + live.selected.id = 'b'; + channel.publish(change); + change.item.id = 'mutated'; + assert.equal(received[0].initial, true); + assert.equal(received[0].state.selected.id, 'a'); + assert.equal(received[1].state.selected.id, 'b'); + assert.equal(received[1].change.item.id, 'b'); + assert.throws(() => received[1].state.items.push('c'), TypeError); + assert.deepEqual(live.items, ['a']); +}); + +test('reentrant updates retain order and a new subscriber never regresses behind its initial snapshot', () => { + let count = 0; + const channel = createStateChannel(() => ({ count })); + const first = [], + second = [], + joined = []; + channel.subscribe(({ state, initial }) => { + if (initial) return; + first.push(state.count); + if (state.count === 1) { + count = 2; + channel.publish({ type: 'next' }); + channel.subscribe(({ state }) => joined.push(state.count)); + } + }); + channel.subscribe(({ state, initial }) => { + if (!initial) second.push(state.count); + }); + count = 1; + channel.publish(); + assert.deepEqual(first, [1, 2]); + assert.deepEqual(second, [1, 2]); + assert.deepEqual(joined, [2]); +}); + +test('unsubscribe and destroy during delivery revoke pending callbacks and future reads', () => { + let reads = 0; + const channel = createStateChannel(() => ({ reads: ++reads })); + let late = 0; + let remove; + channel.subscribe( + () => { + remove(); + channel.publish(); + channel.destroy(); + }, + { emitCurrent: false }, + ); + remove = channel.subscribe(() => late++, { emitCurrent: false }); + channel.publish(); + const stoppedReads = reads; + channel.publish(); + channel.subscribe(() => late++); + channel.destroy(); + remove(); + assert.equal(late, 0); + assert.equal(reads, stoppedReads); +}); + +test('a failed subscriber cannot prevent other controls from receiving an update', () => { + const channel = createStateChannel(() => ({ selected: 'a' })); + const errors = []; + const original = console.error; + console.error = (message) => errors.push(message); + try { + channel.subscribe(() => { + throw new Error('consumer failure'); + }); + let received = 0; + channel.subscribe(() => received++); + channel.publish(); + assert.equal(received, 2); + assert.equal(errors.length, 2); + } finally { + console.error = original; + channel.destroy(); + } +}); + +test('an invalid initial snapshot cannot retain a subscriber without a cleanup handle', () => { + let live = { action() {} }; + const channel = createStateChannel(() => live); + let calls = 0; + assert.throws(() => channel.subscribe(() => calls++), /plain data/); + live = { selected: 'a' }; + channel.publish(); + assert.equal(calls, 0); +}); diff --git a/src/app/viewer.js b/src/app/viewer.js new file mode 100644 index 000000000..0b5f720d0 --- /dev/null +++ b/src/app/viewer.js @@ -0,0 +1,36 @@ +import * as Cesium from 'cesium'; + +/** Create the standard globe viewer in caller-owned, visible containers. */ +export function createApplicationViewer({ container, creditContainer }) { + if (!container || !creditContainer) + throw new TypeError('Viewer and credit containers are required'); + const viewer = new Cesium.Viewer(container, { + timeline: false, + animation: false, + baseLayerPicker: false, + geocoder: false, + homeButton: false, + sceneModePicker: false, + navigationHelpButton: false, + fullscreenButton: false, + vrButton: false, + selectionIndicator: false, + infoBox: false, + baseLayer: false, + creditContainer, + msaaSamples: 4, + contextOptions: { webgl: { preserveDrawingBuffer: true } }, + }); + try { + viewer.targetFrameRate = 60; + viewer.scene.globe.show = false; + viewer.scene.skyAtmosphere.show = true; + viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; + viewer.scene.skyAtmosphere.saturationShift = -0.12; + viewer.scene.skyAtmosphere.brightnessShift = -0.08; + return viewer; + } catch (error) { + viewer.destroy(); + throw error; + } +} diff --git a/src/bloom.js b/src/bloom.js index 297d93ee6..fba0472d2 100644 --- a/src/bloom.js +++ b/src/bloom.js @@ -43,7 +43,11 @@ export function clampBloomIntensity(value) { * @returns {number} equivalent v2 intensity */ export function legacyBloomToV2(value) { - const legacy = clamp(Math.round(Number(value) || 0), 0, LEGACY_BLOOM_INTENSITY_MAX); + const legacy = clamp( + Math.round(Number(value) || 0), + 0, + LEGACY_BLOOM_INTENSITY_MAX, + ); return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2); } @@ -59,7 +63,10 @@ export function decodeBloomIntensity(value, version = 1) { if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT; const normalizedVersion = Number(version); - if (Number.isFinite(normalizedVersion) && normalizedVersion >= BLOOM_SCALE_VERSION) { + if ( + Number.isFinite(normalizedVersion) && + normalizedVersion >= BLOOM_SCALE_VERSION + ) { return clampBloomIntensity(num); } diff --git a/src/camera.js b/src/camera.js index 0e12b66f1..3941c9d48 100644 --- a/src/camera.js +++ b/src/camera.js @@ -48,6 +48,7 @@ export function flyToPreset(viewer, presetName, duration = 3.0) { /** * Set camera to Austin on load with a cinematic fly-in. + * @returns {Function} Cancels the pending or active startup flight. */ export function flyToAustin(viewer) { // Start from a high altitude, then fly down @@ -61,7 +62,8 @@ export function flyToAustin(viewer) { }); // Cinematic fly-in after a brief pause - setTimeout(() => { + const timer = setTimeout(() => { + if (viewer.isDestroyed()) return; viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600), orientation: { @@ -73,4 +75,8 @@ export function flyToAustin(viewer) { easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, }); }, 500); + return () => { + clearTimeout(timer); + if (!viewer.isDestroyed()) viewer.camera.cancelFlight(); + }; } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index e7d5b0268..2b8d3367f 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,3 +1,7 @@ +import { readLayerSource } from './testSupport/readLayerSource.mjs'; +import { StyleManager } from './ui/applicationShell.js'; +import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; +import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -5,14 +9,19 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); -const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8'); -const vessels = fs.readFileSync(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), 'utf8'); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); +const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js')); +const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js')); const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8'); const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8'); function body(source, pattern, label) { + const name = pattern.source.match(/^(\w+)/)?.[1]; + if (source === ui && typeof StyleManager.prototype[name] === 'function') { + const method = StyleManager.prototype[name].toString(); + return method.slice(method.indexOf(') {') + 3, -1); + } const match = source.match(pattern); assert.ok(match, `${label} is missing`); return match[1]; @@ -21,7 +30,7 @@ function body(source, pattern, label) { function ordered(source, needles, label) { let previous = -1; for (const needle of needles) { - const index = source.indexOf(needle); + const index = source.indexOf(needle, previous + 1); assert.ok(index >= 0, `${label}: missing ${needle}`); assert.ok(index > previous, `${label}: ${needle} is out of order`); previous = index; @@ -29,11 +38,7 @@ function ordered(source, needles, label) { } test('Cockpit takeover invalidates deferred work before camera cancellation', () => { - const enter = body( - ui, - /enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/, - 'Cockpit enter', - ); + const enter = cockpitEnter.toString(); ordered(enter, [ 'if (!info || !entity?.position) return false;', 'this.onCameraTakeover?.();', @@ -42,7 +47,7 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', () ], 'Cockpit takeover'); assert.match( ui, - /onCameraTakeover: \(\) => this\._stampNavigation\(\{ cancelPendingSelection: false \}\),/, + /onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/, 'Cockpit retires stale camera work without clearing the aircraft selection it adopts', ); }); @@ -53,14 +58,14 @@ test('one explicit tracking selection clears sibling IDs before publishing its d /_persistAwarenessSelection\(event, cleared = false\) \{([\s\S]*?)\n \}/, 'tracking persistence', ); - assert.match(persist, /adoptLayerParams\?\.\(layerId,/); + assert.match(persist, /adoptLayerParams\?\.\(\s*layerId,/); assert.match(persist, /\['flights', 'selectedFlightsTrackingId'\]/); assert.match(persist, /\['military', 'selectedMilitaryTrackingId'\]/); assert.match(persist, /\['satellites', 'selectedSatTrackingId'\]/); assert.match(persist, /if \(otherLayerId === layerId\) continue;/); assert.match( persist, - /for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(otherLayerId,[\s\S]*?adoptLayerParams\?\.\(layerId,/, + /for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(\s*otherLayerId,[\s\S]*?adoptLayerParams\?\.\(\s*layerId,/, 'the previous family clears before Flight/Military/Satellite publishes the new durable ID', ); }); @@ -75,9 +80,9 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/); assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/); assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/); - assert.match(stamp, /if \(!passivelyClearedShareSelection && !flightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedFlightsTrackingId: null/); - assert.match(stamp, /if \(!passivelyClearedShareSelection && !militaryFlightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedMilitaryTrackingId: null/); - assert.match(stamp, /if \(!passivelyClearedShareSelection && !satellitesLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedSatTrackingId: null/); + assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/); + assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/); + assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/); }); test('voice Cockpit entry reaches the camera only through stamping seams', () => { @@ -110,7 +115,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => // Seam 1: any tracker handoff stamps, so the adoption step is covered. assert.match( ui, - /viewer\.trackedEntityChanged\.addEventListener\(\(entity\) => \{\s*if \(entity && !this\._disposed\) this\._stampNavigation\(\{ cancelPendingSelection: false \}\);/, + /viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, 'tracker handoff must stamp', ); // Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above. @@ -131,21 +136,18 @@ test('voice Cockpit next/previous shares the manual Context navigation path', () // is what stamps. Divergence here is how a voice-only camera path escapes // the arbiter. assert.match( - ui, - /this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/, + CockpitViewController.toString(), + /this\._listen\(this\.contextPrevious, 'click', \(\) =>\s*this\.navigateContext\(-1, \{ origin: 'user' \}\),?\s*\);/, ); assert.match( - ui, - /this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/, - ); - const funnel = body( - ui, - /navigateContext\(direction, options = \{\}\) \{([\s\S]*?)\n \}\n\n \/\*\* Adopt/, - 'Cockpit Context navigation funnel', + CockpitViewController.toString(), + /this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/, ); + const funnel = navigateContext.toString(); + assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/); ordered(funnel, [ "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", - 'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;', + 'const navigationOptions = wasActive', 'militaryAwarenessLayer?.[method]?.(navigationOptions)', 'this._adoptTrackedEntity(performance.now());', ], 'Cockpit Context navigation funnel'); @@ -243,22 +245,22 @@ test('validated voice camera destinations share the UI navigation authority faca }); test('deferred search releases only after its final authority check', () => { - const handler = body( - ui, - /this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/, - 'search handler', - ); - ordered(handler, [ - "this._beginDeferredNavigation('location')", - 'this._activeLocationSearchGeneration = generation;', - 'searchAndFlyTo(this.viewer, query', - 'beforeFly: () => this._reassertNavigationHandoff(generation)', - 'generation !== this._navigationGeneration', + const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); + ordered(search, [ + 'const authority = this.begin();', + 'this.onStart?.(authority);', + 'await this.search(query', + 'beforeFly: () => current() && this.beforeFly(authority)', + 'if (!current() || controller.signal.aborted) return;', 'destination?.cancelled', 'finally', - 'this._settleLocationSearchUi(generation)', - ], 'deferred search'); - assert.doesNotMatch(handler.slice(0, handler.indexOf('searchAndFlyTo')), /_releaseFollowCamera/); + 'this.onSettled?.(authority)', + ], 'deferred search component'); + assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); + assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); + assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); + assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/); + assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); }); test('a direct globe gesture retires delayed camera and selection restore only', () => { @@ -268,15 +270,15 @@ test('a direct globe gesture retires delayed camera and selection restore only', ); assert.match( ui, - /viewer\?\.canvas\?\.addEventListener\('pointerdown', this\._initialShareGestureHandler/, + /viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/, ); assert.match( ui, - /viewer\?\.canvas\?\.addEventListener\('wheel', this\._initialShareGestureHandler/, + /viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/, ); assert.match( ui, - /removeEventListener\('pointerdown', this\._initialShareGestureHandler\)[\s\S]*?removeEventListener\('wheel', this\._initialShareGestureHandler\)/, + /removeEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler,?\s*\)[\s\S]*?removeEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler,?\s*\)/, ); const stamp = body( ui, @@ -300,7 +302,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () = 'this._disposed = true;', 'this._stampNavigation();', 'this._removeWorldRequestFocusListener?.();', - 'await this._restoreContextSession();', + 'await this._contextControls.restoreForDisposal();', ], 'dispose invalidation'); }); @@ -311,7 +313,7 @@ test('teardown synchronously closes immediate camera entry points', () => { 'this._removeCctvRequestFocusListener?.();', 'this._removeWorldRequestFocusListener?.();', 'this._navigationOwnerChangedRemover?.();', - 'await this._restoreContextSession();', + 'await this._contextControls.restoreForDisposal();', ], 'synchronous teardown barrier'); const navigation = body( @@ -337,25 +339,18 @@ test('teardown synchronously closes immediate camera entry points', () => { }); test('teardown refuses deferred location work before geocoding begins', () => { - const deferred = body( - ui, - /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, - 'deferred navigation', - ); + const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation'); assert.match(deferred, /disposed: this\._disposed/); - - const handler = body( - ui, - /this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/, - 'search handler', - ); - ordered(handler, [ - "const generation = this._beginDeferredNavigation('location');", - 'if (generation === false)', - 'this._locationSearch.blur();', - 'searchAndFlyTo(this.viewer, query', + const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); + ordered(search, [ + 'if (!query || this.destroyed) return;', + 'const authority = this.begin();', + 'if (authority === false)', + 'this.input.blur();', + 'this.onStart?.(authority);', + 'await this.search(query', ], 'disposed search refusal'); - assert.match(handler, /if \(generation === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\._activeLocationSearchGeneration/); + assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/); }); test('refused canned destinations commit no location or POI state', () => { @@ -387,7 +382,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => } const vesselClick = body( vessels, - /handler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/, + /handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/, 'vessel click', ); ordered(vesselClick, [ @@ -397,7 +392,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => ], 'vessel sibling ownership'); const vesselFocus = body( vessels, - /function selectAndFocusVessel\(record\) \{([\s\S]*?)\n\}/, + /function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/, 'vessel focus helper', ); assert.match(vesselFocus, /requestWorldFocus\(\{/); diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 404831aa0..faf70f9f6 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -73,7 +73,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur assert.match(diagnostics[0][0], /before setEnabled await/); assert.match(diagnostics[1][0], /after setEnabled await/); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/); }); @@ -95,3 +95,25 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async assert.deepEqual(transitions, [false]); assert.deepEqual(diagnostics, []); }); + + +test('a disposed UI cannot activate a camera when enabling finishes late', async () => { + let release; + let disposed = false; + let activations = 0; + const pending = runCctvLayerEnableTransition({ + target: true, + setEnabled: () => new Promise((resolve) => { release = resolve; }), + readOwnership: () => ({}), + shouldFocus: () => !disposed, + activate: () => { activations++; return 'camera-a'; }, + fly: () => assert.fail('disposed UI must not fly'), + debug: () => {}, + }); + disposed = true; + release(); + await pending; + assert.equal(activations, 0); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); + assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/); +}); diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index fee23eb5e..64b4c20de 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -57,7 +57,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once assert.strictEqual(added[0].callback, listener); assert.strictEqual(removed[0].callback, added[0].callback); - const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); + const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); assert.match( uiSource, /_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 297a6b7a6..24884b5ad 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,23 @@ +import { readLayerSource } from './testSupport/readLayerSource.mjs'; +import { PanelLayoutController } from './ui/panelLayoutController.js'; +import { readShellElements } from './ui/shellElements.js'; +import { readStylesheet } from './testSupport/readStylesheet.mjs'; +import { SceneControls } from './ui/sceneControls.js'; +import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; +import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; +import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; +import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; +import { updateContext as cockpitContext } from './ui/cockpitContext.js'; +import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js'; +import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; +import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; +import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; +import { clearSelectedLayers } from './ui/contextActions.js'; +import { _selectContextMode } from './ui/contextTransactions.js'; +import { _restoreContextSession } from './ui/contextSession.js'; +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -7,11 +27,11 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'); -const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8'); -const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8'); +const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); +const css = readStylesheet(path.join(ROOT, 'style.css')); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8'); -const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); +const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); test('Cockpit has one reset action beside its bottom exit path', () => { @@ -65,16 +85,16 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => { }); test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => { - assert.match(ui, /const modes = COCKPIT_VISION_MODES;/); - assert.match(ui, /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/); - assert.doesNotMatch(ui, /none: 'NONE'/); - assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/); + assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/); + assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/); + assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/); + assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/); assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/); - assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/); - assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(this\.stages, this\.transitions\);/); + assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/); + assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/); assert.match( ui, - /if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/, + /if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/, 'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides', ); assert.match( @@ -92,20 +112,20 @@ test('Contacts uses the approved radar icon', () => { }); test('Cockpit Escape handling precedes form-control shortcut suppression and focus is restored', () => { - const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/); + const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - const escapeIndex = keydown[1].indexOf("event.key === 'Escape'"); - const formGuardIndex = keydown[1].indexOf("closest?.('input, textarea, select, [contenteditable]')"); + const escapeIndex = keydown.indexOf("event.key === 'Escape'"); + const formGuardIndex = keydown.indexOf("closest?.('input, textarea, select, [contenteditable]')"); assert.ok(escapeIndex >= 0 && formGuardIndex > escapeIndex, 'Escape must work while focus is inside a form control'); - const enter = ui.match(/\n enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/); - const exit = ui.match(/\n exit\(\{ restoreTracking = true \} = \{\}\) \{([\s\S]*?)\n \}\n\n update\(\)/); + const enter = cockpitEnter.toString(); + const exit = cockpitExit.toString(); assert.ok(enter && exit, 'Cockpit entry/exit methods are missing'); - assert.match(enter[1], /activeElement/); - assert.match(enter[1], /mapViewButton.*focus|focus.*mapViewButton/s); - assert.match(exit[1], /focus\(\{ preventScroll: true \}\)/); - assert.match(exit[1], /this\.restoreTrackingFrame\(entity\)/); - assert.doesNotMatch(exit[1], /applyTrackedCameraFrame/); + assert.match(enter, /activeElement/); + assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s); + assert.match(exit, /focus\(\{ preventScroll: true \}\)/); + assert.match(exit, /this\.restoreTrackingFrame\(entity\)/); + assert.doesNotMatch(exit, /applyTrackedCameraFrame/); assert.match( ui, /restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/, @@ -114,24 +134,22 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc }); test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => { - const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/); + const keydown = cockpitKeyDown.toString(); assert.ok(keydown, 'Cockpit keyboard handler is missing'); - assert.match(keydown[1], /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/); - assert.match(keydown[1], /#cockpit-utility-controls \[aria-expanded="true"\]/); + assert.match(keydown, /document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/); + assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/); assert.match( - keydown[1], - /const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, + keydown, + /const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/, ); }); test('the Contact panel never hides itself out from under its own NEXT button', () => { - const updateContext = ui.match(/\n updateContext\(info, heading\) \{([\s\S]*?)\n \}\n\n scheduleContextLayout\(\)/); + const updateContext = cockpitContext.toString(); assert.ok(updateContext, 'Cockpit updateContext is missing'); - const body = updateContext[1]; - // ui.js cannot be imported under node (Cesium's `mgrs` dependency), so the - // decision lives in cockpitMath.resolveCockpitContextReadout and this pins - // ui.js to it. The panel hosts PREVIOUS/NEXT: the only reason it may hide is - // that no snapshot exists at all. + const body = updateContext; + // Pin the real presentation owner to the readout policy: PREVIOUS/NEXT + // remains reachable unless no Context snapshot exists at all. assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/); assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/); assert.equal( @@ -164,25 +182,23 @@ test('the Contact panel never hides itself out from under its own NEXT button', ); assert.match( css, - /\.cockpit-context-window\[data-state="lost"\]/, + /\.cockpit-context-window\[data-state='lost'\]/, 'CONTACT LOST reuses the panel-level data-state cue that "uncertain" already uses', ); }); test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => { - const read = ui.match(/\n readAircraftInfo\(\) \{([\s\S]*?)\n \}/); + const read = readAircraftInfo.toString(); assert.ok(read, 'readAircraftInfo is missing'); - assert.match(read[1], /resolveTrackedAircraftInfo\(\{/); - assert.match(read[1], /gevTrackedId/); + assert.match(read, /resolveTrackedAircraftInfo\(\{/); + assert.match(read, /gevTrackedId/); // In cockpit mode the controller moves the entity off viewer.trackedEntity, // so its own handle is the tracked identity there. - assert.match(read[1], /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); + assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/); }); test('programmatic Context layer changes cannot bypass explicit expansion policy', () => { - const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/); - assert.ok(handler, 'Context layer state handler is missing'); - assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/); + assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/); }); test('share startup isolates panel defaults from recipient-local collapse preferences', () => { @@ -227,8 +243,9 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', css, /body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/, ); - assert.match(sceneDirector, /this\._running = true;\s*document\.body\.classList\.add\('scene-playback-mode'\);/); - assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*document\.body\.classList\.remove\('scene-playback-mode'\);/); + assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/); + assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/); + assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/); assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner'); assert.equal((html.match(/id="detection-toggle"/g) || []).length, 1, 'Detection control must have one stateful DOM owner'); assert.equal((html.match(/id="models3d-toggle"/g) || []).length, 1, '3D control must have one stateful DOM owner'); @@ -239,17 +256,17 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', const cockpitRadio = html.match(/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<\/aside>/); assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing'); assert.doesNotMatch(cockpitRadio[0], /context-radio-details-btn|radio-filter|radio-tuner/); - assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(--cockpit-utility-top/); - assert.match(css, /\[data-cockpit-launcher="display"\]\s*\{\s*width:\s*var\(--left-collapsed-width, 176px\)/); + assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/); + assert.match(css, /\[data-cockpit-launcher='display'\]\s*\{\s*width:\s*var\(\s*--left-collapsed-width, 176px\)/); assert.match(css, /--display-panel-expanded-width:\s*272px/); - assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(--display-panel-expanded-width\)/); - assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)\s*\{[\s\S]*?width:\s*var\(--display-panel-expanded-width\)/); + assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(\s*--display-panel-expanded-width\)/); + assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)\s*\{[\s\S]*?width:\s*var\(\s*--display-panel-expanded-width\)/); assert.doesNotMatch(ui, /--cockpit-display-expanded-width|dataPanelWidth/); - assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/); + assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, 0\.42\)/); assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/); - assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/); - assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/); - assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/); + assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/); + assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/); + assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/); assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/); assert.doesNotMatch(ui, /--cockpit-display-tab-width/); // These two widths size Cockpit Radio and nothing else. They were named for @@ -259,8 +276,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', assert.match(css, /--cockpit-radio-expanded-width:\s*232px/); assert.doesNotMatch(css, /--map-stack-(?:collapsed|expanded)-width/); assert.doesNotMatch(html, /id="stack-panel"/, 'the retired Map Stack panel must not remain in Cockpit layout'); - assert.match(css, /\[data-cockpit-launcher="radio"\]\s*\{\s*width:\s*var\(--cockpit-radio-collapsed-width, 148px\)/); - assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="radio"\]\)[\s\S]*?--cockpit-radio-expanded-width/); + assert.match(css, /\[data-cockpit-launcher='radio'\]\s*\{\s*width:\s*var\(\s*--cockpit-radio-collapsed-width, 148px\)/); + assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='radio'\]\)[\s\S]*?--cockpit-radio-expanded-width/); assert.doesNotMatch(ui, /--cockpit-radio-tab-width/); assert.doesNotMatch(css, /\.cockpit-utility-launcher:hover/); const desktopUtilityCss = css.slice( @@ -273,8 +290,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls', ); assert.match(css, /\.cockpit-utility-controls\.layout-primary-only[\s\S]*?display:\s*none/); assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*?\.cockpit-utility-controls:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?display:\s*none/); - assert.match(ui, /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/); - assert.match(ui, /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/); + assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{\s*availableHeight,\s*expandedHeight,\s*collapsedHeight,?\s*\}\)/); + assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/); }); test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => { @@ -286,7 +303,7 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below ); assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1, 'Parameters must have one DOM owner'); assert.match( - ui, + PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(), /const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/, ); assert.match( @@ -302,67 +319,63 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat manager, /for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/, ); - assert.match(ui, /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/); - assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/); - assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/); - assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/); - assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/); - assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/); - assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); + assert.match(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/); + assert.match(_selectContextMode.toString(), /async function _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/); + assert.match(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/); + assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/); + assert.match(_restoreContextSession.toString(), /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/); + assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/); + assert.match(clearSelectedLayers.toString(), /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); }); test('Cockpit Display portal retains both scroll owners across round trips', () => { - assert.match(ui, /this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop \|\| 0/); - assert.match(ui, /this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop \|\| 0/); - assert.match(ui, /if \(!this\._cockpitDisplayPortalActive\)[\s\S]*?this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop/); - assert.match(ui, /if \(this\._cockpitDisplayPortalActive\)[\s\S]*?this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop/); - assert.match(ui, /this\._cockpitDisplayPanel\.scrollTop = this\._cockpitDisplayScrollTop[\s\S]*?this\._ppToggles\.scrollTop = this\._standardDisplayScrollTop/); + const portal = CockpitDisplayPortal.toString(); + assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/); + assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/); + assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/); + assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/); + assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/); }); test('Cockpit side surfaces behave as two single-expanded accordions', () => { - assert.match( - ui, - /if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/, + assert.match(radioBindings, + /if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/, ); - assert.match( - ui, + assert.match(radioBindings, /'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/, ); - assert.match( - ui, + assert.match(radioBindings, /'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/, ); assert.match( ui, - /!nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel = !this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/, + /!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/, ); assert.match( ui, - /nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/, + /nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/, 'closing Data Layers must restore Contact only after an automatic collapse', ); - assert.match( - ui, + assert.match(setContextCollapsed.toString(), /const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/, 'Contact expansion must notify only on a collapsed-to-expanded transition', ); - assert.match( - ui, + assert.match(setSignalCollapsed.toString(), /const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/, 'Live Signals expansion must notify only on a collapsed-to-expanded transition', ); assert.match( - ui, + setSignalCollapsed.toString(), /setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/, ); assert.match( - ui, - /!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/, + radioBindings, + /!displayOpen\s*&&\s*!radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/, 'Live Signals should reopen only after both utility panels close and no manual collapse is retained', ); assert.match( - ui, - /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/, + radioBindings, + /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/, 'left-side interactions must not collapse the independent Cockpit utilities', ); }); @@ -392,7 +405,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the ); assert.match( callback[1], - /_cockpitPanelRestore\.set\(panelId, panel\.classList\.contains\('collapsed'\)\)/, + /_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/, 'entry must remember each panel\'s exact collapsed state before hiding it', ); assert.match( @@ -437,12 +450,12 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the }); test('real disclosure changes reconsider only their own temporary panel lane', () => { - const collapseStart = ui.indexOf('setPanelCollapsed(panelId, collapsed, {'); + const collapseStart = ui.indexOf(' setPanelCollapsed('); const collapse = ui.slice( collapseStart, ui.indexOf('toggleCleanView(forceEnabled)', collapseStart), ); - assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed && !wasAutoCollapsed[\s\S]*?return;/); + assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/); assert.match(collapse, /_rightPanelStack\?\.contains\(panelEl\)[\s\S]*?_scheduleRightPanelLayout\(\{ reconsiderAutoCollapse: true \}\)/); assert.match(collapse, /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/); }); @@ -450,7 +463,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', ( test('Cockpit hides the complete top-center globe action group', () => { assert.match( css, - /body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\) \{ display: none !important; \}/, + /body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/, ); assert.match( css, @@ -467,14 +480,14 @@ test('Reset releases Contact camera ownership through its selection-preserving r assert.ok(resetStart >= 0); assert.ok(contextRelease > resetStart); assert.ok(satelliteRelease > contextRelease); - assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/); - assert.match( - ui, - /for \(const button of \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]\) \{[\s\S]*?addEventListener\('click', this\._globeResetHandler\)/, - 'both reset controls must delegate to the one shared reset route', - ); - assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/); - assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/); + assert.match(readShellElements.toString(), /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/); + const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8'); + assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/); + assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/); + assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/, + 'both reset controls delegate to the same supplied reset action'); + assert.match(cockpitEnter.toString(), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/); + assert.match(readRadioSource(new URL('./ui/cockpitTrackingController.js', import.meta.url), 'utf8'), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/); }); test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => { @@ -496,84 +509,86 @@ test('Location navigation releases immediate routes before flight and deferred r const poiHandler = ui.slice(ui.indexOf('_onPoiClick(cityId, poiIndex) {'), ui.indexOf('_expandPOIRow(cityId) {')); assert.match(cityHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/); assert.match(poiHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/); - assert.match(ui.slice(search, search + 320), /beforeFly: \(\) => this\._reassertNavigationHandoff\(generation\)/); + assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); + assert.match(ui.slice(search, search + 180), /\.\.\.options/); + const lookup = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); + assert.match(lookup, /beforeFly: \(\) => current\(\) && this\.beforeFly\(authority\)/); assert.ok(voiceStart >= 0, 'voice Location must use the same handoff'); assert.match(voiceActions, /styleManager\.reassertDeferredLocationNavigation\(generation\)/); }); test('Cockpit Radio station changes preserve first-person camera ownership', () => { - const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/); + const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/); assert.ok(cycleHelper, 'shared Radio cycle helper is missing'); assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/); - assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/); - assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/); + assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/); + assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/); assert.match( - ui, - /_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/, + radioBindings, + /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/, ); assert.match( - ui, - /_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/, + radioBindings, + /listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/, ); }); test('Cockpit panel corridors reserve the owned topline readouts', () => { - const leftObstacles = ui.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); - const rightObstacles = ui.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); + const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); + const leftObstacles = layout.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); + const rightObstacles = layout.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/); assert.ok(leftObstacles && rightObstacles, 'responsive panel obstacle selectors are missing'); assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline/); assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/); assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline > div/); assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline > div/); - const leftLayout = ui.match( - /_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/, - ); + const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); assert.ok(leftLayout, 'left accordion layout pass is missing'); assert.doesNotMatch( - leftLayout[1], + leftLayout, /setProperty\('--cockpit-utility-top'/, 'the right margin must not borrow the left accordion corridor: it is solved ' + 'against left-lane obstacles and put the strip through the briefing card', ); - const signalLayout = ui.match(/syncSignalLayout\(\) \{([\s\S]*?)\n \}\n\n dispose\(\)/); + const signalLayout = syncSignalLayout.toString(); assert.ok(signalLayout, 'Cockpit signal layout method is missing'); assert.match( - signalLayout[1], - /setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/, + signalLayout, + /setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/, 'Cockpit owns the utility strip anchor and republishes it every layout tick', ); assert.match( - signalLayout[1], + signalLayout, /resolveCockpitUtilityAnchor\(\{[\s\S]*?recBottom: recBounds \? recBounds\.bottom : 0,[\s\S]*?signalTop: signalBounds\.top,[\s\S]*?stripHeight: utilityBounds\.height,/, 'the strip hangs off the REC readout and is clamped by the briefing card it shares the margin with', ); - assert.match(signalLayout[1], /#intel-hud \.hud-top-right/); + assert.match(signalLayout, /#intel-hud \.hud-top-right/); assert.match( - signalLayout[1], - /const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/, + signalLayout, + /const recBounds = isRenderedOnScreen\(recReadout\)\s*\? recReadout\.getBoundingClientRect\(\)\s*: null;/, 'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC ' + 'readout a rect — a rect test alone would anchor the strip to an invisible readout', ); assert.match( - ui, - /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, + isRenderedOnScreen.toString(), + /function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/, ); assert.match( - ui, - /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.cockpitView\?\.scheduleContextLayout\(\);/, + layout, + /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.scheduleCockpitLayout\(\);/, 'the strip must remeasure on the same HUD fade the accordion does — the REC ' + 'readout keeps its rect until the transition ends', ); assert.doesNotMatch( - signalLayout[1], + signalLayout, /Math\.max\(120,/, 'the 120px corridor floor can never be reached by a 107px strip and only hid the collision', ); - assert.match(signalLayout[1], /const availableHeight = utilityAnchor\.maxHeight;/); - assert.match(signalLayout[1], /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/); + assert.match(signalLayout, /const availableHeight = utilityAnchor\.maxHeight;/); + assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/); assert.match( css, - /\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/, + /\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(\s*--cockpit-utility-top,\s*var\(\s*--left-stack-safe-top,\s*var\(\s*--left-stack-top\)\s*\)\s*\)/, 'the first frame before Cockpit publishes an anchor still needs the fallback chain', ); assert.match(css, /body\.cockpit-mode #left-panel-stack\s*\{[\s\S]*?transition:\s*none;/); @@ -585,17 +600,15 @@ test('an expanded Cockpit left panel stays above Contact, HUD, and attribution', /COCKPIT_PASSABLE_LEFT_OBSTACLE_SELECTOR|cockpitOverlaysPassable/, 'Cockpit obstacles must never be bypassed by an expanded map panel', ); - const leftLayout = ui.match( - /_syncLeftPanelAdaptiveLayout\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/, - ); + const leftLayout = fs.readFileSync(new URL('./ui/leftPanelRail.js', import.meta.url), 'utf8'); assert.ok(leftLayout, 'left accordion layout pass is missing'); assert.match( - leftLayout[1], + leftLayout, /rect\.top >= baseTop\) \{\s*\n\s*bottomObstacles\.push\(\{ top: rect\.top \}\);/, 'every rendered lower-lane obstacle must constrain the panel corridor', ); assert.match( - leftLayout[1], + leftLayout, /safeBottom = resolveLeftStackBottomBoundary\(\{[\s\S]*?safeGap,\s*\n\s*\}\);/, ); const cockpitHud = css.match(/#cockpit-hud\s*\{[\s\S]*?z-index:\s*(\d+);/); @@ -638,26 +651,26 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls ); assert.doesNotMatch(ui, /\['presets',/); assert.match( - ui, - /group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)/, + CockpitDisplayPortal.toString(), + /group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/, ); assert.match( - ui, - /_setCockpitDisplayPortalActive\(active\) \{[\s\S]*?record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/, + CockpitDisplayPortal.prototype.setActive.toString(), + /record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/, ); assert.match( - ui, - /window\.removeEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)[\s\S]*?_setCockpitDisplayPortalActive\(false\)[\s\S]*?record\.anchor\.remove\(\)/, + CockpitDisplayPortal.prototype.destroy.toString(), + /this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/, ); assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/); assert.equal((html.match(/id="style-buttons"/g) || []).length, 1); assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1); assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/); assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/); - assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/); + assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/); assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/); - assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/); - assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/); + assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/); + assert.match(cycleVisionMode.toString(), /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/); assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/); assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/); assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{[\s\S]*?min-width:\s*0;/); @@ -695,14 +708,12 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig }); test('cockpit aircraft handoff invalidates the prior world-position anchor', () => { - const match = ui.match( - /_adoptTrackedEntity\(nowMs, suppliedInfo = null\) \{([\s\S]*?)\n \}\n\n setVisionMode/, - ); + const match = _adoptTrackedEntity.toString(); assert.ok(match, 'cockpit tracked-entity handoff block is missing'); - assert.match(match[1], /this\.viewer\.trackedEntity = undefined;/); - assert.match(match[1], /this\.cockpitAnchorValid = false;/); - assert.match(match[1], /this\.heading = normalizeHeading\(info\.track \?\? 0\);/); - assert.match(match[1], /this\.lastFrameMs = nowMs;/); + assert.match(match, /this\.viewer\.trackedEntity = undefined;/); + assert.match(match, /this\.cockpitAnchorValid = false;/); + assert.match(match, /this\.heading = normalizeHeading\(info\.track \?\? 0\);/); + assert.match(match, /this\.lastFrameMs = nowMs;/); }); test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => { @@ -727,7 +738,7 @@ test('cockpit summary presents the focused item as Contact', () => { assert.match(match[0], /aria-label="Next — nearest unvisited contact in the 250 km window"/); assert.match(match[0], /aria-label="Collapse Contact panel"/); assert.doesNotMatch(match[0], />GLOBAL CONTEXT { @@ -750,7 +761,9 @@ test('Global Context names its mixed contact cycle without changing the stable m test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => { assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/); - assert.match(manager, /if \(!layer\.showInTogglePanel\) continue;/); + const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8'); + assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/); + assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/); assert.match(html, /id="global-context-panel"/); assert.match(html, /id="global-context-flights-btn"/); assert.match(html, /id="global-context-missions-btn"/); @@ -775,14 +788,12 @@ test('cockpit briefing cycle control keeps its state as the accessible name', () assert.match(match[0], />CYCLE OFF<\/button>/); assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./); - const update = ui.match( - /setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/, - ); + const update = setBriefAutoRotate.toString(); assert.ok(update, 'cockpit briefing cycle state updater is missing'); - assert.match(update[1], /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/); - assert.match(update[1], /setAttribute\('aria-label', label\)/); - assert.match(update[1], /\.title = help;/); - assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/); + assert.match(update, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/); + assert.match(update, /setAttribute\('aria-label', label\)/); + assert.match(update, /\.title = help;/); + assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/); }); test('voice Cockpit entry honours a requested contact layer before it enters', () => { @@ -849,7 +860,7 @@ test('cockpit state cannot report entryAllowed while already active', () => { ); assert.match( state, - /const entryAllowed = !active && Boolean\(/, + /const entryAllowed =\s*!active &&\s*Boolean\(/, 'entry is impossible while already inside, unconditionally', ); assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained'); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 1d2c80205..a0bd83676 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -1,3 +1,5 @@ +export { resolveHudRailLayout } from './ui/panelRailGeometry.js'; + /** Normalize a heading into the [0, 360) range. */ export function normalizeHeading(value) { if (!Number.isFinite(value)) return 0; @@ -23,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) { * becoming a first-person surge or reversal while still removing drift. */ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { - if (!Number.isFinite(distanceM) || distanceM <= 0 - || !Number.isFinite(dtSec) || dtSec <= 0) return 0; + if ( + !Number.isFinite(distanceM) || + distanceM <= 0 || + !Number.isFinite(dtSec) || + dtSec <= 0 + ) + return 0; const dt = Math.min(0.1, dtSec); const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const eased = distanceM * (1 - Math.exp(-1.25 * dt)); @@ -34,14 +41,21 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { /** Return whether a throttled cockpit presentation update is due. */ export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { - if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false; - if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true; + if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) + return false; + if ( + !Number.isFinite(lastUpdateMs) || + lastUpdateMs <= 0 || + nowMs < lastUpdateMs + ) + return true; return nowMs - lastUpdateMs >= intervalMs; } /** Return whether the bounded rendered-surface acquisition window has elapsed. */ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { - if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) return true; + if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) + return true; return nowMs - startedMs >= timeoutMs; } @@ -49,7 +63,11 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { * Keep the cockpit camera above the shared rendered-surface floor. * Unknown floors preserve the proposed camera height. */ -export function cockpitGroundSafeHeight(proposedHeightM, groundHeightM, clearanceM) { +export function cockpitGroundSafeHeight( + proposedHeightM, + groundHeightM, + clearanceM, +) { if (!Number.isFinite(proposedHeightM)) return proposedHeightM; if (!Number.isFinite(groundHeightM)) return proposedHeightM; const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0; @@ -66,36 +84,51 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) { } /** Format the cockpit Context scope without overstating installation coverage. */ -export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) { - const normalizedLabel = typeof subjectLabel === 'string' - ? subjectLabel.trim() || '—' - : Number.isFinite(subjectLabel) ? String(subjectLabel) : '—'; - const radiusKm = Number.isFinite(radiusM) && radiusM >= 0 - ? String(Math.round(radiusM / 1000)) - : '—'; - const coverage = typeof installationCoverage === 'string' - ? installationCoverage.trim() - : ''; +export function formatCockpitContextScope( + subjectLabel, + radiusM, + installationCoverage = null, +) { + const normalizedLabel = + typeof subjectLabel === 'string' + ? subjectLabel.trim() || '—' + : Number.isFinite(subjectLabel) + ? String(subjectLabel) + : '—'; + const radiusKm = + Number.isFinite(radiusM) && radiusM >= 0 + ? String(Math.round(radiusM / 1000)) + : '—'; + const coverage = + typeof installationCoverage === 'string' ? installationCoverage.trim() : ''; const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`; - return coverage - ? `${base} · INSTALLATIONS ${coverage}` - : base; + return coverage ? `${base} · INSTALLATIONS ${coverage}` : base; } /** Return seven 30-degree compass divisions centered on a heading. */ export function compassDivisions(heading) { const center = Math.round(normalizeHeading(heading) / 30) * 30; - return [-90, -60, -30, 0, 30, 60, 90].map((offset) => normalizeHeading(center + offset)); + return [-90, -60, -30, 0, 30, 60, 90].map((offset) => + normalizeHeading(center + offset), + ); } /** Format a compass division as a cardinal/intercardinal label or degrees. */ export function formatCompassDivision(heading) { const normalized = normalizeHeading(heading); const labels = new Map([ - [0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'], - [180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'], + [0, 'N'], + [45, 'NE'], + [90, 'E'], + [135, 'SE'], + [180, 'S'], + [225, 'SW'], + [270, 'W'], + [315, 'NW'], ]); - return labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0'); + return ( + labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0') + ); } /** Choose a readable altitude-tape interval for the current flight level. */ @@ -188,20 +221,22 @@ export function formatSpeedRulerTick(valueKt) { /** Return the initial great-circle bearing between two latitude/longitude points. */ export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) { if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null; - const φ1 = fromLat * Math.PI / 180; - const φ2 = toLat * Math.PI / 180; - const Δλ = (toLon - fromLon) * Math.PI / 180; + const φ1 = (fromLat * Math.PI) / 180; + const φ2 = (toLat * Math.PI) / 180; + const Δλ = ((toLon - fromLon) * Math.PI) / 180; const y = Math.sin(Δλ) * Math.cos(φ2); - const x = Math.cos(φ1) * Math.sin(φ2) - - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); + const x = + Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) return null; - return normalizeHeading(Math.atan2(y, x) * 180 / Math.PI); + return normalizeHeading((Math.atan2(y, x) * 180) / Math.PI); } /** Return a target bearing relative to the current heading in the [-180, 180) range. */ export function relativeBearing(bearing, heading) { if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null; - return ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180; + return ( + ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180 + ); } /** @@ -227,10 +262,13 @@ export function resolveTrackedAircraftInfo({ if (civilian) candidates.push({ ...civilian, layerId: 'flights' }); if (military) candidates.push({ ...military, layerId: 'military' }); if (!candidates.length) return null; - const trackedKey = String(trackedId || '').trim().toLowerCase(); + const trackedKey = String(trackedId || '') + .trim() + .toLowerCase(); if (trackedKey) { const owner = candidates.find( - (candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey, + (candidate) => + `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey, ); if (owner) return owner; } @@ -251,7 +289,10 @@ export function resolveTrackedAircraftInfo({ * aircraftRelative: boolean, contactLost: boolean}} * Panel visibility plus the per-field rendering rules for this frame. */ -export function resolveCockpitContextReadout({ snapshot = null, info = null } = {}) { +export function resolveCockpitContextReadout({ + snapshot = null, + info = null, +} = {}) { if (!snapshot) { return { visible: false, @@ -262,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } = }; } const trackedId = info?.icao24 ?? info?.id; - const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId - && String(snapshot.subject?.id) === String(trackedId); + const subjectMatchesTracked = + snapshot.subject?.layerId === info?.layerId && + String(snapshot.subject?.id) === String(trackedId); // `subjectPresent` is absent on snapshots built before the presence signal // existed; only an explicit `false` means the subject left its source. const contactLost = snapshot.subjectPresent === false; return { visible: true, - mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'), + mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign', subjectMatchesTracked, // The nose-relative arrow and BRG readout are measured in the tracked // aircraft's own frame; every other value in that row is measured from the @@ -282,45 +324,3 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } = contactLost, }; } - -/** - * Resolve a vertically centered panel slot inside a HUD rail while respecting - * live rectangles that intersect that rail above or below the viewport center. - */ -export function resolveHudRailLayout({ - viewportHeight, - panelHeight, - laneLeft, - laneRight, - obstacles = [], - baseTop, - baseBottom, - gap = 12, - align = 'center', -}) { - if (![viewportHeight, panelHeight, laneLeft, laneRight, baseTop, baseBottom] - .every(Number.isFinite) || viewportHeight <= 0 || laneRight <= laneLeft) return null; - const midpoint = viewportHeight * 0.5; - let safeTop = Math.max(0, baseTop); - let safeBottom = Math.min(viewportHeight, baseBottom); - - for (const rect of obstacles) { - if (![rect?.left, rect?.right, rect?.top, rect?.bottom].every(Number.isFinite)) continue; - if (rect.right <= laneLeft || rect.left >= laneRight || rect.bottom <= rect.top) continue; - if (rect.bottom <= midpoint) safeTop = Math.max(safeTop, rect.bottom + gap); - else if (rect.top >= midpoint) safeBottom = Math.min(safeBottom, rect.top - gap); - } - - safeBottom = Math.max(safeTop, safeBottom); - const availableHeight = Math.max(0, safeBottom - safeTop); - const renderedHeight = Math.min(Math.max(0, panelHeight), availableHeight); - return { - top: align === 'start' - ? safeTop - : safeTop + Math.max(0, (availableHeight - renderedHeight) * 0.5), - maxHeight: availableHeight, - safeTop, - safeBottom, - constrained: panelHeight > availableHeight, - }; -} diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs new file mode 100644 index 000000000..57e9ecd6f --- /dev/null +++ b/src/cockpitSignalFocus.test.mjs @@ -0,0 +1,219 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { afterEach } from 'node:test'; +import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; +import { CockpitViewController } from './ui/cockpitController.js'; +const previousDocument = globalThis.document; +afterEach(() => { globalThis.document = previousDocument; }); + +// Model native focus loss on removal AND on ordinary DOM moves. Keeping an +// object reference alone is insufficient if reconciliation disconnects it. +function fixture() { + const document = { activeElement: null }; + let focusLosses = 0; + let focusCalls = 0; + class Node { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentNode = null; + this.dataset = {}; + this.attributes = new Map(); + this.className = ''; + this._text = ''; + this.classList = { + add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); }, + }; + } + get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); } + get firstElementChild() { return this.children[0] || null; } + get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; } + get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); } + set textContent(value) { this.replaceChildren(); this._text = String(value); } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) ?? null; } + contains(node) { return this === node || this.children.some((child) => child.contains(node)); } + append(...nodes) { for (const node of nodes) this.insertBefore(node, null); } + insertBefore(node, anchor) { + assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent'); + if (node === anchor) return node; + node.remove(); + const index = anchor === null ? this.children.length : this.children.indexOf(anchor); + this.children.splice(index, 0, node); + node.parentNode = this; + return node; + } + remove() { + if (!this.parentNode) return; + if (this.contains(document.activeElement)) { + document.activeElement = document.body; + focusLosses += 1; + } + this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + replaceChildren(...nodes) { + for (const child of [...this.children]) child.remove(); + this._text = ''; + this.append(...nodes); + } + matches(selector) { + if (selector === 'button[data-signal-layer][data-signal-id]') { + return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null; + } + return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1)) + : this.tagName === selector.toUpperCase(); + } + closest(selector) { + for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node; + return null; + } + querySelectorAll(selector) { + return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]); + } + focus() { + assert.ok(this.isConnected, 'a focus destination must still be connected'); + focusCalls += 1; + document.activeElement = this; + } + } + document.body = new Node('body'); + document.createElement = (tag) => new Node(tag); + document.activeElement = document.body; + globalThis.document = document; + const controller = Object.create(CockpitViewController.prototype); + controller.services = { formatAwarenessLabel }; + controller.signalList = new Node('ol'); + controller.signalToggle = new Node('button'); + controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')]; + controller.briefPageIndex = 0; + controller.signalItems = []; + controller.signalSignatures = new Map(); + controller.scheduleContextLayout = () => {}; + const display = new Node('button'); + const radio = new Node('button'); + const outside = new Node('button'); + document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside); + const selected = []; + controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) }; + const click = event => controller.handleSignalClick(event); + const buttons = () => controller.signalList.querySelectorAll('button'); + const tab = () => { + const nodes = document.body.querySelectorAll('button'); + const index = nodes.indexOf(document.activeElement); + nodes[(index + 1) % nodes.length].focus(); + }; + return { controller, document, buttons, display, radio, outside, selected, click, tab, + losses: () => focusLosses, focusCalls: () => focusCalls }; +} + +const item = (id, overrides = {}) => ({ + key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM', + target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides, +}); +const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); }; +const snapshot = (distanceM = 2000) => ({ + evaluatedAt: 1_700_000_000_000, + subject: { layerId: 'flights', id: 'current', label: 'CURRENT' }, + cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }], +}); + +test('actual Context refresh retains the focused signal button across unchanged and changed details', () => { + const f = fixture(); + f.controller.updateCockpitSignals(snapshot(), 0); + const target = f.buttons()[1]; target.focus(); + const calls = f.focusCalls(); + for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0); + assert.equal(f.buttons()[1], target); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); + assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it'); + assert.match(f.controller.signalList.textContent, /2\.5 KM/); +}); + +test('signal content and accessible name update without replacing the active target', () => { + const f = fixture(); render(f, [item('a')]); + const target = f.buttons()[0]; target.focus(); + render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]); + assert.equal(f.buttons()[0], target); + assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED'); + assert.match(target.textContent, /RENAMED/); + assert.match(f.controller.signalList.textContent, /CURRENT/); + assert.equal(f.controller.signalList.children[0].className, 'track actionable'); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); +}); + +test('signal ranking changes move surrounding rows without disconnecting the focused row', () => { + const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id))); + const target = f.buttons()[1]; target.focus(); + const calls = f.focusCalls(); + for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) { + render(f, order.map((id) => item(id))); + assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); + } + assert.equal(f.focusCalls(), calls); +}); + +test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => { + const f = fixture(); render(f, [item('a'), item('b')]); + const target = f.buttons()[1]; target.focus(); + f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE'); + assert.equal(f.document.activeElement, target); + assert.equal(f.losses(), 0); + const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } }; + f.click(event); + assert.equal(event.prevented, true); + assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]); +}); + +test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => { + const f = fixture(); render(f, [item('a'), item('b')]); + const removed = f.buttons()[1]; removed.focus(); + const calls = f.focusCalls(); + render(f, [item('a')]); + assert.equal(removed.isConnected, false); + assert.equal(f.document.activeElement, f.controller.briefTabs[0]); + assert.equal(f.focusCalls(), calls + 1); + f.tab(); f.tab(); f.tab(); + assert.equal(f.document.activeElement, f.display); + render(f, [item('a', { detail: 'UPDATED' })]); + assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back'); + f.tab(); + assert.equal(f.document.activeElement, f.radio); +}); + +test('a changed target cannot reuse a focused action for another flight', () => { + const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]); + const removed = f.buttons()[0]; removed.focus(); + render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]); + const target = f.buttons()[0]; + assert.notEqual(target, removed); + assert.equal(removed.isConnected, false); + assert.equal(f.document.activeElement, f.controller.briefTabs[0]); + f.click({ target, preventDefault() {} }); + assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]); +}); + +test('empty, informational and duplicate-key rows have no stale or aliased actions', () => { + const f = fixture(); + render(f, [item('a'), item('a')]); + const [first, second] = f.buttons(); + assert.notEqual(first, second); + second.focus(); + render(f, [item('a'), item('a')]); + assert.deepEqual(f.buttons(), [first, second]); + assert.equal(f.document.activeElement, second); + render(f, [item('status', { target: null, title: 'UNKNOWN' })]); + assert.equal(f.buttons().length, 0); + assert.match(f.controller.signalList.textContent, /UNKNOWN/); + assert.equal(f.document.activeElement, f.controller.briefTabs[0]); + f.outside.focus(); + render(f, []); + assert.equal(f.controller.signalList.children.length, 0); + render(f, [item('a')]); + assert.notEqual(f.buttons()[0], first); + assert.equal(f.document.activeElement, f.outside); +}); diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js index dea0a77a3..0d6c09327 100644 --- a/src/cockpitUtilityLayout.js +++ b/src/cockpitUtilityLayout.js @@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({ gap = 7, minimumExpandedHeight = 120, }) { - const available = Math.max(minimumExpandedHeight, Number(availableHeight) || 0); + const available = Math.max( + minimumExpandedHeight, + Number(availableHeight) || 0, + ); const expanded = Math.max(0, Number(expandedHeight) || 0); const collapsed = Math.max(0, Number(collapsedHeight) || 0); const spacing = Math.max(0, Number(gap) || 0); @@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({ ), }; } - diff --git a/src/cockpitVisionPolicy.js b/src/cockpitVisionPolicy.js index 40602df2e..c49aa35da 100644 --- a/src/cockpitVisionPolicy.js +++ b/src/cockpitVisionPolicy.js @@ -1,4 +1,10 @@ -export const COCKPIT_VISION_MODES = Object.freeze(['optical', 'crt', 'nvg', 'thermal', 'noir']); +export const COCKPIT_VISION_MODES = Object.freeze([ + 'optical', + 'crt', + 'nvg', + 'thermal', + 'noir', +]); const TARGET_STYLE_BY_MODE = Object.freeze({ crt: 'retro', diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 0dcb5a6f0..331151f8e 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -1,3 +1,4 @@ +import { _syncContextModeButtons } from './ui/contextPresentation.js'; // Contacts-scoped detection (owner playtest 2026-08-18: "when you click on // Contacts, detections should just turn on, and they should stay on in Cockpit // or in third-person tracking inside Contacts"). @@ -26,7 +27,9 @@ import { } from './data/detection.js'; import { canonicalizeDensity } from './data/detectionPolicy.js'; -const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); +// Follow the UI wiring and its extracted preset definitions. +const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8') + + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); /** * The tactical preset ui.js hands Contacts. Read out of the source so this test @@ -34,7 +37,7 @@ const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8'); */ const MILITARY_PRESET = (() => { const match = uiSource.match( - /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: '(\w+)', densityPct: (\d+) \}\)/, + /const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: '(\w+)',\s*densityPct: (\d+),?\s*\}\)/, ); assert.ok(match, 'ui.js must expose one shared military detection preset'); return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) }; @@ -290,10 +293,11 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc 'ACTIVATION turns detection on REGARDLESS of the style-preset override flag', ); assert.match( - uiSource, - /this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/, + _syncContextModeButtons.toString(), + /this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/, 'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through', ); + assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/); // The cockpit vision hook — the old trigger — must be out of the detection // business entirely, or leaving the cockpit turns detections off again. const visionHook = uiSource.slice( diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 8aff666fd..af836c793 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -1,7 +1,15 @@ -// Source-contract pins for StyleManager's Context-session handler ordering. -// ui.js cannot be imported headlessly (it touches the DOM at module scope), so -// these read the shipped source — the idiom established by cockpitMarkup / -// cameraHandoff tests. Each pin guards a bug that shipped or nearly shipped: +import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; +import { connectContextManager } from './ui/contextSubscriptions.js'; +import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js'; +import { _selectContextMode, _clearLayersOutsideContextMode } from './ui/contextTransactions.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; +import { setContextMode as contextModeAction } from './ui/contextActions.js'; +import { ContextControls } from './ui/contextControls.js'; +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); +// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped: // - session bookkeeping ran AFTER the exit early-return, so the compensating // userAdded.delete never ran on the left-panel chip exit and restoration // resurrected the mission layer the user just disabled; @@ -15,12 +23,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8'); +const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8'); -const handlerStart = src.indexOf('_handleContextLayerChange(change) {'); -assert.ok(handlerStart > 0, 'handler found'); -const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart); -const handler = src.slice(handlerStart, handlerEnd); +const handler = _handleContextLayerChange.toString(); test('context handler: session bookkeeping runs before the exit early-return', () => { const record = handler.indexOf('recordContextSessionUserChange('); @@ -52,23 +57,19 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi }); test('direct Context entry captures on the synchronous request boundary', () => { - const attachStart = src.indexOf('attachDataManager(dataManager) {'); - const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart); - const attach = src.slice(attachStart, attachEnd); + const attach = connectContextManager.toString(); assert.match( attach, /subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/, ); assert.match( attach, - /_captureContextSessionSnapshot\(\{ excludeLayerIds: \[change\.layerId\] \}\)/, + /_captureContextSessionSnapshot\(\{\s*excludeLayerIds:\s*\[change\.layerId\],?\s*\}\)/, ); }); test('Clear All defers a newer explicit mission guard until its batch settles', () => { - const attachStart = src.indexOf('attachDataManager(dataManager) {'); - const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart); - const attach = src.slice(attachStart, attachEnd); + const attach = connectContextManager.toString(); assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/); assert.match(attach, /_contextModeDeferredEntryIntent = \{/); assert.match(attach, /await this\._clearSelectedLayersManagerPromise/); @@ -76,10 +77,7 @@ test('Clear All defers a newer explicit mission guard until its batch settles', }); test('context restore replays explicit companion intent after the stale restore queue drains', () => { - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _selectContextMode('), - ); + const restore = _restoreContextSession.toString(); const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds('); const replay = restore.indexOf('await settleContextIntentReplay('); assert.ok(restoreAwait > 0, 'restore queue is awaited'); @@ -88,10 +86,7 @@ test('context restore replays explicit companion intent after the stale restore }); test('context restore settles the Contacts coordinator before dependency fanout', () => { - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _selectContextMode('), - ); + const restore = _restoreContextSession.toString(); const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'"); const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle); const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', settle); @@ -102,10 +97,7 @@ test('context restore settles the Contacts coordinator before dependency fanout' }); test('context restore preserves its exact pending target after a failed transition', () => { - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _selectContextMode('), - ); + const restore = _restoreContextSession.toString(); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/); assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/); @@ -120,25 +112,22 @@ test('context handler: either failed direct Context-shell start rolls the sessio assert.match(failedBranch, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); assert.match( failedBranch, - /_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{ notificationToken \},\s*\)/, + /_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{\s*notificationToken,?\s*\},?\s*\)/, ); assert.match(failedBranch, /_runUserFacingContextAction/); assert.match(failedBranch, /const failureMessage =/); assert.match( failedBranch, - /await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\._showToast\(failureMessage\);\s*return true;/, + /await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/, ); - assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/); + assert.match(failedBranch, /\},\s*failureMessage,?\s*\),?\s*\);/); assert.match(failedBranch, /_trackContextLayerReaction\(/); assert.match( failedBranch, - /else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/, + /else if \(\s*!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\s*\)/, ); - const deferredRestore = src.slice( - src.indexOf('async _restoreContextSessionAfterLayerSettles('), - src.indexOf('async _selectContextMode('), - ); + const deferredRestore = _restoreContextSessionAfterLayerSettles.toString(); assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/); assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/); assert.doesNotMatch(failedBranch, /excludeLayerIds/); @@ -153,50 +142,45 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token blockedBranch, /!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/, ); - assert.match(blockedBranch, /this\._showToast\(/); + assert.match(blockedBranch, /this\.showToast\(/); }); test('every user-facing Context exit route settles through the failure surface', () => { - const initPanel = src.slice( - src.indexOf('_initGlobalContextPanel() {'), - src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')), - ); + const initPanel = _initGlobalContextPanel.toString(); assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3); assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/); assert.doesNotMatch(initPanel, /void this\._selectContextMode/); const deactivationCalls = [...handler.matchAll( - /void this\._trackContextLayerReaction\(this\._runUserFacingContextAction\(\(notificationToken\) => \(\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\)\s*\)\)\)/g, + /void this\._trackContextLayerReaction\(\s*this\._runUserFacingContextAction\(\(notificationToken\) =>\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\),?\s*\),?\s*\)/g, )]; assert.equal(deactivationCalls.length, 4, 'dependency and primary layer exits share the caught restore path'); assert.doesNotMatch(handler, /void this\._deactivateContextForLayerChange\(\)/); }); test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => { - const radioControls = src.slice( - src.indexOf('const toggleRadio = async (trigger) => {'), - src.indexOf("this._radioFilter?.addEventListener('change'"), + const radioControls = radioBindings.slice( + radioBindings.indexOf('const toggleRadio = async (trigger) => {'), + radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), ); - assert.match(radioControls, /await this\._runUserFacingContextAction\(/); + assert.match(radioControls, /await this\.actions\.runUserAction\(/); assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/); - assert.match(radioControls, /if \(toggled === false\) return/); + assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/); }); test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => { - const radioControls = src.slice( - src.indexOf('const toggleRadio = async (trigger) => {'), - src.indexOf("this._radioFilter?.addEventListener('change'"), + const radioControls = radioBindings.slice( + radioBindings.indexOf('const toggleRadio = async (trigger) => {'), + radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), ); assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/); - assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); - assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2); + assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); + assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1); + assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/); }); test('right-rail context entry is transactional: activation result gates the mode', () => { - const select = src.slice( - src.indexOf('async _selectContextMode('), - src.indexOf('async _deactivateContextForLayerChange('), - ); + const select = _selectContextMode.toString(); const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent('); assert.ok(activation > 0, 'activation result captured'); const failureBlock = select.slice(activation); @@ -206,8 +190,8 @@ test('right-rail context entry is transactional: activation result gates the mod assert.match(failureBlock, /await this\._dataManager\._waitForVisibilityIntent\?\.\(/); assert.match(failureBlock, /outcome\?\.intentEpoch === replacementIntent\.intentEpoch[\s\S]*?outcome\.succeeded === true/); assert.match(failureBlock, /outcome\?\.cancellationReason === 'superseded'[\s\S]*?outcome\.successorEnabled === true[\s\S]*?outcome\.successorIntentEpoch > replacementIntent\.intentEpoch/); - assert.match(failureBlock, /activationError \|\| activated === false \|\| !this\._dataManager\.isEnabled\(entryLayerId\)/); - assert.match(failureBlock, /cancelledAndSettled = terminalIntentOutcome\?\.succeeded === false/); + assert.match(failureBlock, /activationError\s*\|\|\s*activated === false\s*\|\|\s*!this\._dataManager\.isEnabled\(entryLayerId\)/); + assert.match(failureBlock, /cancelledAndSettled\s*=\s*terminalIntentOutcome\?\.succeeded === false/); assert.match(failureBlock, /\['caller-abort', 'resource-abort', 'superseded'\]/); assert.match(failureBlock, /this\._contextMode = null/); assert.match(failureBlock, /await this\._restoreContextSession\(\{[\s\S]*?excludeLayerIds: \[entryLayerId\],[\s\S]*?notificationToken/); @@ -215,11 +199,8 @@ test('right-rail context entry is transactional: activation result gates the mod }); test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => { - const select = src.slice( - src.indexOf('async _selectContextMode('), - src.indexOf('async _deactivateContextForLayerChange('), - ); - assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/); + const select = _selectContextMode.toString(); + assert.match(select, /await this\._clearLayersOutsideContextMode\(mode,\s*\{\s*notificationToken,\s*signal,?\s*\}\)/); assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/); assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/); @@ -250,21 +231,19 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi // invalidates in-flight Context transactions so teardown cannot be raced into // publishing a mode. const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {'))); - assert.match(dispose, /this\._contextModeGeneration \+= 1;/); + assert.match(dispose, /this\._contextControls\.stop\(\)/); + assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/); - const guard = src.slice( - src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'), - src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"), - ); + const guard = connectContextManager.toString(); assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/); - assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/); + assert.match(guard, /const notificationToken\s*=\s*change\.notificationToken\s*\|\|\s*Symbol\('direct-context-shell-entry'\)/); assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/); - assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode, \{ notificationToken \}\)/); + assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode,\s*\{\s*notificationToken,?\s*\}\)/); assert.match( guard, /await this\._restoreContextSession\(\{\s*excludeLayerIds: \[change\.layerId\],\s*notificationToken,\s*\}\)/, ); - assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(notificationToken\)/); + assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(\s*notificationToken,?\s*\)/); }); test('a lost cross-mode switch says Context is off, and the state agrees', () => { @@ -272,14 +251,11 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () => // a bare "did not complete" while the operator's Context was silently gone. // Text and state are derived from the same verdict so they cannot disagree, // and the failed layer ids survive (Manjunath's honesty requirement). - const setter = src.slice( - src.indexOf(' async setContextMode(mode, {'), - src.indexOf(' getCockpitState() {'), - ); + const setter = contextModeAction.toString(); assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost'); assert.match( setter, - /const crossModeSwitchLost = transitioned !== true\s*&& Boolean\(priorMode\) && priorMode !== canonical && !state\.mode;/, + /const crossModeSwitchLost\s*=\s*transitioned !== true\s*&&\s*Boolean\(priorMode\)\s*&&\s*priorMode !== canonical\s*&&\s*!state\.mode;/, 'the verdict requires a real cross-mode switch that ended with no mode', ); assert.match( @@ -296,14 +272,10 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () => }); test('Context cancellation reaches isolation and restore lifecycle mutations', () => { - const clearStart = src.indexOf('async _clearLayersOutsideContextMode('); - const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart)); + const clear = _clearLayersOutsideContextMode.toString(); assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/); - const restore = src.slice( - src.indexOf('async _restoreContextSession('), - src.indexOf('async _restoreContextSessionAfterLayerSettles('), - ); + const restore = _restoreContextSession.toString(); assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/); assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/); assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/); @@ -314,10 +286,7 @@ test('Context cancellation reaches isolation and restore lifecycle mutations', ( }); test('Context facade preserves success when cancellation arrives after commit', () => { - const setContextMode = src.slice( - src.indexOf('async setContextMode('), - src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')), - ); + const setContextMode = contextModeAction.toString(); assert.match( setContextMode, /transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/, @@ -329,23 +298,17 @@ test('Context facade preserves success when cancellation arrives after commit', }); test('Context production rollback paths merge primary and restore failed-layer identities', () => { - const select = src.slice( - src.indexOf('async _selectContextMode('), - src.indexOf('async _deactivateContextForLayerChange('), - ); + const select = _selectContextMode.toString(); assert.equal( - (select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length, + (select.match(/mergeContextTransitionErrors\(\s*transitionError,\s*restoreError,?\s*\)/g) || []).length, 2, ); - assert.match(select, /transitionError\.failedLayerIds = \[\.\.\.new Set\(\[/); - assert.match(select, /this\._contextTransitionFailedLayerIds = \[\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\)\]/); + assert.match(select, /transitionError\.failedLayerIds = \[\s*\.\.\.new Set\(\[/); + assert.match(select, /this\._contextTransitionFailedLayerIds = \[\s*\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\),?\s*\]/); }); test('stale Context cancellation preserves rollback failed-layer identities', () => { - const setContextMode = src.slice( - src.indexOf('async setContextMode('), - src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')), - ); + const setContextMode = contextModeAction.toString(); assert.match( setContextMode, /if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs new file mode 100644 index 000000000..49a640d97 --- /dev/null +++ b/src/contextTabKeyboard.test.mjs @@ -0,0 +1,151 @@ +import { readStylesheet } from './testSupport/readStylesheet.mjs'; +import { _selectContextMode } from './ui/contextTransactions.js'; +import { _syncContextModeButtons } from './ui/contextPresentation.js'; +import { _initGlobalContextPanel } from './ui/contextBindings.js'; +import { clearSelectedLayers } from './ui/contextActions.js'; +import { readFileSync as readRadioSource } from 'node:fs'; +const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); +const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); +const css = readStylesheet(new URL('../style.css', import.meta.url)); + +test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { + for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) { + const button = html.match(new RegExp(`