This commit is contained in:
Redacted-Coder 2026-09-08 22:40:19 +00:00 committed by GitHub
commit e6fceb6a14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2269 additions and 1291 deletions

View File

@ -1,4 +1,10 @@
# God's Eye View — environment variables
# Set to 1 to block paid Google/OpenAI services while allowing free-account keys.
GEV_FREE_ONLY=1
# Optional Photon endpoint override (server operator configured, not user input).
# GEV_PHOTON_URL=https://photon.komoot.io/api/
# Conservative local traffic tile budget. Use a provider account without paid overage.
TOMTOM_DAILY_TILE_BUDGET=5000
# 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.
#
@ -63,9 +69,9 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# GEV_RATELIMIT_OPENAI_PER_MIN=30
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
# Default: oauth — requires client credentials below
# Use anonymous access for the free, no-account setup.
# Set to "anon" to skip aircraft auth (rate-limited but works)
OPENSKY_AUTH_MODE=oauth
OPENSKY_AUTH_MODE=anon
# Optional: OpenSky OAuth credentials (from opensky-network.org account dashboard)
OPENSKY_CLIENT_ID=
@ -116,18 +122,15 @@ AISSTREAM_API_KEY=
# 0 disables the silence watch entirely.
# AISSTREAM_SILENCE_TIMEOUT_MS=120000
# Optional: TomTom live traffic flow (BYOK freemium — free tier is ~50,000 tile
# requests/day, get a key at https://developer.tomtom.com). SERVER-SIDE ONLY:
# Optional: TomTom live traffic flow (BYOK — check your current account
# allowance at https://developer.tomtom.com). SERVER-SIDE ONLY:
# the browser fetches same-origin /api/tomtom/* and never sees the key.
# Keyless fallback: the traffic layer runs its built-in simulation (white dots,
# hardcoded per-road-class speeds) — no key required for the layer to work.
# TOMTOM_API_KEY=
# Optional soft cap on upstream tile fetches per UTC day (default 40000) — a
# configurable application safety ceiling, not a guarantee of staying within
# TomTom's free allowance (currently 200K tile requests/month:
# https://docs.tomtom.com/pricing/). Over the cap the proxy serves cached/stale
# tiles instead of hitting upstream.
# TOMTOM_DAILY_TILE_BUDGET=40000
# Optional soft cap on upstream tile fetches per UTC day (set to 5000 above).
# Over the cap the proxy serves cached/stale tiles instead of hitting upstream.
# TOMTOM_DAILY_TILE_BUDGET=5000
# Optional: CCTV layer tuning (advanced). Defaults are sensible — leave unset
# unless you're customizing the camera source pack.

22
.github/workflows/phase1.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Phase 1 checks
on: [push, pull_request]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
PUPPETEER_SKIP_DOWNLOAD: 'true'
GEV_FREE_ONLY: '1'
GEV_REQUIRE_ALLOCATION_GATE: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24.19.0'
cache: npm
- run: npm ci
- run: npm test
- run: npm run test:phase1
- run: npm run build

8
.gitignore vendored
View File

@ -10,5 +10,13 @@ output/
.DS_Store
.gstack/
3d-models/
# Private configuration and local development artifacts
.env.*
!.env.example
*.pem
*.key
.codex/
.agents/
pinokio/ENVIRONMENT
pinokio/.installed

View File

@ -262,3 +262,14 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09
- Initial project version.
## Phase 1 community preview
- Add portable free-only startup and source-health diagnostics.
- Expand camera regions and enable official HLS playback.
- Add saved cities, Boston transit, global hazard reports, weather units and radar.
- Retain known grounded-aircraft regression limitations; upstream integration pending.
## Phase 2 community preview
- Integrate upstream v0.1.1 through 7596522 while retaining Phase 1.
- Add original-creator credits and a free, independently implemented Situation Desk.
- Add region watchlists, headline context maps, saved stories, briefings and notes.

View File

@ -130,3 +130,20 @@ Douglas-Peucker simplification, 6-decimal rounding).
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
## Phase 1 runtime additions
These connections fetch provider content at runtime; this contribution does not
bundle camera images, video recordings or weather observations.
- WSDOT camera catalog and snapshots: https://data.wsdot.wa.gov/ ; preserve WSDOT attribution and provider conditions.
- Caltrans HLS: official streaming URLs in the existing Caltrans catalog; on-demand playback, not redistributed recordings.
- MBTA vehicle positions: https://api-v3.mbta.com/ ; Boston only, subject to MBTA developer terms.
- GDACS: https://www.gdacs.org/ ; attributed published disaster reports, not comprehensive warnings.
- Open-Meteo: https://open-meteo.com/ ; CC BY 4.0 attribution; public endpoint limited to eligible noncommercial use.
- RainViewer: https://www.rainviewer.com/api.html ; free API use within provider terms, attribution retained; incomplete radar coverage.
- Photon: https://github.com/komoot/photon ; OpenStreetMap-based search, moderate public-service use; configurable self-hosted endpoint.
## Phase 2 Situation Desk
Google News RSS indexes publisher headlines and links, retrieved at runtime.
No article bodies, images, video or Conflictly feeds are bundled. Publisher
content retains its own terms. Country-name matches use an explicitly labeled
small country-center catalog for regional context, not incident geolocation.

196
FREE-START.md Normal file
View File

@ -0,0 +1,196 @@
# Gods Eye — free local setup
Install Node.js 24.14+ within version 24, or Node.js 26. In this folder, run `npm ci`,
then `npm start`, and visit http://localhost:4173.
On macOS, **Start Gods Eye.command** is an optional launcher; a downloaded copy
may require executable permission first.
Keep the launcher window open while using the app. Press Control-C to stop it.
If Gods Eye is already running, use the existing browser page instead of starting a second copy.
This launcher selects a compatible Node runtime and enables `GEV_FREE_ONLY=1`.
Google and OpenAI credentials are blocked; optional free-account keys in `.env`
are allowed. No subscription or paid API account is configured.
Try the Locations buttons or search for a city, landmark, or address and press
Enter. Bundled places work locally; other places use Photon / OpenStreetMap
through a bounded, cached proxy. The public Photon service permits moderate
project use and has no availability guarantee: https://github.com/komoot/photon.
Set `GEV_PHOTON_URL` to use your own compatible service if needed.
Turn on Earthquakes, Flights, Satellites, or the bundled infrastructure layers.
Public feeds need internet access and can be delayed, rate-limited, or unavailable.
Traffic without a TomTom key is a labeled simulation.
Use the **◉ Source Status** button at the top to inspect connections and receipt
ages, and to refresh enabled sources. Enabling Space Missions temporarily stages
its own layers; disabling it restores the previous view. It does not mean the
other feeds stopped working.
## Free-account connections
Add your own keys to `.env`, keep `GEV_FREE_ONLY=1`, and restart the launcher.
Do not paste keys into chat or save them in browser URLs.
| Feature | Variable | Account / key |
| --- | --- | --- |
| Live ships | `AISSTREAM_API_KEY` | https://aisstream.io/ |
| Fire detections | `FIRMS_MAP_KEY` | https://firms.modaps.eosdis.nasa.gov/api/map_key/ |
| Real traffic flow | `TOMTOM_API_KEY` | https://docs.tomtom.com/pricing |
TomTom lists a free vector-traffic tile allowance. Use an account with no paid
overage enabled. This setup limits requests to 5,000 tiles/day; that local counter
cannot account for other apps sharing your provider quota. Check your account's
allowance before adding a key. Without it, traffic remains explicitly simulated.
Google photorealistic buildings and OpenAI voice/AI commands are disabled under
the no-paid-services constraint. The free map is mapped imagery and terrain,
not live satellite video. Satellites are propagated from orbital elements;
CCTV is provider snapshots or streams with limited coverage; fire detections
and aircraft reports have inherent source delays. Buildings, cables, dams,
and datacenters are reference data rather than live activity.
For a manually configured launch, use a supported Node version, run `npm ci`,
then `npm run dev -- --host localhost --port 4173`. That command uses `.env`.
The free launcher keeps Google and OpenAI disabled even if `.env` later changes.
## Verification of this setup
- Production build passed using Node 24.19.0.
- All 2,607 unit and allocation tests passed after the free-source changes.
- Browser checks confirmed OpenStreetMap rendering, live aircraft tracking,
a keyless Tokyo search, 36 earthquakes, and 834 satellites. Feed counts vary.
- The retry verified free place search for Reykjavik, 5,537 aircraft in the
server snapshot, 50 military aircraft, 25 space missions, 800 camera directory
entries, and live weather. An Austin CCTV snapshot displayed `SNAPSHOT · OK`.
Directory entries do not guarantee every camera is working. The station-only
satellite endpoint returned 21 objects; the UI's four catalogs totalled 834.
- The added AISStream, NASA FIRMS, and TomTom keys were verified against real
payloads: a live ship stream, 183,552 fire detections from three sources with
`stale: false`, and a traffic tile decoded into 870 road segments. Counts vary.
- Fixed the mapped-installations proxy to try another public mirror after
upstream access rejections. The previously failing request now returns HTTP
200 with six mapped objects in the Austin test area. Free-mode HUD summaries
now use local metrics without repeatedly calling the disabled OpenAI service.
- The 21 targeted proxy/HUD tests and production build passed after these fixes.
- Fresh browser checks showed ships and fires ON, TomTom traffic ON with 96%
coverage, CCTV and mapped layers finishing loading, and no console errors.
Visual inspection still found a provider's "Image Unavailable" camera picture
delivered with HTTP 200. The badge now says RECEIVED instead of OK and explains
that delivery does not prove camera availability. Individual cameras remain
provider-dependent; a successful directory request is not a camera health test.
- Ship client refresh is now 10 seconds (previously 60), reading the server's
existing live stream cache; no extra upstream AIS subscription was added.
- Additional UI checks loaded 750 radio directory entries, about 4,400
datacenters, 716 dams, and about 2,600 cable reference objects. Radio playback
itself was not tested. Bikeshare returned no stations at the current view;
mapped installations requested a closer view. These are not verified live feeds.
- During the retry, OpenSky switched to the existing adsb.lol regional fallback.
That preserves nearby aircraft but is not global coverage. The Source Status
panel now carries the same fallback/stale labels as the layer controls.
- Before the latest proxy fix, the broader tracking harness finished with 96 passes and 4 failures:
three checks reported one HTTP 503 from the military-installations endpoint;
one ground-model sampling-count assertion also failed. The endpoint now passes
the live payload check; this does not establish that every tracking assertion passes.
- A later full tracking-harness run was interrupted by a development-page reload
(execution context destroyed) during the camera-label change, so it is not a
completed regression result. The final production build passed.
- Dependency hardening on September 6 updated compatible packages plus Puppeteer
and Sharp to patched releases. The install audit reported zero known
vulnerabilities. This is not a security certification or a public deployment review.
## City Pulse, Storm Watch, and worldwide weather
Open **◈ City Pulse, Storm Watch & World Weather** at the top of the map.
The panel adds its own map markers; selecting a result flies to its location.
**Focus map** temporarily hides other enabled layers; **Restore other layers**
or **Stop & close** brings them back. Closing stops these feeds and clears their markers.
- **City Pulse:** Boston transit positions from MBTA (up to 200 vehicles,
refreshed every 30 seconds). Other cities retain access to the existing traffic,
camera and bikeshare controls where those providers have coverage. Transit is
not worldwide. Use **Enable city layers** and **Browse cameras** as needed.
- **Storm Watch:** global GDACS cyclone, flood, drought, wildfire and volcano
reports, refreshed every six minutes. Up to 300 reports are mapped; cyclone,
flood, drought and volcano reports take priority over wildfire overflow.
These are published event reports, not radar, comprehensive local warnings,
or river-gauge measurements. Each record shows its report date and severity.
- **Weather around the world:** an 18-city global overview plus weather search
for other places. Open-Meteo provides model-based current conditions and
three-day forecasts, labeled separately from direct station observations.
Temperature offers Fahrenheit/Celsius, wind km/h, precipitation mm, and weather times UTC.
Refresh interval is ten minutes; source model updates can be less frequent.
Server caches coalesce simultaneous requests. On provider failure, cached data
less than an hour old may be returned explicitly marked STALE; older server
cache is not returned as current. Client-retained data also gets a stale label
if refresh fails. The Open-Meteo public endpoint is intended for noncommercial
use within its free limits; no paid service or new key was configured.
Sources: https://api-v3.mbta.com/ · https://www.gdacs.org/ · https://open-meteo.com/
Verified: 18 overview weather locations, 200 Boston vehicle records, and 300
hazard reports (including 7 cyclone and 15 flood reports in the test snapshot).
Browser checks covered all three views, focused mapping, and weather search for
Reykjavik. Five targeted tests and the production build passed. Existing unrelated
grounded-aircraft regression failures have not been changed by this addition.
## More camera cities and live video
Use the **▣ Browse camera cities and live video** button at the top of the map.
Choose a city/region and camera. Entries marked **VIDEO** expose an official
Caltrans HLS stream; press **Play live video** to start it. Return to snapshot,
change cameras, or close the dialog to release the stream. Playback is on demand
and limited to one stream in this browser panel. Traffic layers can remain enabled.
The expanded catalog loads all 12 Caltrans districts and adds 300 WSDOT cameras
across 10 Washington areas. The current 900-camera cap is shared across sources
to retain geographic variety. On verification it contained 216 live-video links;
individual stream availability varies. Los Angeles video played successfully in
the browser. Washington and London are labeled snapshots, not continuous video.
Washington source documentation: https://data.wsdot.wa.gov/arcgis/rest/services/TravelInformation/TravelInfoCamerasWeather/FeatureServer/0
No additional account or paid service is required for these additions. Set
`CCTV_WSDOT_ENABLED=0` to omit Washington; `CCTV_CALTRANS_DISTRICTS` can restrict
districts. Catalogs remain cached for 15 minutes. Selected snapshot requests run
every 30 seconds; provider image-update intervals may be longer. The live player
uses free HLS.js when the browser lacks native HLS playback. New York and Singapore
have not been connected in this change.
## Source health without extra API usage
The top **◉** control now shows how many enabled sources report a fault, stale
data, or a fallback. It checks the existing layer metadata every five seconds;
it does not make extra provider requests or consume API allowance. Selected
snapshot feeds also have conservative receipt deadlines to catch stalled updates.
Reference catalogs and predicted satellite positions are explicitly distinguished.
Open the control and choose **Download health report** to save a JSON report of
current states and the latest 200 state changes observed during this page session.
The report excludes raw errors, URLs, credentials, and coordinates. It is a
diagnostic journal, not a recording of observations, and resets on page reload.
Receipt age cannot establish observation freshness. Individual camera availability
still requires inspection; a provider can successfully return a placeholder image.
Validation after dependency and health changes: all 2,611 unit and allocation
checks passed, including simulated stalled receipts and recovery, bounded journal
retention, and exclusion of raw sensitive fields. The production build passed.
The completed browser tracking run passed 87 checks and failed four: grounded
models becoming ready, tracked model zoom transitions, retained hidden-model
flooring, and shown-but-not-ready fleet-model flooring. These remain unresolved.
The same full run recorded no console errors and no HTTP 5xx responses.
### Live view preferences and radar (September 6)
City Pulse now supports place search and up to 50 favorite cities saved in this
browser. Favorites appear first in the city selector and can be removed. Weather
offers remembered Fahrenheit/Celsius units for map labels, current conditions and
daily forecasts. New preferences default to Fahrenheit.
Storm Watch uses hazard symbols, severity colors and a category filter; labels
appear closer to the ground. These are report locations, not hazard footprints.
Storm Watch and World Weather include optional RainViewer precipitation radar,
a frame selector for the past two hours and opacity control. Metadata refreshes
every five minutes while open; tiles use the documented maximum zoom of 7.
Coverage is incomplete and frame generation times differ from observation times.
This is reflectivity, not Doppler velocity. No new API key is required.
Verification: 8 focused tests passed; production build passed; world weather,
custom city search/save and radar metadata loaded in the browser; a public radar
tile returned HTTP 200 image/png. This does not certify every external feed.

42
PHASE-1.md Normal file
View File

