diff --git a/src/cameraBrowser.js b/src/cameraBrowser.js
new file mode 100644
index 0000000..0a8de2b
--- /dev/null
+++ b/src/cameraBrowser.js
@@ -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(); });
+}
diff --git a/src/creatorCredits.js b/src/creatorCredits.js
new file mode 100644
index 0000000..91ad656
--- /dev/null
+++ b/src/creatorCredits.js
@@ -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="God’s 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();
+}
diff --git a/src/freeGeocode.js b/src/freeGeocode.js
new file mode 100644
index 0000000..168e1ea
--- /dev/null
+++ b/src/freeGeocode.js
@@ -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;
+}
diff --git a/src/freeServices.test.mjs b/src/freeServices.test.mjs
new file mode 100644
index 0000000..ab03be1
--- /dev/null
+++ b/src/freeServices.test.mjs
@@ -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/);
+});
diff --git a/src/hud.js b/src/hud.js
index 74b9c04..7739d1f 100644
--- a/src/hud.js
+++ b/src/hud.js
@@ -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;
diff --git a/src/liveViewPreferences.js b/src/liveViewPreferences.js
new file mode 100644
index 0000000..3f4222e
--- /dev/null
+++ b/src/liveViewPreferences.js
@@ -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';
+}
diff --git a/src/liveViewPreferences.test.mjs b/src/liveViewPreferences.test.mjs
new file mode 100644
index 0000000..219f5ff
--- /dev/null
+++ b/src/liveViewPreferences.test.mjs
@@ -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');
+});
diff --git a/src/liveViews.js b/src/liveViews.js
new file mode 100644
index 0000000..bdf282d
--- /dev/null
+++ b/src/liveViews.js
@@ -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(``);
+ 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();};
+}
diff --git a/src/locations.js b/src/locations.js
index 36a1226..f8fbf38 100644
--- a/src/locations.js
+++ b/src/locations.js
@@ -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;
diff --git a/src/locations.test.mjs b/src/locations.test.mjs
index 7b8244c..289adb9 100644
--- a/src/locations.test.mjs
+++ b/src/locations.test.mjs
@@ -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;
diff --git a/src/main.js b/src/main.js
index 84fbf12..5374bbd 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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);
diff --git a/src/situationDesk.js b/src/situationDesk.js
new file mode 100644
index 0000000..9a30f65
--- /dev/null
+++ b/src/situationDesk.js
@@ -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();
+}
diff --git a/src/situationModel.js b/src/situationModel.js
new file mode 100644
index 0000000..c2ffa13
--- /dev/null
+++ b/src/situationModel.js
@@ -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||timeDate.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);
+}
diff --git a/src/situationModel.test.mjs b/src/situationModel.test.mjs
new file mode 100644
index 0000000..7df45cc
--- /dev/null
+++ b/src/situationModel.test.mjs
@@ -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);
+});
diff --git a/src/sourceHealth.js b/src/sourceHealth.js
new file mode 100644
index 0000000..6e5b2fd
--- /dev/null
+++ b/src/sourceHealth.js
@@ -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 })) };
+ },
+ };
+}
diff --git a/src/sourceHealth.test.mjs b/src/sourceHealth.test.mjs
new file mode 100644
index 0000000..72e6e6f
--- /dev/null
+++ b/src/sourceHealth.test.mjs
@@ -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);
+});
diff --git a/src/sourceStatus.js b/src/sourceStatus.js
new file mode 100644
index 0000000..75d3b50
--- /dev/null
+++ b/src/sourceStatus.js
@@ -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; });
+}
diff --git a/src/ui.js b/src/ui.js
index 668e752..796438a 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -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);
diff --git a/src/weatherRadar.js b/src/weatherRadar.js
new file mode 100644
index 0000000..a31ea5e
--- /dev/null
+++ b/src/weatherRadar.js
@@ -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();}};
+}
diff --git a/src/worldPlaces.js b/src/worldPlaces.js
new file mode 100644
index 0000000..88a472b
--- /dev/null
+++ b/src/worldPlaces.js
@@ -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}));