@ -0,0 +1,42 @@
# Phase 1 — public release candidate
This community edition extends Bilawal Sidhu's MIT-licensed God's Eye View.
Release status: community preview on ModDayJob/gods-eye-view, branch codex/phase-1.
Based on upstream ac927de; integration with newer upstream changes is pending.
## Included
- Free-only launcher, OpenStreetMap and keyless place search.
- Optional AISStream, NASA FIRMS and TomTom connections; faster ship refresh.
- Source-health indicators, stale/fallback labels and downloadable diagnostics.
- Expanded Caltrans and Washington cameras, region browsing and HLS video.
- City Pulse with Boston transit, place search and 50 saved favorite cities.
- Global GDACS hazard reports, category filters and severity symbols.
- Worldwide weather, three-day forecasts and saved Fahrenheit/Celsius units.
- RainViewer precipitation radar with recent-frame selection and opacity.
- Dependency updates, public setup instructions and automated test workflow.
## Validation and limitations
Earlier local checks passed 2,611 unit/allocation tests; the latest weather change
passed eight focused checks and a production build. A prior browser regression
run passed 87 checks and failed four grounded-aircraft/model checks. Those remain
open. See FREE-START.md for specific live checks; historical counts are not a
promise of current coverage. GitHub Actions has not run yet.
Transit is Boston-only. Radar is reflectivity, not Doppler velocity. Satellites
are predicted from orbital data. Camera availability varies. Third-party data,
maps, video and models remain subject to their source terms; the code's MIT
license does not grant blanket redistribution rights for provider content.
## Publication route
Fork the original repository under the chosen GitHub account. Clone that fork
to a separate folder to preserve upstream history, then apply this edition's
changes on a codex/phase-1 branch. Compare against upstream before committing;
do not replace upstream changes blindly. Exclude private configuration, caches,
generated builds and logs. Review the exact staged files for secrets and review
bundled data/model attribution before pushing. Do not upload this entire working
folder as a ZIP with local files included.
Open a draft pull request describing Phase 1 and its known limitations.
Publish a Phase 1 release/tag after review and passing checks. Users download
the source and run npm ci followed by npm start; each supplies their own optional
keys. A shared hosted service needs a separate deployment and quota design.

54
PHASE-2.md Normal file
View File

@ -0,0 +1,54 @@
# Phase 2 — upstream integration and Situation Desk
## Creator shout-outs
God's Eye View was created by Bilawal Sidhu and is maintained with Sameh Khamis
at Halfpixel. This community edition exists because of their open-source work.
The original license, data attribution and author metadata remain intact.
ModDayJob's additions were developed with AI assistance.
## Upstream integration
Integrated upstream v0.1.1 through 759652207fd1279ece97f0f19af566feb9a82146.
Retains the newer keyless Esri/terrain startup, ion map-loading path, first-run
experience, provider settings, setup doctor, Pinokio files, Overpass recovery
and other fixes. Keeps Phase 1 camera, weather, favorites and health features.
npm start continues to block metered Google/OpenAI services and bind localhost.
## Situation Desk
Open Situation at the top of the globe.
- Up to 60 source-linked headlines, refreshing every five minutes while open.
- Eight regional selections; conflict/diplomacy, humanitarian, disaster and general topics.
- Six-, 24- and 48-hour windows; headline text filtering and duplicate-title removal.
- Saved region watchlist and up to 100 saved stories, stored in this browser.
- Deterministic briefing from the loaded sample, publisher counts, keywords and export.
- Private scenario notes and text export; notes are hypotheses, not predictions.
- Worldwide country-mention markers. Country centers are context, not event locations.
- Explicit stale/error states; bounded requests, cache and coalescing.
- Prominent original-creator credit in the app and README.
## Conflictly comparison
Public and signed-in dashboards were inspected. Conflictly inspired the feed,
filtering and briefing workflow; no proprietary code, branding, assets or paid
feed content were copied. No affiliation is implied. Its paid daily briefings
were not accessed. Our briefings summarize openly retrieved headline metadata.
This edition does not reproduce community voting, a proprietary tension index,
prediction probabilities, market feeds, mobile push or paid historical archives.
Those need separate data, methodology or service work; no synthetic scores are
presented as measured facts.
## Data interpretation
Google News RSS supplies publisher headlines/links fetched at runtime. Articles
remain on publisher sites and retain their own terms. A report is not independent
verification. Keyword frequency and publisher counts are descriptive statistics,
not confidence, escalation or incident counts. Regional search and country-name
matching are incomplete. Existing provider terms and coverage limits still apply.
## Validation and preview status
- Clean dependency installation and production build passed.
- Automated suites: 2,721 checks (one skipped), allocation check and 13 focused checks; zero failures.
- Dependency audit reported zero known vulnerabilities at verification time.
- Live news endpoint returned 60 headlines; browser feed, saved stories and briefing checked.
- Full browser flight regression: 96 passed, 12 failed. Eight failures involved
incomplete render-frame windows, two involved tracked ground/zoom behavior,
and two recorded terrain-service HTTP 502 responses. These remain unresolved;
passing unit tests do not establish graphics or provider reliability.
- Phase 2 is a preview, not a claim that every external feed is available.

View File

@ -1,3 +1,43 @@
# Phase 2 — community situation desk
Built on [Bilawal Sidhu's God's Eye View](https://github.com/bilawalsidhu/gods-eye-view).
Created by **Bilawal Sidhu**, maintained with **Sameh Khamis** at **Halfpixel**.
Thank you to the original creators and contributors for making this possible.
[Bilawal](https://github.com/bilawalsidhu) · [Sameh](https://github.com/samehkhamis) · [Halfpixel](https://halfpixel.ai)
Community extensions by **ModDayJob**, developed with AI assistance.
The original MIT license and attribution are preserved.
## Start the community edition
1. Install Node.js **24.14+ within 24.x** (recommended) or **26.x**.
2. Download this repository using **Code → Download ZIP**, extract it, and open a terminal in the extracted folder.
3. Run:
```sh
npm ci
npm start
```
The app opens at **http://localhost:4173**. Keep the terminal open; Ctrl+C stops it.
This start command enables free-only mode on Windows, macOS and Linux.
No API keys are needed to explore the free map, weather, radar and public feeds.
Optional ship, fire and traffic keys go in your own ignored `.env` file;
copy `.env.example` first. Never publish that file.
Phase 2 integrates upstream v0.1.1 through commit 7596522 and adds a free Situation Desk: region/topic/time filters, headline maps, watchlists, saved stories, briefings, and scenario notes. See [Phase 2 notes](PHASE-2.md).
Phase 1 adds source-health reporting, expanded cameras and live video, city
favorites, Boston transit, global hazard reports, weather and precipitation radar.
Read [the setup and limitations](FREE-START.md) and [Phase 1 release notes](PHASE-1.md).
This is a local application with a server, not a standalone GitHub Pages site.
Public data providers have their own usage limits and coverage. Free-only mode
disables Google photorealistic tiles and OpenAI voice; the original documentation
below also describes those optional upstream features.
---
<div align="center">
# 🌐 God's Eye View

29
Start Gods Eye.command Normal file
View File

@ -0,0 +1,29 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
# Prefer a supported installed Node, then the runtime bundled with Codex.
node_bin="$(command -v node || true)"
supported_node() {
[[ -n "$1" ]] && "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit((major === 24 && minor >= 14) || major === 26 ? 0 : 1)' 2>/dev/null
}
if ! supported_node "$node_bin"; then
node_bin="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node"
fi
if ! supported_node "$node_bin"; then
echo 'Gods Eye needs Node 24.14+ (version 24) or Node 26. Install it, then reopen this launcher.'
read -r -p 'Press Enter to close.'
exit 1
fi
if [[ ! -f node_modules/vite/bin/vite.js ]]; then
echo 'Dependencies are missing. Run npm ci with a supported Node version first.'
read -r -p 'Press Enter to close.'
exit 1
fi
# Block metered services, while allowing optional free-account keys in .env.
export GEV_FREE_ONLY=1 GOOGLE_MAPS_API_KEY='' OPENAI_API_KEY=''
export OPENSKY_AUTH_MODE=anon HOST=localhost PORT=4173
echo 'Gods Eye — free services mode: http://localhost:4173'
echo 'Keep this window open. Press Control-C to stop.'
exec "$node_bin" node_modules/vite/bin/vite.js --host localhost --port 4173 --strictPort --open

View File

@ -2611,3 +2611,16 @@ 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.
## Phase 1 community branch
See FREE-START.md and PHASE-1.md for the free-only launch path, source-health
diagnostics, expanded cameras, city favorites, Boston transit, GDACS, Open-Meteo
and RainViewer. Run npm start for localhost-only free mode. All credentials remain
user-supplied. This branch starts from ac927de and requires reconciliation with
newer upstream changes before merging; it does not remove those upstream changes.
## Phase 2
Integrated upstream through 7596522. Situation Desk and original-creator credits
are initialized from main.js. The /api/situation-news proxy serves bounded,
cached headline metadata; source status, stale flags and region semantics are
documented in PHASE-2.md. Closing the panel stops its polling and removes markers.

1636
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,19 +40,22 @@
"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",
"start": "node scripts/start-free.mjs",
"test:phase1": "node --test server/*.test.mjs src/liveViewPreferences.test.mjs"
},
"dependencies": {
"@mapbox/vector-tile": "^3.0.0",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"hls.js": "^1.7.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
"satellite.js": "^6.0.2"
},
"devDependencies": {
"puppeteer": "^24.37.5",
"sharp": "^0.34.5",
"puppeteer": "^25.10.0",
"sharp": "^0.35.4",
"vite": "^6.0.0",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"

View File

@ -0,0 +1,42 @@
// Read-only source smoke check. Never prints or submits API credentials.
const checks = [
['Free configuration', '/api/free-providers'],
['Place search', '/api/free-geocode?q=Reykjavik'],
['Flights', '/api/opensky'],
['Military flights', '/api/adsblol/mil'],
['Satellites', '/api/celestrak/stations'],
['Space missions', '/api/launches'],
['Camera directory', '/api/cctv/sources'],
['Traffic configuration', '/api/tomtom/status'],
['Fire configuration', '/api/firms/status'],
['Ships', '/api/ais-live'],
['Weather', '/api/weather-effects?latitude=30.2672&longitude=-97.7431'],
];
const results = [];
for (let i = 0; i < checks.length; i += 3) {
await Promise.all(checks.slice(i, i + 3).map(async ([name, path]) => {
const start = Date.now();
try {
const response = await fetch(`http://localhost:4173${path}`, { signal: AbortSignal.timeout(30000) });
const text = await response.text();
const body = name === 'Satellites' && !text.trim().startsWith('{')
? { results: text.trim().split('\n').filter(line => line.startsWith('1 ')) }
: JSON.parse(text);
const rows = body.states || body.ac || body.results || body.sources || body.vessels;
const result = { name, http: response.status, seconds: ((Date.now() - start) / 1000).toFixed(1),
...(Array.isArray(rows) ? { count: rows.length } : {}),
...(body.status ? { status: body.status } : {}),
...(typeof body.hasKey === 'boolean' ? { hasKey: body.hasKey } : {}),
...(body.place ? { place: body.place.label } : {}),
...(path === '/api/free-providers' ? { configuration: body } : {}),
};
results.push(result);
console.log(JSON.stringify(result));
} catch (error) {
const result = { name, error: error.name === 'TimeoutError' ? 'timeout' : 'request failed' };
results.push(result);
console.log(JSON.stringify(result));
}
}));
}
if (results.some(r => r.error || (r.http >= 400 && r.name !== 'Ships'))) process.exitCode = 1;

21
scripts/start-free.mjs Normal file
View File

@ -0,0 +1,21 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const root=fileURLToPath(new URL('../',import.meta.url));
const vite=fileURLToPath(new URL('../node_modules/vite/bin/vite.js',import.meta.url));
const [major,minor]=process.versions.node.split('.').map(Number);
if(!((major===24&&minor>=14)||major===26)){
console.error('Install Node 24.14+ (24.x) or Node 26 before starting Gods Eye.');
process.exit(1);
}
if(!existsSync(vite)){
console.error('Run npm ci first to install dependencies.');
process.exit(1);
}
const child=spawn(process.execPath,[vite,'--host','localhost','--port','4173','--strictPort','--open'],{
cwd:root,stdio:'inherit',
env:{...process.env,GEV_FREE_ONLY:'1',GOOGLE_MAPS_API_KEY:'',OPENAI_API_KEY:'',HOST:'localhost',PORT:'4173'}
});
child.on('error',()=>{console.error('Unable to start the local server.');process.exitCode=1;});
child.on('exit',code=>{process.exitCode=code??1;});
for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));

View File

@ -0,0 +1,42 @@
import { lonLatToTile } from '../src/data/tomtomTiles.js';
import { decodeFlowTile } from '../src/data/flowTiles.js';
const root = 'http://localhost:4173';
const { x, y } = lonLatToTile(-97.7431, 30.2672, 12);
const checks = [
['Traffic tile', `/api/tomtom/flow/12/${x}/${y}.pbf`, 'tile'],
['Fire detections', '/api/firms', 'fires'],
['Ships', '/api/ais-live?maxRows=5000', 'ships'],
['Camera health', '/api/cctv/health', 'cameras'],
['Mapped installations', '/api/military-installations?south=30.15&west=-97.85&north=30.35&east=-97.65', 'mapped'],
];
await Promise.all(checks.map(async ([name, path, kind]) => {
const start = Date.now();
try {
const r = await fetch(root + path, { signal: AbortSignal.timeout(90000) });
const bytes = new Uint8Array(await r.arrayBuffer());
const result = { name, http: r.status, seconds: Math.round((Date.now() - start) / 1000) };
if (kind === 'tile' && r.ok) {
result.bytes = bytes.length;
result.contentType = r.headers.get('content-type');
result.cache = r.headers.get('x-tomtom-cache');
result.decodedRoadSegments = decodeFlowTile(bytes, 12, x, y).length;
} else {
const body = JSON.parse(new TextDecoder().decode(bytes));
if (kind === 'fires') Object.assign(result, { count: body.fires?.length, stale: body.stale, sourceCount: body.sources?.length });
if (kind === 'ships') Object.assign(result, { count: body.rows?.length, status: body.status, lastMessageAt: body.lastMessageAt });
if (kind === 'mapped') Object.assign(result, { count: body.elements?.length, status: body.status });
if (kind === 'cameras') {
const cameras = Array.isArray(body.cameras) ? body.cameras : Object.values(body.cameras || {});
result.observed = cameras.length;
result.statusCounts = cameras.reduce((counts, camera) => {
const status = camera.status || 'unknown'; counts[status] = (counts[status] || 0) + 1; return counts;
}, {});
}
}
console.log(JSON.stringify(result));
if (!r.ok) process.exitCode = 1;
} catch (e) {
console.log(JSON.stringify({ name, error: e.name === 'TimeoutError' ? 'timeout' : 'request failed' }));
process.exitCode = 1;
}
}));

62
server/cameraExpansion.js Normal file
View File

@ -0,0 +1,62 @@
export function publicVideoUrl(value) {
try {
const url = new URL(value);
return url.protocol === 'https:' && url.hostname === 'wzmedia.dot.ca.gov'
&& !url.username && !url.password && url.pathname.endsWith('.m3u8') ? url.href : '';
} catch { return ''; }
}
// Round-robin keeps a large early provider from excluding every later city.
export function balanceCameraCities(sources, limit, groupBy = source => source.city || source.provider || 'Other') {
const groups = new Map();
for (const source of sources) {
const key = groupBy(source);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(source);
}
const result = [];
for (let i = 0; result.length < limit; i++) {
let added = false;
for (const group of groups.values()) {
if (group[i] && result.length < limit) { result.push(group[i]); added = true; }
}
if (!added) break;
}
return result;
}
const CITIES = [['Seattle', 47.6062, -122.3321], ['Tacoma', 47.2529, -122.4443],
['Olympia', 47.0379, -122.9007], ['Spokane', 47.6588, -117.4260],
['Vancouver WA', 45.628, -122.6739], ['Bellingham', 48.7519, -122.4787],
['Everett', 47.979, -122.202], ['Yakima', 46.6021, -120.5059],
['Tri-Cities', 46.2396, -119.1006], ['Wenatchee', 47.4235, -120.3103]];
export function normalizeWashingtonCameras(payload) {
return (payload?.features || []).flatMap(({ attributes: a = {}, geometry: g = {} }) => {
const lat = Number(g.y), lon = Number(g.x);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < 45 || lat > 50 || lon < -125 || lon > -116 || !a.OBJECTID) return [];
let image;
try {
image = new URL(a.ImageURL);
if (image.protocol !== 'https:' || image.username || image.password
|| !['images.wsdot.wa.gov', 'images.wsdot.com', 'www.wsdot.com', 'www.tripcheck.com'].includes(image.hostname)) return [];
} catch { return []; }
const nearest = [...CITIES].sort((a, b) => ((lat-a[1])**2 + ((lon-a[2])*0.68)**2) - ((lat-b[1])**2 + ((lon-b[2])*0.68)**2))[0];
return [{ id: `wsdot-${a.OBJECTID}`, name: String(a.CameraTitle || a.OBJECTID),
city: `${nearest[0]} area`, cityId: nearest[0].toLowerCase().replace(/\s/g, '-'),
provider: 'Washington State Department of Transportation', lat, lon,
headingDeg: ({ N: 0, E: 90, S: 180, W: 270 })[a.CompassDirection] ?? 0,
headingConfidence: 'low', pitchDeg: -18, fovDeg: 44, rangeM: 145,
mountHeightM: 8, groundElevationM: 0, feedType: 'image', url: image.href,
snapshotUrl: image.href, sourceKind: 'wsdot-open-data', license: 'WSDOT public traffic cameras; provider snapshots' }];
});
}
export async function loadWashingtonCameras() {
if (process.env.CCTV_WSDOT_ENABLED === '0') return [];
try {
const url = 'https://data.wsdot.wa.gov/arcgis/rest/services/TravelInformation/TravelInfoCamerasWeather/FeatureServer/0/query?where=1%3D1&outFields=OBJECTID,CameraTitle,ImageURL,CompassDirection&outSR=4326&f=json&resultRecordCount=2000';
const r = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return balanceCameraCities(normalizeWashingtonCameras(await r.json()), 300);
} catch { console.warn('[CCTV] Washington camera catalog unavailable'); return []; }
}

View File

@ -0,0 +1,21 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { publicVideoUrl, balanceCameraCities, normalizeWashingtonCameras } from './cameraExpansion.js';
test('only official HTTPS HLS links reach the browser player', () => {
assert.equal(publicVideoUrl('https://wzmedia.dot.ca.gov/D7/cam/playlist.m3u8'), 'https://wzmedia.dot.ca.gov/D7/cam/playlist.m3u8');
for (const url of ['https://evil.test/a.m3u8', 'http://wzmedia.dot.ca.gov/a.m3u8', 'https://key@wzmedia.dot.ca.gov/a.m3u8', 'https://wzmedia.dot.ca.gov.evil.test/a.m3u8']) assert.equal(publicVideoUrl(url), '');
});
test('city balancing does not let a large provider crowd out later cities', () => {
const input = [{city:'A',id:1},{city:'A',id:2},{city:'A',id:3},{city:'B',id:4},{city:'C',id:5}];
assert.deepEqual(balanceCameraCities(input, 3).map(x=>x.id), [1,4,5]);
assert.equal(balanceCameraCities(input, 20).length, 5);
assert.deepEqual(balanceCameraCities([], 20), []);
});
test('Washington rejects invalid coordinates and untrusted image origins', () => {
const feature = { attributes: { OBJECTID: 1, CameraTitle: 'I-5', ImageURL: 'https://images.wsdot.wa.gov/a.jpg' }, geometry: {x:-122.33,y:47.60} };
const [camera] = normalizeWashingtonCameras({features:[feature]});
assert.equal(camera.city, 'Seattle area');
assert.equal(camera.feedType, 'image');
assert.equal(normalizeWashingtonCameras({features:[{...feature,geometry:{x:0,y:0}}]}).length,0);
assert.equal(normalizeWashingtonCameras({features:[{...feature,attributes:{...feature.attributes,ImageURL:'http://localhost/private'}}]}).length,0);
});

65
server/freeServices.js Normal file
View File

@ -0,0 +1,65 @@
import { normalizePhotonPlace } from '../src/freeGeocode.js';
export function freeProviderConfig(env = process.env) {
const hasKey = key => Boolean(String(env[key] || '').trim());
return {
freeOnly: env.GEV_FREE_ONLY === '1',
ships: hasKey('AISSTREAM_API_KEY'),
fires: hasKey('FIRMS_MAP_KEY'),
traffic: hasKey('TOMTOM_API_KEY'),
imagery: hasKey('CESIUM_ION_TOKEN'),
voice: env.GEV_FREE_ONLY !== '1' && hasKey('OPENAI_API_KEY'),
google: env.GEV_FREE_ONLY !== '1' && hasKey('GOOGLE_MAPS_API_KEY'),
};
}
/** One bounded, cached search on Enter; no background autocomplete traffic. */
export function freeServicesPlugin({ fetchJson } = {}) {
const cache = new Map();
const inFlight = new Map();
let lastRequest = 0;
let queue = Promise.resolve();
const send = (res, status, payload) => {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(payload));
};
const install = middlewares => {
middlewares.use('/api/free-providers', (req, res) => {
if (req.method !== 'GET') return send(res, 405, { error: 'Method not allowed' });
send(res, 200, freeProviderConfig());
});
middlewares.use('/api/free-geocode', async (req, res) => {
if (req.method !== 'GET') return send(res, 405, { error: 'Method not allowed' });
const q = new URL(req.url || '', 'http://localhost').searchParams.get('q')?.trim();
if (!q || q.length > 200) return send(res, 400, { error: 'Enter a place name (1200 characters).' });
const key = q.toLowerCase();
const cached = cache.get(key);
if (cached && Date.now() - cached.at < 24 * 60 * 60_000) return send(res, 200, cached.payload);
if (!inFlight.has(key)) {
if (inFlight.size >= 4) return send(res, 429, { error: 'Search busy; retry shortly.' });
const task = queue.then(async () => {
const delay = Math.max(0, 1100 - (Date.now() - lastRequest));
if (delay) await new Promise(resolve => setTimeout(resolve, delay));
lastRequest = Date.now();
const url = new URL(process.env.GEV_PHOTON_URL || 'https://photon.komoot.io/api/');
url.search = new URLSearchParams({ q, limit: '1', lang: 'en' }).toString();
const data = await fetchJson(url.href, {
timeoutMs: 9000, maxBytes: 128 * 1024,
headers: { 'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)' },
});
if (!Array.isArray(data?.features)) throw new Error('Invalid geocoder response');
const payload = { place: normalizePhotonPlace(data.features[0]) };
cache.set(key, { payload, at: Date.now() });
while (cache.size > 200) cache.delete(cache.keys().next().value);
return payload;
});
queue = task.catch(() => {});
inFlight.set(key, task);
task.finally(() => inFlight.delete(key)).catch(() => {});
}
try { send(res, 200, await inFlight.get(key)); }
catch { send(res, 503, { error: 'Place search is temporarily unavailable.' }); }
});
};
return { name: 'free-services', configureServer: s => install(s.middlewares), configurePreviewServer: s => install(s.middlewares) };
}

76
server/liveViews.js Normal file
View File

@ -0,0 +1,76 @@
import { WORLD_PLACES } from '../src/worldPlaces.js';
export const validPoint = (lat,lon) => Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat)<=90 && Math.abs(lon)<=180;
export function normalizeDisasters(data) {
if (!Array.isArray(data?.features)) throw new Error('Invalid disaster feed');
return data.features.flatMap(f => {
let c = f.geometry?.coordinates; if (Array.isArray(c?.[0])) c=c[0];
const p=f.properties||{};
if (f.geometry?.type!=='Point' || !validPoint(c?.[1],c?.[0])) return [];
return [{id:`${p.eventtype}-${p.eventid}`,lat:c[1],lon:c[0],name:String(p.title||'Disaster report'),
type:p.eventtype,level:p.alertlevel,time:p.todate,description:String(p.description||'').slice(0,1500)}];
}).filter(x=>['TC','FL','DR','WF','VO'].includes(x.type))
.sort((a,b)=>Number(a.type==='WF')-Number(b.type==='WF'))
.slice(0,300);
}
export function normalizeRadar(raw) {
if(raw?.host!=='https://tilecache.rainviewer.com'||!Array.isArray(raw?.radar?.past))throw new Error('Invalid radar feed');
const frames=raw.radar.past.filter(f=>Number.isInteger(f.time)&&f.time>0&&/^\/v2\/radar\/[a-zA-Z0-9_-]{1,64}$/.test(f.path))
.sort((a,b)=>a.time-b.time).slice(-13).map(({time,path})=>({time,path}));
if(!frames.length)throw new Error('Empty radar feed');
return {source:'RainViewer',host:raw.host,frames};
}
export function normalizeTransit(data) {
if (!Array.isArray(data?.data)) throw new Error('Invalid transit feed');
return data.data.flatMap(v => {
const a=v.attributes||{};
if (!validPoint(a.latitude,a.longitude)) return [];
return [{id:v.id,lat:a.latitude,lon:a.longitude,name:`${v.relationships?.route?.data?.id||'Transit'} · ${a.label||v.id}`,
time:a.updated_at,status:a.current_status}];
}).slice(0,200);
}
export function liveViewsPlugin({fetchJson}) {
const cache=new Map(), pending=new Map();
async function obtain(key,ttl,job) {
const old=cache.get(key);
if(old && Date.now()-old.receivedAt<ttl) return {...old,stale:false};
if(pending.has(key)) return pending.get(key);
if(pending.size>=4) throw new Error('Busy');
const task=(async()=>{
try {
const result={...await job(),receivedAt:Date.now()};cache.set(key,result);
while(cache.size>80) cache.delete(cache.keys().next().value);
return {...result,stale:false};
} catch(e) {
if(old && Date.now()-old.receivedAt<3600000) return {...old,stale:true};
throw e;
} finally {pending.delete(key);}
})();
pending.set(key,task); return task;
}
const install=middlewares=>middlewares.use('/api/live-views',async(req,res)=>{
const send=(status,body)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(body));};
if(req.method!=='GET') return send(405,{error:'GET only'});
const u=new URL(req.url||'/','http://localhost');
try {
if(u.pathname==='/radar') return send(200,await obtain('radar',300000,async()=>normalizeRadar(await fetchJson('https://api.rainviewer.com/public/weather-maps.json',{timeoutMs:15000,maxBytes:100000}))));
if(u.pathname==='/disasters') return send(200,await obtain('disasters',360000,async()=>({source:'GDACS',kind:'Published disaster reports · up to 300; cyclone/flood/drought/volcano reports prioritized over wildfire overflow',items:normalizeDisasters(await fetchJson('https://www.gdacs.org/contentdata/xml/gdacsAPP_Home.geojson',{timeoutMs:15000,maxBytes:2000000}))})));
if(u.pathname==='/transit') return send(200,await obtain('transit',30000,async()=>({source:'MBTA',kind:'Reported vehicle positions · Boston only · up to 200 vehicles',items:normalizeTransit(await fetchJson('https://api-v3.mbta.com/vehicles?page%5Blimit%5D=200',{timeoutMs:15000,maxBytes:2000000}))})));
if(u.pathname==='/weather') {
const world=u.searchParams.get('world')==='1';
const lat=Number(u.searchParams.get('lat')),lon=Number(u.searchParams.get('lon'));
if(!world && (!u.searchParams.has('lat')||!u.searchParams.has('lon')||!validPoint(lat,lon))) return send(400,{error:'Valid coordinates required'});
const points=world?WORLD_PLACES:[{name:'Selected location',lat:Math.round(lat*100)/100,lon:Math.round(lon*100)/100}];
const key=world?'weather-world':`weather-${points[0].lat}-${points[0].lon}`;
return send(200,await obtain(key,600000,async()=>{
const q=new URLSearchParams({latitude:points.map(p=>p.lat).join(','),longitude:points.map(p=>p.lon).join(','),current:'temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m',daily:'temperature_2m_max,temperature_2m_min,precipitation_probability_max',forecast_days:'3',timezone:'GMT'});
const raw=await fetchJson(`https://api.open-meteo.com/v1/forecast?${q}`,{timeoutMs:15000,maxBytes:1000000});
const rows=Array.isArray(raw)?raw:[raw];
if(rows.length!==points.length||rows.some(r=>!r.current)) throw new Error('Incomplete weather');
return {source:'Open-Meteo · CC BY 4.0',kind:'Model-based current conditions and forecasts · not station observations',items:rows.map((r,i)=>({...points[i],current:r.current,daily:r.daily,units:r.current_units}))};
}));
}
send(404,{error:'Unknown feed'});
}catch{send(503,{error:'Provider unavailable. Retry later; no empty success substituted.'});}
});
return {name:'live-views',configureServer:s=>{install(s.middlewares);},configurePreviewServer:s=>{install(s.middlewares);}};
}

50
server/liveViews.test.mjs Normal file
View File

@ -0,0 +1,50 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {normalizeDisasters,normalizeTransit,liveViewsPlugin} from './liveViews.js';
test('disaster parser accepts published nested points and rejects invalid coordinates',()=>{
const feature={geometry:{type:'Point',coordinates:[[10,20]]},properties:{eventid:1,eventtype:'TC',title:'Storm'}};
assert.equal(normalizeDisasters({features:[feature]})[0].lat,20);
assert.equal(normalizeDisasters({features:[{...feature,geometry:{type:'Point',coordinates:[10,100]}}]}).length,0);
assert.throws(()=>normalizeDisasters({}));
});
test('transit retains observation timestamp and rejects missing positions',()=>{
const data={data:[{id:'1',attributes:{latitude:42,longitude:-71,updated_at:'2026-09-06T00:00:00Z'}},{id:'2',attributes:{}}]};
const out=normalizeTransit(data);assert.equal(out.length,1);assert.equal(out[0].time,'2026-09-06T00:00:00Z');
});
function harness(fetchJson){let handler;liveViewsPlugin({fetchJson}).configureServer({middlewares:{use:(_,fn)=>handler=fn}});return(url,method='GET')=>new Promise(resolve=>{let status;handler({url,method},{writeHead:s=>status=s,end:s=>resolve({status,body:JSON.parse(s)})});});}
test('requests are validated, coalesced and cached without exposing raw failures',async()=>{
let calls=0;const request=harness(async()=>{calls++;await new Promise(r=>setTimeout(r,5));return{data:[]};});
assert.equal((await request('/weather?lat=999&lon=0')).status,400);
assert.equal((await request('/weather')).status,400);
assert.equal((await request('/transit','POST')).status,405);
const results=await Promise.all([request('/transit'),request('/transit')]);
assert.equal(calls,1);assert.equal(results[0].status,200);assert.equal(results[0].body.stale,false);
await request('/transit');assert.equal(calls,1);
const bad=harness(async()=>{throw new Error('secret upstream key');});
const failure=await bad('/transit');assert.equal(failure.status,503);assert.ok(!JSON.stringify(failure).includes('secret'));
});
test('Vite setup returns no accidental post-install middleware hook',()=>{
const connect=()=>{};
const plugin=liveViewsPlugin({fetchJson:async()=>({})});
assert.equal(plugin.configureServer({middlewares:{use:()=>connect}}),undefined);
assert.equal(plugin.configurePreviewServer({middlewares:{use:()=>connect}}),undefined);
});
test('expired cache survives provider outage only with an explicit stale flag',async()=>{
const realNow=Date.now;let now=100000,fail=false;
Date.now=()=>now;
try {
const request=harness(async()=>{if(fail)throw new Error('offline');return{data:[]};});
const first=await request('/transit');assert.equal(first.body.stale,false);
now+=31000;fail=true;
const old=await request('/transit');assert.equal(old.status,200);assert.equal(old.body.stale,true);assert.equal(old.body.receivedAt,100000);
now+=3600001;assert.equal((await request('/transit')).status,503);
}finally{Date.now=realNow;}
});
test('radar accepts only the documented host and frame paths',async()=>{
const {normalizeRadar}=await import('./liveViews.js');
const raw={host:'https://tilecache.rainviewer.com',radar:{past:[{time:100,path:'/v2/radar/100'}]}};
assert.equal(normalizeRadar(raw).frames.length,1);
assert.throws(()=>normalizeRadar({...raw,host:'https://example.com'}));
assert.throws(()=>normalizeRadar({...raw,radar:{past:[{time:100,path:'/v2/radar/../200'}]}}));
const request=harness(async()=>raw);assert.equal((await request('/radar')).status,200);
});

31
server/situationNews.js Normal file
View File

@ -0,0 +1,31 @@
import {REGIONS,TOPICS,cleanArticles} from '../src/situationModel.js';
export function situationNewsPlugin({fetchText,parseArticles}){
const cache=new Map(),pending=new Map();
async function obtain(region,topic,hours){
const key=region.id+':'+topic+':'+hours,old=cache.get(key);
if(old&&Date.now()-old.receivedAt<300000)return {...old,stale:false};
if(pending.has(key))return pending.get(key);
if(pending.size>=4)throw new Error('Busy');
const task=(async()=>{
try{
const query=[region.query,TOPICS[topic].query,'when:'+hours+'h'].filter(Boolean).join(' ');
const params=new URLSearchParams({q:query,hl:'en-US',gl:'US',ceid:'US:en'});
const xml=await fetchText('https://news.google.com/rss/search?'+params,{timeoutMs:12000,maxBytes:1000000});
// A valid empty channel is distinct from an upstream error page.
if(!/<rss[\s>]/i.test(xml)||!/<channel[\s>]/i.test(xml))throw new Error('Invalid feed');
const result={region:region.id,topic,hours,receivedAt:Date.now(),source:'Google News RSS · publisher headlines',items:cleanArticles(parseArticles(xml,100),Date.now(),hours)};
cache.set(key,result);while(cache.size>48)cache.delete(cache.keys().next().value);
return {...result,stale:false};
}catch(e){if(old&&Date.now()-old.receivedAt<3600000)return {...old,stale:true};throw e;}
finally{pending.delete(key);}
})();pending.set(key,task);return task;
}
const install=m=>m.use('/api/situation-news',async(req,res)=>{
const send=(code,body)=>{res.writeHead(code,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(body));};
if(req.method!=='GET')return send(405,{error:'GET only'});
const u=new URL(req.url||'/','http://localhost'),region=REGIONS.find(r=>r.id===(u.searchParams.get('region')||'world')),topic=u.searchParams.get('topic')||'conflict',hours=Number(u.searchParams.get('hours')||24);
if(!region||!Object.hasOwn(TOPICS,topic)||![6,24,48].includes(hours))return send(400,{error:'Choose a supported region, topic and time window.'});
try{send(200,await obtain(region,topic,hours));}catch{send(503,{error:'News provider unavailable; retry later.'});}
});
return {name:'situation-news',configureServer:s=>{install(s.middlewares);},configurePreviewServer:s=>{install(s.middlewares);}};
}

View File

@ -0,0 +1,9 @@
import {test} from 'node:test';import assert from 'node:assert/strict';
import {situationNewsPlugin} from './situationNews.js';
function harness(fetchText){let handler;situationNewsPlugin({fetchText,parseArticles:()=>[]}).configureServer({middlewares:{use:(_,f)=>handler=f}});return url=>new Promise(resolve=>{let code;handler({method:'GET',url},{writeHead:value=>{code=value;},end:s=>resolve({code,body:JSON.parse(s)})});});}
test('news validates choices, coalesces requests and rejects upstream error pages',async()=>{
let calls=0;const request=harness(async()=>{calls++;await new Promise(r=>setTimeout(r,5));return '<rss><channel></channel></rss>';});
assert.equal((await request('/?region=unknown')).code,400);
const out=await Promise.all([request('/'),request('/')]);assert.equal(calls,1);assert.equal(out[0].body.stale,false);
const bad=harness(async()=>'<html>error</html>');assert.equal((await bad('/')).code,503);
});

View File

@ -603,8 +603,12 @@ function ringAreaM2(ring) {
* 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 apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env?.GOOGLE_MAPS_API_KEY;
if (!apiKey || apiKey === 'your_google_maps_api_key_here') {
const { findFreePlace } = await import('../freeGeocode.js');
const place = await findFreePlace(query, { signal }).catch(() => null);
return place ? { ...place, viewport: normalizeGeocodeViewport(place.viewport) } : null;
}
const cacheKey = `${query.toLowerCase()}|${biasRect || ''}`;
const cached = cacheRead(geocodeCache, cacheKey);

112
src/cameraBrowser.js Normal file
View File

@ -0,0 +1,112 @@
// One selected stream at a time. Closed dialogs own no media requests.
const REGIONS = {1:'North Coast',2:'Redding / Northeast California',3:'Sacramento region',4:'San Francisco Bay Area',5:'Central Coast',6:'Fresno / Central Valley',7:'Los Angeles region',8:'San Bernardino / Riverside',9:'Eastern Sierra',10:'Stockton region',11:'San Diego region',12:'Orange County'};
const cityGroup = source => source.provider === 'Caltrans'
? REGIONS[Number(String(source.cityId).replace('ca-d',''))] || source.city : source.city;
export function initCameraBrowser() {
const button = document.createElement('button');
button.textContent = '▣';
button.title = 'Browse camera cities and live video';
button.setAttribute('aria-label', button.title);
button.id = 'camera-browser-button';
document.getElementById('top-center-actions').append(button);
const dialog = document.createElement('dialog');
dialog.id = 'camera-browser-dialog';
dialog.style.cssText = 'margin:auto;width:min(820px,92vw);max-height:85vh;overflow:auto;background:#0b1922;color:#e1edf2;border:1px solid #547783;border-radius:12px;padding:22px';
dialog.setAttribute('aria-label', 'Camera cities and live video');
const heading = document.createElement('h2'); heading.textContent = 'Camera cities & live video';
const close = document.createElement('button'); close.textContent = 'Close';
close.onclick = () => dialog.close();
const info = document.createElement('p');
info.textContent = 'Public traffic cameras. Choose a city and camera. Live video starts only when you press Play; snapshots are periodically refreshed provider images.';
const city = document.createElement('select'); city.setAttribute('aria-label', 'Camera city');
const camera = document.createElement('select'); camera.setAttribute('aria-label', 'Camera location');
city.style.cssText = camera.style.cssText = 'max-width:100%;margin:8px;padding:8px';
const play = document.createElement('button'); play.textContent = 'Play live video';
const stop = document.createElement('button'); stop.textContent = 'Return to snapshot';
const status = document.createElement('p'); status.setAttribute('role', 'status');
const video = document.createElement('video'); video.controls = true; video.muted = true; video.playsInline = true;
const preview = document.createElement('img'); preview.alt = 'Selected public traffic-camera snapshot';
video.style.cssText = preview.style.cssText = 'width:100%;max-height:52vh;object-fit:contain;background:#000';
video.hidden = true;
const credit = document.createElement('p');
dialog.append(heading, close, info, city, camera, play, stop, status, preview, video, credit);
document.body.append(dialog);
let sources = [], hls = null, timer = null, generation = 0, controller = null;
function release() {
generation++;
clearInterval(timer); timer = null;
hls?.destroy(); hls = null;
video.pause(); video.removeAttribute('src'); video.load(); video.hidden = true;
preview.removeAttribute('src'); preview.hidden = true;
}
function current() { return sources.find(s => s.id === camera.value); }
function snapshot() {
release();
const source = current();
play.disabled = !source?.liveVideoUrl;
stop.disabled = true;
if (!source) { status.textContent = 'No cameras available in this city.'; return; }
credit.textContent = `${source.provider} · ${source.license || 'Public provider feed'} · City areas are approximate.`;
status.textContent = 'SNAPSHOT · Loading provider image…';
preview.hidden = false;
const token = generation;
preview.onload = () => { if (generation === token) status.textContent = 'SNAPSHOT · Image received. Check the picture timestamp; the provider may return an unavailable-image placeholder.'; };
preview.onerror = () => { if (generation === token) status.textContent = 'SNAPSHOT · Provider image unavailable. Try another camera.'; };
const update = () => { if (dialog.open) preview.src = `/api/cctv/frame/${encodeURIComponent(source.id)}?t=${Date.now()}`; };
update(); timer = setInterval(update, 30000);
}
function chooseCity() {
camera.replaceChildren();
for (const source of sources.filter(s => cityGroup(s) === city.value)) {
const option = document.createElement('option'); option.value = source.id;
option.textContent = `${source.liveVideoUrl ? 'VIDEO · ' : ''}${source.name}`;
camera.append(option);
}
snapshot();
}
city.onchange = chooseCity; camera.onchange = snapshot; stop.onclick = snapshot;
play.onclick = async () => {
const source = current(); if (!source?.liveVideoUrl) return;
release(); const token = generation;
video.hidden = false; play.disabled = true; stop.disabled = false;
status.textContent = 'LIVE VIDEO · Connecting…';
video.onplaying = () => { if (generation === token) status.textContent = 'LIVE VIDEO · Playing provider stream; broadcast delay varies.'; };
const fail = () => { if (generation === token) { status.textContent = 'LIVE VIDEO · Stream unavailable. Return to snapshot or choose another camera.'; play.disabled = false; hls?.destroy(); hls = null; video.pause(); } };
video.onerror = fail;
try {
if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = source.liveVideoUrl;
else {
const { default: Hls } = await import('hls.js');
if (generation !== token || !dialog.open) return;
if (!Hls.isSupported()) { fail(); return; }
hls = new Hls({ maxBufferLength: 15, backBufferLength: 15 });
hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) fail(); });
hls.loadSource(source.liveVideoUrl); hls.attachMedia(video);
}
await video.play();
} catch { fail(); }
};
button.onclick = async () => {
if (dialog.open) return;
dialog.showModal(); status.textContent = 'Loading camera cities…';
play.disabled = true; stop.disabled = true;
controller = new AbortController();
const request = controller;
const timeout = setTimeout(() => request.abort(), 25000);
try {
const r = await fetch('/api/cctv/sources', { signal: request.signal });
if (!r.ok) throw new Error('Catalog unavailable');
const body = await r.json();
if (!dialog.open || request.signal.aborted) return;
sources = Array.isArray(body.sources) ? body.sources : [];
city.replaceChildren();
for (const name of [...new Set(sources.map(cityGroup))].sort()) {
const option = document.createElement('option'); option.value = name;
option.textContent = `${name} (${sources.filter(s => cityGroup(s) === name).length})`; city.append(option);
}
chooseCity();
} catch { if (dialog.open) status.textContent = 'Camera catalog unavailable. Close and reopen to retry.'; }
finally { clearTimeout(timeout); }
};
dialog.addEventListener('close', () => { controller?.abort(); release(); });
}

15
src/creatorCredits.js Normal file
View File

@ -0,0 +1,15 @@
export function initCreatorCredits(){
const button=document.createElement('button');button.textContent='Credits';button.id='creator-credits-button';button.title='Original creators and community contributors';
document.getElementById('top-center-actions')?.append(button);
const dialog=document.createElement('dialog');dialog.className='gev-credits';
const title=document.createElement('h2');title.textContent='Built on the work of the original creators';
const intro=document.createElement('p');intro.textContent="Gods Eye View was created by Bilawal Sidhu and is maintained with Sameh Khamis at Halfpixel. Their open-source work makes this community edition possible.";
const links=document.createElement('p');
for(const [name,url] of [['Bilawal Sidhu','https://github.com/bilawalsidhu'],['Sameh Khamis','https://github.com/samehkhamis'],['Original project','https://github.com/bilawalsidhu/gods-eye-view'],['Halfpixel','https://halfpixel.ai']]){
const a=document.createElement('a');a.textContent=name;a.href=url;a.target='_blank';a.rel='noopener noreferrer';links.append(a,document.createTextNode(' · '));
}
const edition=document.createElement('p');edition.textContent='Community extensions by ModDayJob, developed with AI assistance. MIT code attribution and individual data-source credits remain intact.';
const inspiration=document.createElement('p');inspiration.textContent='Conflictly inspired the situation-dashboard workflow. This is an independent implementation; no affiliation or endorsement is implied.';
const close=document.createElement('button');close.textContent='Close credits';close.onclick=()=>dialog.close();
dialog.append(title,intro,links,edition,inspiration,close);document.body.append(dialog);button.onclick=()=>dialog.showModal();
}

View File

@ -55,7 +55,9 @@ const _scratchFocusScreen = new Cesium.Cartesian2();
const DEFAULT_API_URL = '/api/ais-live';
const DEFAULT_RENDER_ROWS = 12000;
const DEFAULT_ACTIVE_LABELS = 900;
const REFRESH_MS = 60000;
// The local server already receives a live WebSocket stream; poll its cached
// snapshot every 10 seconds instead of adding a full minute of client delay.
const REFRESH_MS = 10000;
/** Bounded wait for the first accepted vessel position in one enabled session. */
export const AIS_FIRST_CONNECT_GRACE_MS = 30000;
const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';

View File

@ -120,6 +120,12 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (California): Caltrans — ' +
'<a href="https://cwwp2.dot.ca.gov/" target="_blank" rel="noopener">cwwp2.dot.ca.gov</a>',
},
{ key: 'live-views-gdacs', html: 'Hazard reports: <a href="https://www.gdacs.org/" target="_blank" rel="noopener">GDACS</a>.' },
{ key: 'live-views-mbta', html: 'Boston transit positions: <a href="https://api-v3.mbta.com/" target="_blank" rel="noopener">MBTA V3 API</a>.' },
{
key: 'wsdot-cctv',
html: 'Washington traffic cameras: <a href="https://data.wsdot.wa.gov/" target="_blank" rel="noopener">Washington State Department of Transportation</a> and credited camera owners.',
},
{
key: 'tfl-cctv',
html:

View File

@ -14,6 +14,7 @@ import {
resolveOverpassPreflight,
} from '../../vite.config.js';
test('preflight checks memory, in-flight, then disk before consuming limiter quota', async () => {
const key = 'normalized query';
const fresh = { id: 'memory', status: 200, cachedAt: 900 };

34
src/freeGeocode.js Normal file
View File

@ -0,0 +1,34 @@
/** Convert Photon/OpenStreetMap results into the app's existing place contract. */
export function normalizePhotonPlace(feature) {
const [lon, lat] = feature?.geometry?.coordinates || [];
if (feature?.geometry?.type !== 'Point' || !Number.isFinite(lat) || !Number.isFinite(lon)
|| Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
const p = feature.properties || {};
const label = [...new Set([p.name, p.city, p.state, p.country].filter(v => typeof v === 'string' && v))].join(', ');
if (!label) return null;
const kind = p.type || p.osm_value;
const types = kind === 'country' ? ['country']
: ['state', 'county'].includes(kind) ? ['administrative_area_level_1']
: ['city', 'town', 'village', 'district', 'locality'].includes(kind) ? ['locality']
: p.osm_key === 'highway' ? ['route'] : ['point_of_interest'];
let viewport = null;
const extent = p.extent;
if (Array.isArray(extent) && extent.length === 4 && extent.every(Number.isFinite)
&& Math.abs(extent[0]) <= 180 && Math.abs(extent[2]) <= 180
&& Math.abs(extent[1]) <= 90 && Math.abs(extent[3]) <= 90) {
viewport = {
southwest: { lat: Math.min(extent[1], extent[3]), lng: extent[0] },
northeast: { lat: Math.max(extent[1], extent[3]), lng: extent[2] },
};
}
return { lat, lon, label, primaryName: p.name || label, types, viewport, source: 'Photon · OpenStreetMap' };
}
export async function findFreePlace(query, { signal } = {}) {
const response = await fetch(`/api/free-geocode?q=${encodeURIComponent(query)}`, {
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(response.status === 429 ? 'Place search is busy; retry shortly.' : 'Place search is temporarily unavailable.');
const payload = await response.json();
return payload.place || null;
}

64
src/freeServices.test.mjs Normal file
View File

@ -0,0 +1,64 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizePhotonPlace } from './freeGeocode.js';
import { freeServicesPlugin, freeProviderConfig } from '../server/freeServices.js';
import { sourceStatusText } from './sourceStatus.js';
const berlin = { geometry: { type: 'Point', coordinates: [13.4, 52.5] }, properties: {
name: 'Berlin', country: 'Germany', osm_value: 'city', extent: [13, 53, 14, 52],
} };
test('free geocoder validates coordinates and converts north/south extent order', () => {
const place = normalizePhotonPlace(berlin);
assert.deepEqual(place.types, ['locality']);
assert.deepEqual(place.viewport, { southwest: { lat: 52, lng: 13 }, northeast: { lat: 53, lng: 14 } });
assert.equal(normalizePhotonPlace({ ...berlin, geometry: { type: 'Point', coordinates: [0, 100] } }), null);
assert.equal(normalizePhotonPlace(null), null);
});
test('provider status exposes only booleans and free mode overrides metered keys', () => {
const config = freeProviderConfig({ GEV_FREE_ONLY: '1', OPENAI_API_KEY: 'secret', GOOGLE_MAPS_API_KEY: 'secret', AISSTREAM_API_KEY: 'secret' });
assert.equal(config.voice, false);
assert.equal(config.google, false);
assert.equal(config.ships, true);
assert.ok(Object.values(config).every(v => typeof v === 'boolean'));
assert.ok(!JSON.stringify(config).includes('secret'));
});
function harness(fetchJson) {
const routes = new Map();
freeServicesPlugin({ fetchJson }).configureServer({ middlewares: { use: (path, fn) => routes.set(path, fn) } });
return (url, method = 'GET') => new Promise(resolve => {
let status;
routes.get('/api/free-geocode')({ url, method }, {
writeHead: s => { status = s; },
end: body => resolve({ status, body: JSON.parse(body) }),
});
});
}
test('free search coalesces simultaneous queries and caches the response', async () => {
let calls = 0;
const request = harness(async url => {
calls++;
assert.equal(new URL(url).searchParams.get('q'), 'Berlin');
return { features: [berlin] };
});
const [a, b] = await Promise.all([request('?q=Berlin'), request('?q=Berlin')]);
assert.equal(a.status, 200);
assert.deepEqual(a, b);
assert.equal((await request('?q=berlin')).body.place.label, 'Berlin, Germany');
assert.equal(calls, 1);
});
test('invalid search requests never call the provider; outages are not empty success', async () => {
let calls = 0;
const request = harness(async () => { calls++; throw new Error('outage'); });
assert.equal((await request('?q=')).status, 400);
assert.equal((await request(`?q=${'x'.repeat(201)}`)).status, 400);
assert.equal((await request('?q=Berlin', 'POST')).status, 405);
assert.equal(calls, 0);
assert.equal((await request('?q=Berlin')).status, 503);
});
test('source status distinguishes off, failed, and receipt age from observation time', () => {
assert.equal(sourceStatusText({ enabled: true, stats: { status: 'zoom-in', error: 'Zoom in to load' } }), 'Zoom in to load');
assert.equal(sourceStatusText({ enabled: false }), 'Off');
assert.match(sourceStatusText({ enabled: true, stats: { error: 'feed down' } }), /Unavailable/);
assert.equal(sourceStatusText({ enabled: true, stats: { count: 4, lastUpdate: 1000 } }, 61000), '4 items · received 1m ago');
assert.match(sourceStatusText({ enabled: true, stats: { count: 4, lastUpdate: 1000, source: 'adsb.lol', coverage: 'regional fallback' } }, 61000), /FALLBACK.*regional fallback/);
});

View File

@ -621,6 +621,13 @@ export class IntelHUD {
*/
async _updateSummary(animate = false, force = false) {
const fallbackText = this._composeSummary();
// Free mode intentionally has no OpenAI key. Use the live local metrics
// directly instead of repeatedly calling a disabled service and emitting 503s.
if (import.meta.env?.GEV_FREE_ONLY === true) {
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
return;
}
if (!this._latestMetrics) {
this._setSummaryText(fallbackText, animate);
return;

View File

@ -0,0 +1,11 @@
export const PREFERENCES_KEY = 'gods-eye.live-views.v1';
export function cleanPreferences(raw) {
const favorites = (Array.isArray(raw?.favorites) ? raw.favorites : []).filter(p =>
typeof p?.name === 'string' && p.name.trim() && Number.isFinite(p.lat) &&
Number.isFinite(p.lon) && Math.abs(p.lat)<=90 && Math.abs(p.lon)<=180
).slice(0,50).map(p=>({name:p.name.slice(0,160),lat:p.lat,lon:p.lon}));
return {favorites,unit:raw?.unit==='C'?'C':'F',selected:typeof raw?.selected==='string'?raw.selected:'Boston'};
}
export function temperature(value,unit) {
return Number.isFinite(value) ? `${Math.round(unit==='F'?value*9/5+32:value)}°${unit}` : 'unavailable';
}

View File

@ -0,0 +1,12 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {cleanPreferences,temperature} from './liveViewPreferences.js';
test('saved cities are bounded and malformed storage is safe',()=>{
assert.deepEqual(cleanPreferences(null),{favorites:[],unit:'F',selected:'Boston'});
assert.equal(cleanPreferences({favorites:[{name:'A',lat:91,lon:0},{name:'B',lat:0,lon:0}]}).favorites.length,1);
assert.equal(cleanPreferences({favorites:Array.from({length:80},()=>({name:'A',lat:0,lon:0}))}).favorites.length,50);
});
test('units handle freezing, negatives and missing values',()=>{
assert.equal(temperature(0,'F'),'32°F');assert.equal(temperature(-40,'F'),'-40°F');
assert.equal(temperature(20,'C'),'20°C');assert.equal(temperature(null,'F'),'unavailable');
});

124
src/liveViews.js Normal file
View File

@ -0,0 +1,124 @@
import * as Cesium from 'cesium';
import { WORLD_PLACES } from './worldPlaces.js';
import { findFreePlace } from './freeGeocode.js';
import { cleanPreferences, PREFERENCES_KEY, temperature } from './liveViewPreferences.js';
import { createWeatherRadar } from './weatherRadar.js';
export function initLiveViews({viewer,dataManager}) {
const data=new Cesium.CustomDataSource('Live views'); viewer.dataSources.add(data);
const button=document.createElement('button');button.textContent='◈';button.id='live-views-button';
button.title='City Pulse, Storm Watch & World Weather';button.setAttribute('aria-label',button.title);
document.getElementById('top-center-actions').append(button);
const panel=document.createElement('section');panel.id='live-views-panel';panel.hidden=true;
panel.setAttribute('aria-label','Live map views');
const title=document.createElement('h2');title.textContent='Live map views';
const close=document.createElement('button');close.textContent='Stop & close';
const focus=document.createElement('button');focus.textContent='Focus map';
let pausedLayers=[];
focus.onclick=async()=>{
focus.disabled=true;close.disabled=true;
if(pausedLayers.length){await Promise.allSettled(pausedLayers.map(id=>dataManager.setEnabled(id,true,{origin:'user'})));pausedLayers=[];focus.textContent='Focus map';}
else{pausedLayers=dataManager.getAll().filter(l=>l.enabled).map(l=>l.id);await Promise.allSettled(pausedLayers.map(id=>dataManager.setEnabled(id,false,{origin:'user'})));focus.textContent='Restore other layers';}
focus.disabled=false;close.disabled=false;
};
const mode=document.createElement('select');mode.setAttribute('aria-label','Live view');
for(const [value,label] of [['city','City Pulse'],['storm','Storm Watch'],['weather','Weather around the world']]) {const o=document.createElement('option');o.value=value;o.textContent=label;mode.append(o);}
let prefs;try{prefs=cleanPreferences(JSON.parse(localStorage.getItem(PREFERENCES_KEY)));}catch{prefs=cleanPreferences();}
const persist=()=>{try{localStorage.setItem(PREFERENCES_KEY,JSON.stringify(prefs));}catch{status.textContent='Browser storage unavailable; changes last for this session only.';}};
let custom=null;
const places=()=>[...prefs.favorites,...WORLD_PLACES.filter(p=>!prefs.favorites.some(f=>f.name===p.name)),...(custom&&!prefs.favorites.some(f=>f.name===custom.name)&&!WORLD_PLACES.some(f=>f.name===custom.name)?[custom]:[])];
const selected=()=>places().find(p=>p.name===city.value)||WORLD_PLACES.find(p=>p.name==='Boston');
const city=document.createElement('select');city.setAttribute('aria-label','View city');
function populate(name=prefs.selected){city.replaceChildren();for(const p of places()){const o=document.createElement('option');o.value=p.name;o.textContent=(prefs.favorites.some(f=>f.name===p.name)?'★ ':'')+p.name;city.append(o);}city.value=places().some(p=>p.name===name)?name:'Boston';}
populate();
const favorite=document.createElement('button');
const favoriteLabel=()=>{favorite.textContent=prefs.favorites.some(p=>p.name===city.value)?'★ Remove favorite':'☆ Save favorite city';};
favoriteLabel();
favorite.onclick=()=>{const p=selected();if(prefs.favorites.some(f=>f.name===p.name)){prefs.favorites=prefs.favorites.filter(f=>f.name!==p.name);custom=p;}else{if(prefs.favorites.length>=50){status.textContent='You can save up to 50 cities. Remove a favorite first.';return;}prefs.favorites.push(p);}prefs.selected=p.name;persist();populate(p.name);favoriteLabel();};
const units=document.createElement('select');units.setAttribute('aria-label','Temperature units');
for(const [value,label] of [['F','Fahrenheit (°F)'],['C','Celsius (°C)']]){const o=document.createElement('option');o.value=value;o.textContent=label;units.append(o);}units.value=prefs.unit;
units.onchange=()=>{prefs.unit=units.value;persist();if(lastReport)render(lastReport);};
const hazard=document.createElement('select');hazard.setAttribute('aria-label','Hazard filter');
const hazardNames={TC:'Cyclones',FL:'Floods',DR:'Droughts',WF:'Wildfires',VO:'Volcanoes'};
for(const [value,label] of [['all','All hazards'],...Object.entries(hazardNames)]){const o=document.createElement('option');o.value=value;o.textContent=label;hazard.append(o);}
hazard.onchange=()=>{if(lastReport)render(lastReport);};
const legend=document.createElement('p');legend.textContent='Alert level: red · orange · green. Symbols mark report locations, not affected boundaries. Zoom in for labels.';
const go=document.createElement('button');go.textContent='Go to city';
const cameras=document.createElement('button');cameras.textContent='Browse cameras';cameras.onclick=()=>document.getElementById('camera-browser-button')?.click();
const layers=document.createElement('button');layers.textContent='Enable city layers';
layers.onclick=async()=>{
layers.disabled=true;
const result=await Promise.allSettled(['traffic','cctv','bikeshare'].map(id=>dataManager.setEnabled(id,true,{origin:'user'})));
status.textContent=result.every(r=>r.status==='fulfilled'&&r.value!==false)?'City layers enabled; local coverage varies.':'Some city layers did not enable; check Source Status.';
layers.disabled=false;
};
const form=document.createElement('form');const query=document.createElement('input');query.placeholder='Find any city or place';query.setAttribute('aria-label','Find city or place');
const search=document.createElement('button');search.textContent='Find place';form.append(query,search);
const reset=document.createElement('button');reset.textContent='World overview';
const status=document.createElement('p');status.setAttribute('role','status');
const note=document.createElement('p');const list=document.createElement('div');
const radar=createWeatherRadar(viewer);
panel.append(title,close,focus,mode,city,go,favorite,form,layers,cameras,units,hazard,legend,radar.element,reset,status,note,list);document.body.append(panel);
let timer=null,abort=null,generation=0,point=null,lastReport=null,lastKey=null;
const fly=p=>viewer.camera.flyTo({destination:Cesium.Cartesian3.fromDegrees(p.lon,p.lat,mode.value==='city'?18000:300000),duration:1.5});
function clear(){clearTimeout(timer);abort?.abort();generation++;data.entities.removeAll();viewer.scene.requestRender();}
function render(report) {
data.entities.removeAll();list.replaceChildren();
note.textContent=`${report.source}. ${report.kind}. Received ${new Date(report.receivedAt).toLocaleTimeString()}.${report.stale?' STALE — latest refresh failed.':''}`;
let items=report.items;
if(mode.value==='storm') items=items.filter(x=>['TC','FL','DR','WF','VO'].includes(x.type)&&(hazard.value==='all'||x.type===hazard.value));
status.textContent=`${report.stale?'STALE · ':''}${items.length} ${mode.value==='weather'?'weather locations':mode.value==='city'?'reported transit vehicles':'published hazard reports'}. Select a row to fly there.`;
if(mode.value==='storm') note.textContent+=' Cyclones, floods, droughts, wildfires and volcanoes. Reports can describe ongoing events, not a complete local warning service.';
if(mode.value==='weather') note.textContent+=' Global overview samples 18 cities. Search any place for local weather. Times are UTC.';
const weatherValue=(value,unit)=>value==null?'unavailable':`${value}${unit}`;
for(const [index,item] of items.entries()) {
const text=mode.value==='weather'?`${item.name}: ${temperature(item.current.temperature_2m,prefs.unit)} · wind ${weatherValue(item.current.wind_speed_10m,' km/h')}`:item.name;
const color=mode.value==='weather'?Cesium.Color.SKYBLUE:mode.value==='city'?Cesium.Color.LIME: item.level==='Red'?Cesium.Color.RED:item.level==='Orange'?Cesium.Color.ORANGE:item.level==='Green'?Cesium.Color.GREEN:Cesium.Color.GRAY;
const symbol={TC:'🌀',FL:'≋',DR:'☀',WF:'♨',VO:'▲'}[item.type]||'!';
const icon='data:image/svg+xml,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><circle cx="20" cy="20" r="17" fill="#101c30" stroke="${color.toCssColorString()}" stroke-width="3"/><text x="20" y="27" font-size="24" font-family="sans-serif" text-anchor="middle" fill="white">${symbol}</text></svg>`);
data.entities.add({id:`view-${index}`,name:text,position:Cesium.Cartesian3.fromDegrees(item.lon,item.lat,100),
point:mode.value==='storm'?undefined:{pixelSize:mode.value==='city'?7:10,color,outlineColor:Cesium.Color.BLACK,outlineWidth:1},
billboard:mode.value==='storm'?{image:icon,width:32,height:32,scaleByDistance:new Cesium.NearFarScalar(100000,1,20000000,.65)}:undefined,
label:mode.value==='city'?undefined:{text:mode.value==='weather'?`${item.name} ${temperature(item.current.temperature_2m,prefs.unit)}`:hazardNames[item.type],font:'13px sans-serif',fillColor:Cesium.Color.WHITE,showBackground:true,pixelOffset:new Cesium.Cartesian2(0,-25),distanceDisplayCondition:new Cesium.DistanceDisplayCondition(0,mode.value==='storm'?1800000:25000000)}});
const row=document.createElement('section');const action=document.createElement('button');action.textContent=text;action.onclick=()=>fly(item);row.append(action);
const details=document.createElement('p');
if(mode.value==='weather') {
const daily=item.daily||{};
details.textContent=`Model time: ${item.current.time} UTC · precipitation ${weatherValue(item.current.precipitation,' mm')}. `+(daily.time||[]).map((t,i)=>`${t}: ${temperature(daily.temperature_2m_min?.[i],prefs.unit)}${temperature(daily.temperature_2m_max?.[i],prefs.unit)}, precipitation chance ${weatherValue(daily.precipitation_probability_max?.[i],'%')}`).join(' | ');
} else details.textContent=mode.value==='storm'?`${item.level} · ${item.time} UTC · ${item.description}`:`Observed ${item.time||'time unavailable'} · ${item.status||''}`;
row.append(details);list.append(row);
}
viewer.scene.requestRender();
}
async function refresh() {
clearTimeout(timer);abort?.abort();const token=++generation;abort=new AbortController();
if(mode.value==='city'&&city.value!=='Boston'){data.entities.removeAll();list.replaceChildren();note.textContent='Cameras, traffic and bikeshare depend on local coverage. The new transit connection currently covers Boston only.';status.textContent='Choose Enable city layers or Browse cameras.';return;}
const key=mode.value==='city'?'/transit':mode.value==='storm'?'/disasters':point?`/weather?lat=${point.lat}&lon=${point.lon}`:'/weather?world=1';
status.textContent='Loading public feed…';
if(lastKey!==key){data.entities.removeAll();list.replaceChildren();lastReport=null;lastKey=key;}
const request=abort;
const timeout=setTimeout(()=>request.abort(),20000);
try {
const r=await fetch(`/api/live-views${key}`,{signal:request.signal});if(!r.ok)throw new Error('Unavailable');
const report=await r.json();if(token!==generation||panel.hidden)return;
if(point&&mode.value==='weather'&&report.items[0])report.items[0].name=point.name;
lastReport=report;render(report);
}catch{
if(token!==generation||panel.hidden)return;
if(lastReport){render({...lastReport,stale:true});status.textContent='Refresh failed · retained previous data marked STALE.';}
else{status.textContent='Provider unavailable. Automatic retry will continue while this view is open.';note.textContent='No current data verified.';}
}finally{
clearTimeout(timeout);
if(token===generation&&!panel.hidden)timer=setTimeout(refresh,mode.value==='city'?30000:mode.value==='storm'?360000:600000);
}
}
function switchMode(){clear();point=null;lastReport=null;lastKey=null;note.textContent='';form.hidden=mode.value==='storm';units.hidden=mode.value!=='weather';hazard.hidden=legend.hidden=mode.value!=='storm';radar.setActive(mode.value!=='city');layers.hidden=cameras.hidden=mode.value!=='city';reset.hidden=mode.value==='city';if(mode.value!=='city')viewer.camera.flyTo({destination:Cesium.Cartesian3.fromDegrees(0,15,22000000),duration:1.5});else fly(selected());refresh();}
mode.onchange=switchMode;
go.onclick=()=>{const p=selected();prefs.selected=p.name;persist();favoriteLabel();fly(p);if(mode.value==='weather'){point=p;refresh();}else if(mode.value==='city')refresh();};
city.onchange=()=>go.click();
reset.onclick=()=>{point=null;viewer.camera.flyTo({destination:Cesium.Cartesian3.fromDegrees(0,15,22000000),duration:1.5});refresh();};
form.onsubmit=async e=>{e.preventDefault();if(!query.value.trim())return;clear();const token=generation;search.disabled=true;status.textContent='Finding place…';try{const p=await findFreePlace(query.value.trim());if(token!==generation||panel.hidden)return;if(!p){status.textContent='Place not found.';return;}custom={name:p.primaryName||p.label,lat:p.lat,lon:p.lon};populate(custom.name);favoriteLabel();point=custom;fly(point);refresh();}catch{if(token===generation)status.textContent='Place search unavailable.';}finally{search.disabled=false;}};
button.onclick=()=>{if(!panel.hidden)return;panel.hidden=false;switchMode();};
close.onclick=()=>{panel.hidden=true;radar.setActive(false);clear();lastReport=null;lastKey=null;if(pausedLayers.length)focus.click();};
}

View File

@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { findFreePlace } from './freeGeocode.js';
import { viewportBias, placesNearViewRecovery } from './annotations/annotationResolver.js';
/**
@ -347,8 +348,35 @@ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true });
* default; precise landmarks/buildings use close landmark framing.
*/
export async function searchAndFlyTo(viewer, query, options = {}) {
const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) throw new Error('No Google Maps API key available for geocoding');
const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env?.GOOGLE_MAPS_API_KEY;
if (!apiKey || apiKey === 'your_google_maps_api_key_here') {
const normalized = String(query).trim().toLowerCase();
const cityEntry = Object.entries(CITY_POIS).find(([id, city]) =>
normalized === id || normalized === city.name.toLowerCase());
const poi = findPoiByName(query);
if (!cityEntry && !poi) {
const place = await findFreePlace(query, { signal: options.signal });
if (!place) return null;
if (typeof options.beforeFly === 'function' && options.beforeFly() === false) return CANCELLED_SEARCH;
const mode = geocodeNavigationMode(place.types);
const range = finitePositive(options.range) || defaultRangeForNavigationMode(mode);
if (place.viewport && shouldFrameGeocodeViewport(mode) && !options.range && !options.forceClose) {
const result = flyToViewportBounds(viewer, place.viewport, { ...options, navigationMode: mode });
if (result === CANCELLED_SEARCH) return result;
} else {
flyToLandmark(viewer, place.lat, place.lon, { ...options, range });
}
return { label: place.label, navigationMode: mode, rangeM: range };
}
if (typeof options.beforeFly === 'function' && options.beforeFly() === false) return CANCELLED_SEARCH;
if (cityEntry) {
const [id, city] = cityEntry;
flyToPresetLocation(viewer, id, { viewMode: 'overview', ...options });
return { label: city.name, navigationMode: 'city-overview', rangeM: null };
}
flyToPOI(viewer, poi.cityId, poi.index, options);
return { label: CITY_POIS[poi.cityId].pois[poi.index].name, navigationMode: 'precise-place', rangeM: null };
}
const beforeFly = typeof options.beforeFly === 'function' ? options.beforeFly : null;
const mayFly = () => beforeFly === null || beforeFly() !== false;

View File

@ -53,6 +53,25 @@ const AUSTIN_RESULT = {
},
};
test('keyless search navigates bundled places without network requests and respects cancellation', async () => {
const priorWindow = globalThis.window;
const priorFetch = globalThis.fetch;
globalThis.window = {};
globalThis.fetch = async () => { throw new Error('Keyless preset search must not use a provider'); };
try {
const viewer = stubViewer();
assert.equal((await searchAndFlyTo(viewer, ' Austin ')).label, 'Austin');
assert.equal((await searchAndFlyTo(viewer, 'Golden Gate Bridge')).label, 'Golden Gate Bridge');
assert.equal(viewer.flights.length, 2);
assert.equal(await searchAndFlyTo(viewer, 'Tokyo', { beforeFly: () => false }), CANCELLED_SEARCH);
assert.equal(viewer.flights.length, 2);
} finally {
globalThis.fetch = priorFetch;
if (priorWindow === undefined) delete globalThis.window;
else globalThis.window = priorWindow;
}
});
async function runSearch(viewer, options, { result = AUSTIN_RESULT, query = 'austin' } = {}) {
const hadWindow = Object.hasOwn(globalThis, 'window');
const priorWindow = globalThis.window;

View File

@ -1,3 +1,5 @@
import { initCreatorCredits } from './creatorCredits.js';
import { initSituationDesk } from './situationDesk.js';
import * as Cesium from 'cesium';
import { StyleManager } from './ui.js';
import { flyToAustin } from './camera.js';
@ -32,6 +34,9 @@ import {
} from './renderGovernor.js';
import { installScopeMask } from './scopeMask.js';
import { initFirstRunExperience } from './firstRunExperience.js';
import { initSourceStatus } from './sourceStatus.js';
import { initCameraBrowser } from './cameraBrowser.js';
import { initLiveViews } from './liveViews.js';
import { initKeySetup } from './keySetup.js';
import { loadPhotorealisticTileset } from './mapStartup.js';
@ -238,6 +243,11 @@ async function init() {
}
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
initSourceStatus({ dataManager });
initCreatorCredits();
initSituationDesk({ viewer });
initCameraBrowser();
initLiveViews({ viewer, dataManager });
// Initialize deterministic scene playback for social clip capture
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);

121
src/situationDesk.js Normal file
View File

@ -0,0 +1,121 @@
import * as Cesium from 'cesium';
import {REGIONS,TOPICS,safeNewsUrl,headlineKeywords,mentionedCountries} from './situationModel.js';
const KEY='gev.situation-desk.v1';
export function initSituationDesk({viewer}){
let prefs={watch:[],saved:[],notes:''};try{
const p=JSON.parse(localStorage.getItem(KEY));
if(p)prefs={watch:(Array.isArray(p.watch)?p.watch:[]).filter(id=>REGIONS.some(r=>r.id===id)).slice(0,8),
saved:(Array.isArray(p.saved)?p.saved:[]).filter(a=>safeNewsUrl(a.url)&&typeof a.title==='string').slice(0,100),
notes:typeof p.notes==='string'?p.notes.slice(0,6000):''};
}catch{}
const el=(tag,text)=>{const e=document.createElement(tag);if(text)e.textContent=text;return e;};
const button=el('button','Situation');button.id='situation-desk-button';button.title='Situation Desk · public news and regional context';
document.getElementById('top-center-actions')?.append(button);
const panel=el('section');panel.id='situation-desk';panel.hidden=true;panel.setAttribute('aria-label','Situation Desk');
const header=el('header'),title=el('h2','Situation Desk'),close=el('button','Close');
header.append(title,close,el('p','PUBLIC NEWS · REGIONAL CONTEXT'));
const disclaimer=el('p','Headlines are publisher reports, not independently verified events. Map markers show selected regions or country names mentioned in headlines, not incident locations.');
disclaimer.className='situation-disclaimer';
const controls=el('div');controls.className='situation-controls';
const select=(label,options)=>{
const s=el('select');s.setAttribute('aria-label',label);for(const [v,t] of options){const o=el('option',t);o.value=v;s.append(o);}return s;
};
const region=select('Situation region',REGIONS.map(r=>[r.id,r.name]));
const topic=select('Situation topic',Object.entries(TOPICS).map(([id,t])=>[id,t.name]));
const hours=select('News time window',[['24','Past 24 hours'],['6','Past 6 hours'],['48','Past 48 hours']]);
const refreshButton=el('button','Refresh'),watch=el('button','☆ Watch region'),fly=el('button','Show region');
controls.append(region,topic,hours,refreshButton,watch,fly);
const watches=el('div');watches.className='situation-watchlist';
const search=el('input');search.placeholder='Filter loaded headlines…';search.setAttribute('aria-label','Filter headlines');
const tabs=el('nav');tabs.setAttribute('aria-label','Situation sections');
let tab='feed';const tabButtons={};
for(const [id,name] of [['feed','Headlines'],['brief','Briefing'],['saved','Saved'],['notes','Scenario notes']]){
const b=el('button',name);b.onclick=()=>{tab=id;render();};tabButtons[id]=b;tabs.append(b);
}
const status=el('p','Choose a region to load public news.');status.setAttribute('role','status');
const content=el('div');content.className='situation-content';
const footer=el('footer','Original globe: Bilawal Sidhu & Sameh Khamis / Halfpixel · Community edition: ModDayJob');
panel.append(header,disclaimer,controls,watches,search,tabs,status,content,footer);document.body.append(panel);
const mapData=new Cesium.CustomDataSource('Situation regional context');viewer.dataSources.add(mapData);
let report=null,timer=null,controller=null,generation=0,lastKey='',latestSeen=0;
const persist=()=>{try{localStorage.setItem(KEY,JSON.stringify(prefs));}catch{status.textContent='Browser storage unavailable; changes are only kept this session.';}};
function renderWatches(){
watches.replaceChildren();watch.textContent=prefs.watch.includes(region.value)?'★ Unwatch region':'☆ Watch region';
for(const id of prefs.watch){const r=REGIONS.find(x=>x.id===id),b=el('button',r.name);b.onclick=()=>{region.value=id;load();};watches.append(b);}
}
function mapRegion(){
mapData.entities.removeAll();const r=REGIONS.find(x=>x.id===region.value);if(r.id==='world'){
for(const c of mentionedCountries(report?.items||[]))mapData.entities.add({position:Cesium.Cartesian3.fromDegrees(c.lon,c.lat,1000),name:c.name+' · '+c.count+' headline mentions',
point:{pixelSize:Math.min(22,8+Math.sqrt(c.count)*2),color:Cesium.Color.CYAN,outlineWidth:2,outlineColor:Cesium.Color.BLACK},
label:{text:c.name+' · '+c.count,font:'13px sans-serif',showBackground:true,pixelOffset:new Cesium.Cartesian2(0,-22),distanceDisplayCondition:new Cesium.DistanceDisplayCondition(0,10000000)}});
viewer.scene.requestRender();return;
}
mapData.entities.add({position:Cesium.Cartesian3.fromDegrees(r.lon,r.lat,1000),name:r.name+' · region context, not incident location',
point:{pixelSize:12,color:Cesium.Color.CYAN,outlineWidth:2,outlineColor:Cesium.Color.BLACK},
label:{text:r.name+' · region',font:'14px sans-serif',showBackground:true,pixelOffset:new Cesium.Cartesian2(0,-24)}});
viewer.scene.requestRender();
}
function filtered(items){const q=search.value.toLowerCase().trim();return items.filter(a=>!q||(a.title+' '+a.source).toLowerCase().includes(q));}
function download(text,name){
const url=URL.createObjectURL(new Blob([text],{type:'text/plain'})),a=el('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function cards(items){
if(!items.length)content.append(el('p','No matching headlines. Try another region, topic or time window.'));
for(const a of items){
const card=el('article'),link=el('a',a.title);link.href=safeNewsUrl(a.url);link.target='_blank';link.rel='noopener noreferrer';card.append(link);
card.append(el('p',(a.source||'Publisher')+' · '+new Date(a.publishedAt).toLocaleString()+' · Reported'));
const saved=prefs.saved.some(x=>x.url===a.url),save=el('button',saved?'★ Remove saved':'☆ Save story');
save.onclick=()=>{if(saved)prefs.saved=prefs.saved.filter(x=>x.url!==a.url);else{
if(prefs.saved.length>=100){status.textContent='100 saved stories reached; remove one first.';return;}
prefs.saved.push({...a,region:region.value});
}persist();render();};card.append(save);content.append(card);
}
}
function render(){
content.replaceChildren();renderWatches();
for(const [id,b] of Object.entries(tabButtons))b.setAttribute('aria-pressed',String(id===tab));
const items=filtered(report?.items||[]),r=REGIONS.find(x=>x.id===region.value);
if(tab==='feed'){if(report)cards(items);else content.append(el('p','No feed loaded yet.'));}
if(tab==='saved'){content.append(el('p','Saved locally in this browser. These are archived headlines; they are not refreshed or re-verified.'));cards(filtered(prefs.saved));}
if(tab==='notes'){
content.append(el('h3','Your scenario notebook'),el('p','Write hypotheses, assumptions and evidence to check. These notes are yours, not predictions or live intelligence.'));
const notes=el('textarea');notes.setAttribute('aria-label','Scenario notes');notes.maxLength=6000;notes.rows=12;notes.value=prefs.notes;
notes.oninput=()=>{prefs.notes=notes.value;persist();};const exportNotes=el('button','Download notes');exportNotes.onclick=()=>download(prefs.notes,'situation-notes.txt');content.append(notes,exportNotes);
}
if(tab==='brief'){
const sources=new Set(items.map(x=>x.source));
const summary=[r.name+' — '+TOPICS[topic.value].name,items.length+' loaded headlines from '+sources.size+' publisher labels in the selected window.',
'This summarizes the loaded sample, not all events. Multiple outlets do not establish independent corroboration.',
'Keywords in loaded headlines: '+headlineKeywords(items).map(([w,n])=>w+' ('+n+')').join(', '),
'Feed received: '+(report?new Date(report.receivedAt).toLocaleString():'not loaded')+(report?.stale?' · STALE':''),
...items.slice(0,10).map(a=>a.title+'\n'+a.source+' · '+a.publishedAt+'\n'+a.url)].join('\n\n');
const pre=el('div',summary.replace(/^https?:\/\/\S+$/gm,''));pre.className='situation-brief';const exportBrief=el('button','Download briefing');exportBrief.onclick=()=>download(summary,'situation-briefing.txt');
content.append(el('h3','Briefing from loaded headlines'),pre,exportBrief);
}
}
async function load(){
clearTimeout(timer);controller?.abort();controller=new AbortController();const token=++generation,request=controller;
const key=region.value+':'+topic.value+':'+hours.value;
if(key!==lastKey){report=null;lastKey=key;latestSeen=0;}render();mapRegion();
refreshButton.disabled=true;status.textContent='Loading public headlines…';
const deadline=setTimeout(()=>request.abort(),18000);
try{
const u=new URLSearchParams({region:region.value,topic:topic.value,hours:hours.value});
const response=await fetch('/api/situation-news?'+u,{signal:request.signal});if(!response.ok)throw new Error('Unavailable');
const next=await response.json();if(token!==generation||panel.hidden)return;
const fresh=latestSeen?next.items.filter(x=>Date.parse(x.publishedAt)>latestSeen).length:0;
report=next;mapRegion();latestSeen=Math.max(latestSeen,...next.items.map(x=>Date.parse(x.publishedAt)),0);
status.textContent=(next.stale?'STALE · ':'')+next.items.length+' headlines · '+(fresh?fresh+' newly indexed · ':'')+'Received '+new Date(next.receivedAt).toLocaleTimeString()+' · '+next.source+' · refresh every 5 minutes';
render();
}catch{
if(token!==generation||panel.hidden)return;if(report)report={...report,stale:true};
status.textContent=report?'Refresh failed · previous headlines retained as STALE.':'News provider unavailable. Automatic retry in five minutes.';render();
}finally{clearTimeout(deadline);if(token===generation){refreshButton.disabled=false;if(!panel.hidden)timer=setTimeout(load,300000);}}
}
region.onchange=topic.onchange=hours.onchange=load;refreshButton.onclick=load;search.oninput=render;
watch.onclick=()=>{prefs.watch=prefs.watch.includes(region.value)?prefs.watch.filter(x=>x!==region.value):[...prefs.watch,region.value];persist();renderWatches();};
fly.onclick=()=>{const r=REGIONS.find(x=>x.id===region.value);viewer.camera.flyTo({destination:Cesium.Cartesian3.fromDegrees(r.lon,r.lat,r.id==='world'?22000000:3500000),duration:1.5});};
button.onclick=()=>{if(!panel.hidden)return;panel.hidden=false;load();};
close.onclick=()=>{panel.hidden=true;generation++;clearTimeout(timer);controller?.abort();mapData.entities.removeAll();viewer.scene.requestRender();};
renderWatches();
}

45
src/situationModel.js Normal file
View File

@ -0,0 +1,45 @@
export const REGIONS=[
{id:'world',name:'Worldwide',query:'',lat:15,lon:0},
{id:'ukraine',name:'Ukraine',query:'Ukraine',lat:49,lon:32},
{id:'middle-east',name:'Middle East',query:'(Iran OR Israel OR Lebanon OR Yemen)',lat:29,lon:43},
{id:'sudan',name:'Sudan',query:'Sudan',lat:15,lon:30},
{id:'europe',name:'Europe',query:'Europe',lat:50,lon:12},
{id:'asia',name:'East Asia',query:'(Taiwan OR China OR Korea)',lat:30,lon:120},
{id:'africa',name:'Africa',query:'Africa',lat:3,lon:20},
{id:'americas',name:'Americas',query:'(America OR Brazil OR Mexico)',lat:15,lon:-85}
];
export const TOPICS={
conflict:{name:'Conflict & diplomacy',query:'(conflict OR ceasefire OR diplomacy OR sanctions)'},
humanitarian:{name:'Humanitarian',query:'(humanitarian OR displacement OR refugees OR famine)'},
disaster:{name:'Disasters',query:'(earthquake OR flood OR wildfire OR cyclone)'},
all:{name:'World headlines',query:'(world OR international)'}
};
export function safeNewsUrl(value){
try{const u=new URL(value);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password?u.href:null;}catch{return null;}
}
export function cleanArticles(rows,now=Date.now(),hours=24){
const seen=new Set();return (Array.isArray(rows)?rows:[]).flatMap(a=>{
const url=safeNewsUrl(a.url),time=Date.parse(a.publishedAt);
if(!url||typeof a.title!=='string'||!a.title.trim()||!Number.isFinite(time)||time>now+300000||time<now-hours*3600000)return [];
const key=a.title.toLowerCase().replace(/[^\p{L}\p{N}]/gu,'');
if(seen.has(key))return [];seen.add(key);
return [{id:url,title:a.title.trim().slice(0,240),url,publishedAt:new Date(time).toISOString(),source:String(a.domain||new URL(url).hostname).slice(0,120)}];
}).sort((a,b)=>Date.parse(b.publishedAt)-Date.parse(a.publishedAt)).slice(0,60);
}
export function headlineKeywords(items){
const stop=new Set('about after amid been before between could from have into more over says said than that their there these they this through under were what when where which while will with would world news live'.split(' '));
const counts=new Map();
for(const item of items)for(const word of new Set(item.title.toLowerCase().match(/[a-z]{4,}/g)||[]))if(!stop.has(word))counts.set(word,(counts.get(word)||0)+1);
return [...counts].sort((a,b)=>b[1]-a[1]).slice(0,8);
}
export const COUNTRY_CONTEXT=[
['Ukraine',49,32],['Russia',60,90],['Iran',32,54],['Israel',31.5,34.8],
['Lebanon',33.9,35.9],['Yemen',15.5,47.5],['Sudan',15,30],['Taiwan',23.7,121],
['China',35,104],['Japan',36,138],['Jordan',31,36],['Bahrain',26,50.5],
['Saudi Arabia',24,45],['United States',39,-98],['France',47,2],['Germany',51,10],
['India',22,79],['Pakistan',30,70],['Myanmar',21,96],['Nigeria',9,8],
['Ethiopia',9,40],['Somalia',5,46],['Mexico',23,-102],['Brazil',-10,-52]
].map(([name,lat,lon])=>({name,lat,lon}));
export function mentionedCountries(items){
return COUNTRY_CONTEXT.map(c=>({...c,count:items.filter(a=>new RegExp('\\b'+c.name+'\\b','i').test(a.title)).length})).filter(c=>c.count>0).sort((a,b)=>b.count-a.count);
}

View File

@ -0,0 +1,16 @@
import {test} from 'node:test';import assert from 'node:assert/strict';
import {cleanArticles,safeNewsUrl,headlineKeywords} from './situationModel.js';
test('headlines reject unsafe URLs, invalid times and duplicates',()=>{
const now=Date.now(),a={url:'https://example.com/a',title:'Ceasefire talks resume',publishedAt:new Date(now).toISOString(),domain:'Example'};
assert.equal(cleanArticles([a,{...a,url:'https://example.com/b'},{...a,url:'javascript:alert(1)'},{...a,title:'Old',publishedAt:'2000-01-01'}],now).length,1);
assert.equal(safeNewsUrl('https://user:pass@example.com'),null);
assert.equal(cleanArticles([{...a,publishedAt:'invalid'}],now).length,0);
});
test('keyword counts represent headlines, not repeated words or confidence',()=>{
assert.deepEqual(headlineKeywords([{title:'Ceasefire ceasefire talks'},{title:'Ceasefire continues'}])[0],['ceasefire',2]);
});
test('map counts are explicit country-name mentions only',async()=>{
const {mentionedCountries}=await import('./situationModel.js');
assert.deepEqual(mentionedCountries([{title:'Ukraine and Iran diplomacy'},{title:'Iran updates'}]).map(c=>[c.name,c.count]),[['Iran',2],['Ukraine',1]]);
assert.equal(mentionedCountries([{title:'Unknown location'}]).length,0);
});

41
src/sourceHealth.js Normal file
View File

@ -0,0 +1,41 @@
import { layerFeedState } from './data/manager.js';
// Receipt deadlines are generous tolerances, not provider observation promises.
const DEADLINES = { flights: 180000, military: 120000, earthquakes: 300000,
'ais-live-vessels': 120000, 'rocket-launches': 1200000, 'local-firms': 3600000 };
const REFERENCE = new Set(['military-installations', 'local-datacenters', 'local-dams', 'telegeography-submarine-cables']);
export function sourceHealth(layer, now = Date.now()) {
const stats = layer.stats || {};
const time = stats.lastUpdate == null ? NaN : new Date(stats.lastUpdate).getTime();
const ageMs = Number.isFinite(time) ? Math.max(0, now - time) : null;
let state = !layer.enabled ? 'off' : layerFeedState(stats);
if (layer.enabled && layer.lifecycleState === 'enabling') state = 'loading';
const overdue = layer.enabled && DEADLINES[layer.id] && ageMs !== null && ageMs > DEADLINES[layer.id];
if (overdue && !['unavailable', 'off'].includes(state)) state = 'stale';
let kind = REFERENCE.has(layer.id) ? 'Reference data' : 'Provider snapshots';
if (layer.id === 'satellites') kind = 'Predicted position from orbital elements';
if (layer.id === 'traffic') kind = stats.mode === 'sim' ? 'Simulated movement' : 'Traffic flow; vehicle movement is visualized';
if (layer.id === 'cctv') kind = 'Provider images; delivery does not verify camera availability';
return { id: layer.id, enabled: Boolean(layer.enabled), state, kind, ageMs,
count: Number.isFinite(Number(stats.count)) ? Number(stats.count) : null,
observationTime: 'Not verified by this health check' };
}
export function createHealthJournal(limit = 200) {
const previous = new Map();
const events = [];
return {
sample(layers, now = Date.now()) {
const sources = layers.map(layer => sourceHealth(layer, now));
for (const source of sources) {
if (previous.get(source.id) !== source.state) {
events.push({ at: new Date(now).toISOString(), id: source.id, state: source.state });
previous.set(source.id, source.state);
}
}
if (events.length > limit) events.splice(0, events.length - limit);
// Whitelisted summaries only: no raw errors, URLs, coordinates, or keys.
return { version: 1, capturedAt: new Date(now).toISOString(), sources, events: events.map(e => ({ ...e })) };
},
};
}

25
src/sourceHealth.test.mjs Normal file
View File

@ -0,0 +1,25 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sourceHealth, createHealthJournal } from './sourceHealth.js';
test('stalled live receipts become stale without implying stale reference data', () => {
const layer = { id: 'flights', enabled: true, stats: { count: 3, lastUpdate: 1000 } };
assert.equal(sourceHealth(layer, 200000).state, 'stale');
assert.equal(sourceHealth({ ...layer, enabled: false }, 200000).state, 'off');
assert.equal(sourceHealth({ ...layer, id: 'local-dams' }, 200000).state, 'nominal');
assert.equal(sourceHealth({ ...layer, stats: { ...layer.stats, error: 'failed', status: 'offline' } }, 200000).state, 'unavailable');
});
test('recovery changes state and journal stays bounded without exporting sensitive payloads', () => {
const journal = createHealthJournal(2);
const layer = { id: 'flights', enabled: true, stats: { lastUpdate: 1000, url: 'secret', error: 'secret' } };
journal.sample([layer], 2000);
journal.sample([{ ...layer, stats: { lastUpdate: 1000 } }], 200000);
const result = journal.sample([{ ...layer, stats: { lastUpdate: 200000 } }], 200001);
assert.deepEqual(result.events.map(e => e.state), ['stale', 'nominal']);
assert.ok(!JSON.stringify(result).includes('secret'));
assert.equal(journal.sample([{ ...layer, stats: { lastUpdate: 200000 } }], 200002).events.length, 2);
});
test('simulation and prediction are identified and invalid dates remain unknown', () => {
assert.match(sourceHealth({ id: 'satellites' }).kind, /Predicted/);
assert.match(sourceHealth({ id: 'traffic', stats: { mode: 'sim' } }).kind, /Simulated/);
assert.equal(sourceHealth({ stats: { lastUpdate: 'bad' } }).ageMs, null);
});

166
src/sourceStatus.js Normal file
View File

@ -0,0 +1,166 @@
import { sourceHealth, createHealthJournal } from './sourceHealth.js';
const CADENCE = {
flights: '30s snapshots · motion interpolated with a delay',
military: '15s snapshots · motion interpolated',
earthquakes: '60s · detections can be delayed by USGS',
satellites: 'Orbits propagated continuously from published elements',
'rocket-launches': '5 min · mission metadata; ascent may be reconstructed',
'ais-live-vessels': '10s local snapshots of the live AIS stream',
traffic: 'Live flow with a key; simulated vehicles otherwise',
cctv: 'Active frames about every 10s · coverage varies by camera',
radio: 'Station stream when played; directory refreshed separately',
bikeshare: 'Station availability snapshots; operator cadence varies',
'military-installations': 'Mapped reference data; cached, not live activity',
'local-datacenters': 'Bundled reference data',
'local-dams': 'Bundled reference data',
'telegeography-submarine-cables': 'Bundled reference data',
'local-firms': 'Satellite fire detections, not continuous observation',
};
export function sourceStatusText(layer, now = Date.now()) {
const s = layer.stats || {};
if (layer.lifecycleState === 'enabling' || s.loading) return 'Connecting…';
if (!layer.enabled) return 'Off';
if (['zoom-in', 'empty', 'idle'].includes(s.status) && !s.stale) {
return s.error?.message || s.error || s.loadingLabel || 'No data in this view';
}
const error = s.managerRefreshError || s.error || s.lastError;
if (error) return `Unavailable / partial: ${error.message || String(error)}`;
if (s.refreshing) return 'Refreshing…';
if (!s.lastUpdate) return 'Enabled · waiting for source data';
const age = Math.max(0, Math.floor((now - new Date(s.lastUpdate).getTime()) / 1000));
if (!Number.isFinite(age)) return 'Enabled · source time unavailable';
const state = sourceHealth(layer, now).state;
const prefix = state === 'nominal' ? '' : `${state.toUpperCase()} · `;
const coverage = s.coverage ? ` · ${s.coverage}` : '';
return `${prefix}${s.count ?? 0} items · received ${age < 60 ? `${age}s` : `${Math.floor(age / 60)}m`} ago${coverage}`;
}
export function initSourceStatus({ dataManager }) {
const trigger = document.createElement('button');
trigger.id = 'source-status-button';
trigger.type = 'button';
trigger.textContent = '◉';
trigger.title = 'Live source status and free connections';
trigger.setAttribute('aria-label', 'Live source status and free connections');
document.getElementById('top-center-actions').append(trigger);
const dialog = document.createElement('dialog');
dialog.id = 'source-status-dialog';
dialog.setAttribute('aria-labelledby', 'source-status-title');
const heading = document.createElement('h2');
heading.id = 'source-status-title';
heading.textContent = 'Live sources & free connections';
const close = document.createElement('button');
close.textContent = 'Close';
close.addEventListener('click', () => dialog.close());
const intro = document.createElement('p');
intro.textContent = 'Automatic refresh is active for enabled layers. Receipt time below is when this app fetched data, not when the provider observed it.';
const refresh = document.createElement('button');
refresh.textContent = 'Refresh enabled sources';
const feedback = document.createElement('p');
feedback.setAttribute('role', 'status');
const rows = document.createElement('div');
const journal = createHealthJournal();
const healthSummary = document.createElement('p');
healthSummary.setAttribute('role', 'status');
const download = document.createElement('button');
download.textContent = 'Download health report';
const sampleHealth = () => {
const report = journal.sample(dataManager.getAll().filter(l => l.showInTogglePanel));
const attention = report.sources.filter(s => s.enabled && ['stale', 'degraded', 'unavailable', 'fallback'].includes(s.state)).length;
const offline = navigator.onLine === false;
const message = offline ? 'Network offline · cached data may remain visible' : attention ? `${attention} ${attention === 1 ? 'source needs' : 'sources need'} attention` : 'No reported feed faults';
trigger.textContent = attention || offline ? `${attention || '!'}` : '◉';
trigger.title = `${message} · open source health`;
trigger.setAttribute('aria-label', trigger.title);
healthSummary.textContent = `${message}. Checks use existing layer status; no extra provider requests. Observation freshness and camera availability require separate verification.`;
return { ...report, networkOnline: !offline };
};
download.addEventListener('click', () => {
const url = URL.createObjectURL(new Blob([JSON.stringify(sampleHealth(), null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'gods-eye-health.json';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
sampleHealth();
setInterval(sampleHealth, 5000);
const connections = document.createElement('div');
let config = null;
let busy = false;
let nextRefresh = 0;
const render = () => {
if (!dialog.open) return;
rows.replaceChildren();
for (const layer of dataManager.getAll().filter(l => l.showInTogglePanel)) {
const row = document.createElement('section');
const title = document.createElement('strong');
title.textContent = `${layer.name}${sourceStatusText(layer)}`;
const detail = document.createElement('p');
detail.textContent = `${sourceHealth(layer).kind}. ${CADENCE[layer.id] || 'Source cadence varies'}`;
row.append(title, detail);
rows.append(row);
}
const remaining = Math.max(0, Math.ceil((nextRefresh - Date.now()) / 1000));
refresh.disabled = busy || remaining > 0;
refresh.textContent = busy ? 'Refreshing…' : remaining ? `Refresh available in ${remaining}s` : 'Refresh enabled sources';
};
refresh.addEventListener('click', async () => {
if (busy || Date.now() < nextRefresh) return;
busy = true;
nextRefresh = Date.now() + 60_000;
render();
const enabled = dataManager.getAll().filter(l => l.enabled);
const results = await Promise.allSettled(enabled.map(l => dataManager.refreshLayer(l.id)));
const count = results.filter(r => r.status === 'fulfilled' && r.value).length;
feedback.textContent = enabled.length ? `${count} of ${enabled.length} sources refreshed. Provider caches and limits still apply.` : 'Enable a layer in Data Layers first.';
busy = false;
render();
});
const renderConnections = () => {
connections.replaceChildren();
const label = document.createElement('h3');
label.textContent = 'Optional free accounts';
connections.append(label);
for (const [key, name, env, url] of [
['ships', 'Live ships', 'AISSTREAM_API_KEY', 'https://aisstream.io/'],
['fires', 'Fire detections', 'FIRMS_MAP_KEY', 'https://firms.modaps.eosdis.nasa.gov/api/map_key/'],
['traffic', 'Real traffic flow', 'TOMTOM_API_KEY', 'https://docs.tomtom.com/pricing'],
]) {
const p = document.createElement('p');
const link = document.createElement('a');
link.textContent = name;
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
p.append(link, `${config ? config[key] ? 'key present; enable the layer to verify' : `add ${env}` : 'configuration unavailable'}`);
connections.append(p);
}
const help = document.createElement('p');
help.textContent = 'Put your own free-account keys in the local .env file and restart the launcher. For TomTom, use an account without paid overage; the local limit is 5,000 tiles/day in this setup. Never paste keys into chat.';
const paid = document.createElement('p');
paid.textContent = config?.freeOnly
? 'Free mode: Google photorealistic imagery and OpenAI voice/AI tools are disabled. OSM imagery is mapped data, not a live satellite image.'
: 'Google photorealistic imagery and OpenAI voice require separate metered services. OSM imagery is not a live satellite image.';
connections.append(help, paid);
};
dialog.append(heading, close, intro, healthSummary, download, refresh, feedback, rows, connections);
document.body.append(dialog);
let timer = null;
trigger.addEventListener('click', async () => {
if (dialog.open) return;
dialog.showModal();
render();
renderConnections();
timer = setInterval(render, 1000);
try {
const r = await fetch('/api/free-providers', { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error('Configuration unavailable');
config = await r.json();
} catch { config = null; }
if (dialog.open) renderConnections();
});
dialog.addEventListener('close', () => { clearInterval(timer); timer = null; });
}

View File

@ -2367,6 +2367,12 @@ export class StyleManager {
this._cctvSyncProgress = document.getElementById('cctv-sync-progress');
this._toast = document.getElementById('toast');
this._locationSearch = document.getElementById('location-search');
const searchKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env?.GOOGLE_MAPS_API_KEY;
this._keylessLocationSearch = !searchKey || searchKey === 'your_google_maps_api_key_here';
if (this._keylessLocationSearch && this._locationSearch) {
this._locationSearch.placeholder = 'Search cities, landmarks, addresses...';
this._locationSearch.title = 'Free place search via Photon / OpenStreetMap. Press Enter to search.';
}
this._searchToggle = document.getElementById('search-toggle');
this._locationPills = document.getElementById('location-pills');
this._poiRow = document.getElementById('poi-row');
@ -6358,8 +6364,12 @@ export class StyleManager {
return;
}
const kind = String(activeCamera.sourceKind || activeCamera.feedType || 'unknown').toUpperCase();
const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
const sourceStatus = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
// Providers can return an unavailable-image placeholder with HTTP 200.
// Successful transport does not establish that the camera itself is live.
const status = sourceStatus === 'OK' ? 'RECEIVED' : sourceStatus;
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
this._cctvSourceBadge.title = 'Image delivery status only. Providers may return an unavailable-image placeholder; check the picture for camera availability.';
this._cctvSourceBadge.dataset.frameState = 'ready';
}
@ -9331,7 +9341,9 @@ export class StyleManager {
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else {
this._showToast('Location not found');
this._showToast(this._keylessLocationSearch
? 'No matching place. Add the city or country and try again.'
: 'Location not found');
}
} catch (err) {
console.error('[Search] Geocoding failed:', err);

45
src/weatherRadar.js Normal file
View File

@ -0,0 +1,45 @@
import * as Cesium from 'cesium';
// One requested frame at a time keeps public tile usage bounded.
export function createWeatherRadar(viewer) {
const element=document.createElement('fieldset');
const heading=document.createElement('legend');heading.textContent='Precipitation radar';
const label=document.createElement('label'),toggle=document.createElement('input');
toggle.type='checkbox';toggle.checked=true;label.append(toggle,' Show radar');
const frames=document.createElement('select');frames.setAttribute('aria-label','Radar frame');
const opacity=document.createElement('input');opacity.type='range';opacity.min='0.2';opacity.max='1';opacity.step='.1';opacity.value='.7';opacity.setAttribute('aria-label','Radar opacity');
const status=document.createElement('p');
const info=document.createElement('p');info.textContent='Recent radar mosaic, not wind velocity. Coverage varies; blank areas may have no radar. Frame time can differ from observation time.';
const credit=document.createElement('a');credit.href='https://www.rainviewer.com';credit.target='_blank';credit.rel='noopener noreferrer';credit.textContent='Radar by RainViewer';
element.append(heading,label,frames,opacity,status,info,credit);
let active=false,layer=null,report=null,timer=null,controller=null,version=0;
function remove(){if(layer){viewer.imageryLayers.remove(layer,true);layer=null;}viewer.scene.requestRender();}
function show(){
remove();if(!active||!toggle.checked||!report)return;
const frame=report.frames[Number(frames.value)];if(!frame)return;
const age=Date.now()-frame.time*1000;
status.textContent=`${report.stale||age>1800000?'STALE · ':''}Frame ${new Date(frame.time*1000).toLocaleString()} · loading tiles`;
const provider=new Cesium.UrlTemplateImageryProvider({url:`${report.host}${frame.path}/256/{z}/{x}/{y}/2/1_1.png`,maximumLevel:7,tilingScheme:new Cesium.WebMercatorTilingScheme(),credit:'RainViewer'});
provider.errorEvent.addEventListener(()=>{if(layer?.imageryProvider===provider)status.textContent='Some radar tiles failed to load. Change frame or toggle radar to retry.';});
layer=viewer.imageryLayers.addImageryProvider(provider);layer.alpha=Number(opacity.value);
status.textContent=`${report.stale||age>1800000?'STALE · ':''}Frame ${new Date(frame.time*1000).toLocaleString()} · tiles requested`;
viewer.scene.requestRender();
}
async function refresh(){
controller?.abort();const token=++version;controller=new AbortController();
const timeout=setTimeout(()=>controller?.abort(),20000);
try{
status.textContent='Loading radar timeline…';
const response=await fetch('/api/live-views/radar',{signal:controller.signal});
if(!response.ok)throw new Error('Unavailable');
const next=await response.json();if(token!==version||!active)return;
report=next;frames.replaceChildren();
report.frames.forEach((f,i)=>{const o=document.createElement('option');o.value=String(i);o.textContent=new Date(f.time*1000).toLocaleTimeString()+(i===report.frames.length-1?' · latest':'');frames.append(o);});
frames.value=String(report.frames.length-1);show();
}catch{if(token===version&&active){remove();status.textContent='Radar unavailable · retries in five minutes. Weather conditions remain independent.';}}
finally{clearTimeout(timeout);if(token===version&&active&&toggle.checked)timer=setTimeout(refresh,300000);}
}
toggle.onchange=()=>{clearTimeout(timer);controller?.abort();version++;if(toggle.checked)refresh();else{remove();status.textContent='Radar off';}};
frames.onchange=show;opacity.oninput=()=>{if(layer)layer.alpha=Number(opacity.value);viewer.scene.requestRender();};
return {element,setActive(value){active=value;element.hidden=!value;clearTimeout(timer);controller?.abort();version++;remove();if(value&&toggle.checked)refresh();}};
}

8
src/worldPlaces.js Normal file
View File

@ -0,0 +1,8 @@
export const WORLD_PLACES = [
['Austin',30.2672,-97.7431],['Boston',42.3601,-71.0589],['Seattle',47.6062,-122.3321],
['New York',40.7128,-74.006],['London',51.5074,-.1278],['Paris',48.8566,2.3522],
['Cairo',30.0444,31.2357],['Lagos',6.5244,3.3792],['Cape Town',-33.9249,18.4241],
['Dubai',25.2048,55.2708],['Delhi',28.6139,77.209],['Singapore',1.3521,103.8198],
['Tokyo',35.6762,139.6503],['Sydney',-33.8688,151.2093],['Auckland',-36.8485,174.7633],
['São Paulo',-23.5505,-46.6333],['Buenos Aires',-34.6037,-58.3816],['Mexico City',19.4326,-99.1332],
].map(([name,lat,lon])=>({name,lat,lon}));

View File

@ -9164,6 +9164,53 @@ body.scene-playback-mode #first-run-launcher {
/* Scrolling a tile into view must not animate either. */
.first-run-choices { scroll-behavior: auto; }
}
/* Source health is on demand; no extra polling or panel work while closed. */
#source-status-dialog {
margin: auto;
box-sizing: border-box;
width: min(680px, calc(100vw - 40px));
max-height: 80vh;
overflow: auto;
color: #d8f4f3;
background: #0b171f;
border: 1px solid #46777d;
border-radius: 14px;
padding: 24px;
font: 14px/1.5 system-ui, sans-serif;
}
#source-status-dialog::backdrop { background: #000b; }
#source-status-dialog h2 { font-size: 22px; margin: 0 0 12px; }
#source-status-dialog section { padding: 10px 0; border-bottom: 1px solid #29414b; }
#source-status-dialog p { margin: 6px 0 14px; color: #b5ced3; }
#source-status-dialog section p { margin: 4px 0 0; font-size: 12px; }
#source-status-dialog a { color: #86e5e8; }
#source-status-dialog button { padding: 8px 14px; margin: 0 8px 10px 0; border: 1px solid #568d97; border-radius: 6px; color: #e1ffff; background: #163b46; cursor: pointer; }
#source-status-dialog button:disabled { opacity: .55; cursor: wait; }
#camera-browser-dialog h2 { margin: 0 0 12px; font-size: 24px; }
#camera-browser-dialog p { margin: 12px 0; line-height: 1.5; }
#camera-browser-dialog button, #camera-browser-dialog select {
font: inherit; color: #e1edf2; background: #19323e;
border: 1px solid #547783; border-radius: 6px; padding: 8px 12px;
margin: 4px 8px 4px 0;
}
#camera-browser-dialog button { cursor: pointer; }
#camera-browser-dialog button:disabled { opacity: .5; cursor: default; }
#camera-browser-dialog::backdrop { background: rgba(0,0,0,.55); }
#live-views-panel { position:fixed;z-index:10000;right:16px;top:85px;width:min(390px,90vw);max-height:78vh;overflow:auto;padding:20px;background:#0b1922;color:#e1edf2;border:1px solid #547783;border-radius:12px;font:14px/1.5 sans-serif; }
#live-views-panel h2 { font-size:22px;margin:0 0 12px; }
#live-views-panel button,#live-views-panel select,#live-views-panel input { color:#e1edf2;background:#19323e;border:1px solid #547783;border-radius:6px;padding:8px;margin:4px;max-width:100%;font:inherit; }
#live-views-panel button { cursor:pointer; }
#live-views-panel section { border-top:1px solid #34505c;padding:8px 0; }
#live-views-panel p { margin:8px 0; }
#live-views-panel fieldset { border:1px solid #39526e; border-radius:10px; margin:12px 0; padding:12px; background:#101c30; }
#live-views-panel fieldset legend { color:#8fd9ff; font-weight:600; }
#live-views-panel fieldset label { display:flex; align-items:center; gap:8px; }
#live-views-panel fieldset input[type=checkbox] { width:auto; }
#live-views-panel fieldset input[type=range] { width:100%; }
#live-views-panel a { color:#8fd9ff; }
#live-views-panel > div > section { border:1px solid #2e4158; border-radius:9px; padding:10px; margin:8px 0; background:#101a28; }
#live-views-panel [hidden] { display:none!important; }
/* ============================================================
POWER UP in-app key setup (dev server only, src/keySetup.js)
@ -9520,3 +9567,25 @@ body.scene-playback-mode #key-setup {
border-color: rgba(255, 170, 150, 0.65);
outline: none;
}
#situation-desk { position:fixed; z-index:190; right:16px; top:76px; width:min(510px,calc(100vw - 32px)); max-height:calc(100dvh - 110px); overflow:auto; box-sizing:border-box; padding:22px; border:1px solid #426579; border-radius:16px; color:#e5f3f5; background:linear-gradient(140deg,#102333f5,#08131cfb); box-shadow:0 20px 70px #0009; font:14px/1.55 system-ui,sans-serif; }
#situation-desk[hidden] {display:none}
#situation-desk h2 {font-size:26px;margin:0;letter-spacing:-.5px}
#situation-desk header>button {float:right}
#situation-desk header>p {color:#64d9d2;font-size:10px;letter-spacing:2px}
#situation-desk button,#situation-desk select,#situation-desk input,#situation-desk textarea,.gev-credits button {font:inherit;background:#172e40;color:#e4f4f7;border:1px solid #3d6176;border-radius:7px;padding:8px;margin:3px;box-sizing:border-box;max-width:100%}
#situation-desk button {cursor:pointer}
#situation-desk button[aria-pressed=true] {background:#295c66;border-color:#70d6cc}
#situation-desk button:disabled{opacity:.5}
#situation-desk nav {display:flex;flex-wrap:wrap;margin-top:12px;border-bottom:1px solid #34505c;padding-bottom:10px}
#situation-desk input,#situation-desk textarea{width:100%}
#situation-desk article{padding:15px 0;border-bottom:1px solid #294454}
#situation-desk article>a {font-size:16px;font-weight:600;color:#e3f6ff;text-decoration:none}
#situation-desk article>a:hover{text-decoration:underline}
#situation-desk article p,#situation-desk footer {font-size:11px;color:#9bb7c5}
#situation-desk footer {margin-top:20px}
.situation-disclaimer {padding:10px;background:#193442;border-left:3px solid #6bc8bf;font-size:12px}
.situation-brief {white-space:pre-wrap;overflow-wrap:anywhere}
.gev-credits {max-width:560px;color:#e5f3f5;background:#102333;border:1px solid #426579;border-radius:16px;padding:25px;font:15px/1.6 system-ui}
.gev-credits a{color:#71dad5}
.gev-credits::backdrop{background:#0009}

View File

@ -1,3 +1,4 @@
import { situationNewsPlugin } from './server/situationNews.js';
/**
* Vite configuration for God's Eye View a cinematic geospatial app.
*
@ -26,6 +27,9 @@
*/
import fs from 'node:fs';
import { liveViewsPlugin } from './server/liveViews.js';
import { loadWashingtonCameras, balanceCameraCities, publicVideoUrl } from './server/cameraExpansion.js';
import { freeServicesPlugin } from './server/freeServices.js';
import os from 'node:os';
import { promises as fsp } from 'node:fs';
import { spawnSync } from 'node:child_process';
@ -3529,7 +3533,7 @@ const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
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). */
const DEFAULT_CALTRANS_DISTRICTS = '4,7,11,3';
const DEFAULT_CALTRANS_DISTRICTS = '1,2,3,4,5,6,7,8,9,10,11,12';
const DEFAULT_CALTRANS_MAX_SOURCES = 300;
/** Prioritization anchors: downtown cores of the four default metros. */
const CALTRANS_ANCHORS = [
@ -4054,6 +4058,7 @@ async function loadCaltransSourcesFromOpenData() {
city: String(loc.nearbyPlace || `Caltrans D${district}`),
cityId: `ca-d${district}`,
provider: 'Caltrans',
liveVideoUrl: publicVideoUrl(cctv.imageData?.streamingVideoURL),
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
@ -4087,7 +4092,7 @@ async function loadCaltransSourcesFromOpenData() {
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);
const prioritized = balanceCameraCities(cameras, maxCount, source => source.cityId);
console.log(`[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`);
return prioritized;
}
@ -4193,6 +4198,7 @@ function normalizeSourceItem(item) {
feedType: normalizeFeedType(item.feedType || item.type || ''),
url: typeof item.url === 'string' ? item.url : '',
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
liveVideoUrl: publicVideoUrl(item.liveVideoUrl),
license: String(item.license || item.licenseNote || ''),
sourceKind: String(item.sourceKind || item.kind || 'configured'),
// Optional CAL badge input (cctv-v2 design §3b/§9.2, additive-only per the
@ -4248,18 +4254,21 @@ async function refreshCctvSources() {
let fromAustin = [];
let fromCaltrans = [];
let fromTfl = [];
let fromWashington = [];
if (needsLiveSources) {
const [austinResult, caltransResult, tflResult] = await Promise.allSettled([
const [austinResult, caltransResult, tflResult, washingtonResult] = await Promise.allSettled([
loadAustinSourcesFromOpenData(),
loadCaltransSourcesFromOpenData(),
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
loadWashingtonCameras(),
]);
fromAustin = austinResult.status === 'fulfilled' ? austinResult.value : [];
fromCaltrans = caltransResult.status === 'fulfilled' ? caltransResult.value : [];
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
fromWashington = washingtonResult.status === 'fulfilled' ? washingtonResult.value : [];
}
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv];
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromWashington, ...fromFile, ...fromEnv];
// Deduplicate by camera ID (last-write wins because of Map.set)
const byId = new Map();
@ -4276,7 +4285,7 @@ async function refreshCctvSources() {
if (mergedSources.length > maxCount) {
console.warn(`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`);
}
const capped = mergedSources.length > maxCount ? mergedSources.slice(0, maxCount) : mergedSources;
const capped = mergedSources.length > maxCount ? balanceCameraCities(mergedSources, maxCount) : mergedSources;
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
} else {
@ -4621,6 +4630,7 @@ function cctvProxy() {
sourceKind: source.sourceKind || (source.url ? 'configured' : 'fallback'),
poseSource: source.poseSource,
license: source.license,
liveVideoUrl: source.liveVideoUrl || '',
})),
};
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
@ -7735,10 +7745,19 @@ export default defineConfig(({ mode }) => {
for (const [key, val] of Object.entries(loaded)) {
if (process.env[key] === undefined) process.env[key] = val;
}
// Explicit free mode suppresses metered Google/OpenAI credentials even when
// inherited from a shell. Optional free-account keys remain server-side.
if (process.env.GEV_FREE_ONLY === '1') {
process.env.GOOGLE_MAPS_API_KEY = '';
process.env.OPENAI_API_KEY = '';
}
const env = { ...process.env };
const localAllowedHosts = ['localhost', '127.0.0.1', '.local'];
return {
plugins: [
freeServicesPlugin({ fetchJson: fetchRegionalJson }),
liveViewsPlugin({ fetchJson: fetchRegionalJson }),
situationNewsPlugin({ fetchText: fetchRegionalText, parseArticles: normalizeRssArticles }),
cesium(),
openSkyProxy(),
celestrakProxy(),
@ -7786,6 +7805,7 @@ export default defineConfig(({ mode }) => {
},
// Expose selected API keys to the browser via import.meta.env.*
define: {
'import.meta.env.GEV_FREE_ONLY': JSON.stringify(env.GEV_FREE_ONLY === '1'),
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(env.GOOGLE_MAPS_API_KEY),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(env.CESIUM_ION_TOKEN),
},