Compare commits
4 Commits
bf4d1d16de
...
0bbdb30719
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bbdb30719 | ||
|
|
1e5a241f05 | ||
|
|
0162ec0cdf | ||
|
|
b56684c6e6 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,3 +3,8 @@
|
||||
__pycache__/
|
||||
# Local recorder database (record.py) — dev-only, never committed or deployed.
|
||||
data/
|
||||
# Secrets — API credentials must never be committed or deployed (server-side only).
|
||||
openskycredentials.json
|
||||
*credentials*.json
|
||||
*.secret
|
||||
.env
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GODSIGH ▸ World View</title>
|
||||
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/';</script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Widgets/widgets.css" />
|
||||
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/';</script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Widgets/widgets.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Cesium.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5/dist/satellite.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/cesium@1.143.0/Build/Cesium/Cesium.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5.0.0/dist/satellite.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="cesiumContainer"></div>
|
||||
|
||||
11
js/config.js
11
js/config.js
@ -88,6 +88,15 @@ export const CONFIG = {
|
||||
cap: 500,
|
||||
},
|
||||
|
||||
// Military aircraft — ADS-B Exchange /v2/mil/ (REAL unfiltered military, the
|
||||
// OSINT prize OpenSky hides). Paid RapidAPI feed; the same-origin proxy injects
|
||||
// the key server-side AND 5-min-caches it, so poll cadence == cache TTL keeps
|
||||
// the ~10k/month quota safe (~8.6k/month worst case).
|
||||
adsbx: {
|
||||
proxyMil: 'proxy/adsbx-mil', // RELATIVE — works at "/" and "/godsigh/"
|
||||
pollMs: 300_000, // 5 min
|
||||
},
|
||||
|
||||
// Optional live AIS. Leave blank to use the demo fleet only (roadmap feature).
|
||||
AISSTREAM_API_KEY: '',
|
||||
|
||||
@ -108,6 +117,8 @@ export const CONFIG = {
|
||||
aircraftMid: '#ffe74d',
|
||||
aircraftHigh: '#4dd2ff',
|
||||
aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band)
|
||||
militaryReal: '#ff453a', // REAL military (ADS-B Exchange feed)
|
||||
militaryEmerg: '#ff2d95', // military squawking 7500/7600/7700 or emergency
|
||||
// earthquakes (magnitude ramp: amber → red)
|
||||
quakeMin: '#ffcf50',
|
||||
quakeMax: '#ff3b30',
|
||||
|
||||
@ -59,7 +59,7 @@ export default function create(ctx) {
|
||||
if (!loc) continue;
|
||||
const title = ev.title || 'Wildfire';
|
||||
const category = (ev.categories && ev.categories[0] && ev.categories[0].title) || 'Wildfires';
|
||||
const link = ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${ev.id}` : null);
|
||||
const link = lib.safeUrl(ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(ev.id)}` : ''));
|
||||
const dateTxt = loc.date ? new Date(loc.date).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—';
|
||||
|
||||
ds.entities.add({
|
||||
@ -88,11 +88,11 @@ export default function create(ctx) {
|
||||
},
|
||||
description:
|
||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||
`<tr><th>Event</th><td>${title}</td></tr>` +
|
||||
`<tr><th>Category</th><td>${category}</td></tr>` +
|
||||
`<tr><th>Event</th><td>${lib.escapeHtml(title)}</td></tr>` +
|
||||
`<tr><th>Category</th><td>${lib.escapeHtml(category)}</td></tr>` +
|
||||
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
|
||||
`</tbody></table>` +
|
||||
(link ? `<p><a href="${link}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
|
||||
(link ? `<p><a href="${lib.escapeHtml(link)}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
|
||||
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`,
|
||||
});
|
||||
count++;
|
||||
|
||||
138
js/layers/military.js
Normal file
138
js/layers/military.js
Normal file
@ -0,0 +1,138 @@
|
||||
// Military aircraft (REAL) — ADS-B Exchange /v2/mil/ feed via the paid RapidAPI
|
||||
// proxy. This is the unfiltered military traffic OpenSky/FR24 hide (C-17 Reach
|
||||
// flights, tankers, the Area 51 "Janet" shuttle, etc.) — the OSINT prize.
|
||||
//
|
||||
// BillboardCollection primitive like the civilian aircraft layer, but a distinct
|
||||
// red so it never blends in. Live-only (no history feed for this), polled every
|
||||
// 5 min to match the server-side cache and respect the ~10k/month quota.
|
||||
|
||||
export default function create(ctx) {
|
||||
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
|
||||
const A = CONFIG.adsbx;
|
||||
|
||||
const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection());
|
||||
const glyph = lib.aircraftGlyph(); // shared triangle, tinted per-billboard
|
||||
|
||||
let enabled = true;
|
||||
let isLive = true;
|
||||
let inFlight = false;
|
||||
let timer = null;
|
||||
let delay = A.pollMs;
|
||||
|
||||
ui.addLayer('military', 'Military (ADS-B Exchange)', true, (on) => {
|
||||
enabled = on;
|
||||
bc.show = on && isLive;
|
||||
if (on && isLive) kick();
|
||||
});
|
||||
ui.setStatus('military', 'loading…', 'warn');
|
||||
|
||||
// Transponder emergency codes → the highlight label.
|
||||
const EMERG = { '7500': 'HIJACK', '7600': 'RADIO FAIL', '7700': 'EMERGENCY' };
|
||||
|
||||
function canPoll() { return enabled && isLive && !document.hidden; }
|
||||
function schedule(ms) { if (timer) clearTimeout(timer); timer = setTimeout(tick, ms); }
|
||||
function kick() { if (!canPoll() || timer || inFlight) return; schedule(0); }
|
||||
|
||||
async function tick() {
|
||||
timer = null;
|
||||
if (!canPoll() || inFlight) return;
|
||||
inFlight = true;
|
||||
try { await poll(); } finally { inFlight = false; }
|
||||
if (canPoll()) schedule(delay);
|
||||
}
|
||||
|
||||
// ADS-B Exchange altitude is in FEET (and "ground" for on-ground aircraft).
|
||||
function altFeet(a) { return typeof a.alt_baro === 'number' ? a.alt_baro : 0; }
|
||||
|
||||
async function poll() {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(A.proxyMil);
|
||||
} catch (err) {
|
||||
ui.setStatus('military', 'network error — retrying', 'warn');
|
||||
return;
|
||||
}
|
||||
if (res.status === 503) {
|
||||
// Key not configured (e.g. prod before the key is installed) — degrade quietly.
|
||||
bc.removeAll();
|
||||
ui.setStatus('military', 'ADS-B Exchange not configured', 'warn');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
ui.setStatus('military', `HTTP ${res.status} — retrying`, 'warn');
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (err) {
|
||||
ui.setStatus('military', 'bad response — retrying', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const list = Array.isArray(data && data.ac) ? data.ac : [];
|
||||
bc.removeAll();
|
||||
let count = 0;
|
||||
let emergencies = 0;
|
||||
for (const a of list) {
|
||||
if (a.lat == null || a.lon == null) continue;
|
||||
const sq = a.squawk;
|
||||
const isEmerg = !!EMERG[sq] || (a.emergency && a.emergency !== 'none');
|
||||
if (isEmerg) emergencies++;
|
||||
bc.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048),
|
||||
image: glyph,
|
||||
color: lib.cz(isEmerg ? CONFIG.colors.militaryEmerg : CONFIG.colors.militaryReal),
|
||||
rotation: lib.headingToRotation(a.track), // lib negates — track may be absent (→0)
|
||||
scale: isEmerg ? 1.05 : 0.82, // bolder than civilian; emergencies bigger still
|
||||
id: {
|
||||
layer: 'military',
|
||||
hex: a.hex,
|
||||
callsign: (a.flight || '').trim(),
|
||||
type: a.t,
|
||||
reg: a.r,
|
||||
altFt: altFeet(a),
|
||||
gs: a.gs,
|
||||
track: a.track,
|
||||
squawk: sq,
|
||||
emerg: isEmerg ? (EMERG[sq] || String(a.emergency).toUpperCase()) : null,
|
||||
},
|
||||
});
|
||||
count++;
|
||||
}
|
||||
|
||||
delay = A.pollMs;
|
||||
const stamp = data.now ? new Date(data.now).toLocaleTimeString() : new Date().toLocaleTimeString();
|
||||
const em = emergencies ? ` · ${emergencies}⚠` : '';
|
||||
ui.setStatus('military', `${count} military · ${stamp}${em}`, emergencies ? 'err' : 'ok');
|
||||
}
|
||||
|
||||
function onClockTick(currentTime, live) {
|
||||
isLive = live;
|
||||
if (!isLive) { bc.show = false; return; } // live-only feed; pause when scrubbed
|
||||
bc.show = enabled;
|
||||
if (enabled) kick();
|
||||
}
|
||||
|
||||
function handlePick(picked) {
|
||||
if (picked?.id?.layer !== 'military') return false;
|
||||
const id = picked.id;
|
||||
const rows = [
|
||||
['Type', id.type || '—'],
|
||||
['Reg', id.reg || '—'],
|
||||
['Altitude', `${Math.round(id.altFt || 0)} ft`],
|
||||
['Speed', `${Math.round(id.gs || 0)} kt`],
|
||||
['Squawk', id.squawk || '—'],
|
||||
];
|
||||
if (id.emerg) rows.unshift(['⚠ Status', id.emerg]);
|
||||
ui.showPickOverlay('✈ MIL ' + (id.callsign || id.hex || '—'), rows);
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearPick() { ui.hidePickOverlay(); }
|
||||
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
|
||||
kick();
|
||||
|
||||
return { id: 'military', onClockTick, handlePick, clearPick };
|
||||
}
|
||||
@ -64,7 +64,8 @@ export default function create(ctx) {
|
||||
const depth = coords[2];
|
||||
const mag = p.mag;
|
||||
const timeMs = p.time;
|
||||
const place = p.place || 'Unknown location';
|
||||
const place = p.place || 'Unknown location'; // raw: used in name/label (text contexts)
|
||||
const url = lib.safeUrl(p.url);
|
||||
const size = baseSize(mag);
|
||||
const color = magColor(mag);
|
||||
const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000;
|
||||
@ -93,11 +94,11 @@ export default function create(ctx) {
|
||||
description:
|
||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||
`<tr><th>Magnitude</th><td>M${mag.toFixed(1)}</td></tr>` +
|
||||
`<tr><th>Place</th><td>${place}</td></tr>` +
|
||||
`<tr><th>Place</th><td>${lib.escapeHtml(place)}</td></tr>` +
|
||||
`<tr><th>Depth</th><td>${depth != null ? depth.toFixed(1) + ' km' : '—'}</td></tr>` +
|
||||
`<tr><th>Time (UTC)</th><td>${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'}</td></tr>` +
|
||||
`</tbody></table>` +
|
||||
(p.url ? `<p><a href="${p.url}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
|
||||
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
|
||||
});
|
||||
|
||||
// Labels only for the notable quakes, to keep the globe legible.
|
||||
|
||||
18
js/lib.js
18
js/lib.js
@ -77,6 +77,24 @@ export function altitudeColor(CONFIG, altMeters) {
|
||||
return c.aircraftHigh;
|
||||
}
|
||||
|
||||
// Escape a string for safe interpolation into InfoBox description HTML.
|
||||
// Feeds like USGS/EONET are trusted, but this is defense-in-depth for the
|
||||
// public deploy (and the InfoBox is a real HTML sink).
|
||||
export function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Only allow http(s) URLs into href attributes (blocks javascript:/data: URLs).
|
||||
export function safeUrl(u) {
|
||||
const s = String(u == null ? '' : u).trim();
|
||||
return /^https?:\/\//i.test(s) ? s : '';
|
||||
}
|
||||
|
||||
// CSS hex → Cesium.Color, optional alpha.
|
||||
export function cz(hex, alpha) {
|
||||
const col = Cesium.Color.fromCssColorString(hex);
|
||||
|
||||
@ -172,6 +172,7 @@ const LAYER_MODULES = [
|
||||
'./layers/fires.js',
|
||||
'./layers/ships.js',
|
||||
'./layers/aircraft.js',
|
||||
'./layers/military.js',
|
||||
];
|
||||
const activeLayers = [];
|
||||
|
||||
|
||||
46
serve.py
46
serve.py
@ -42,6 +42,22 @@ OPENSKY_CACHE_FRESH_SEC = 120
|
||||
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
|
||||
HISTORY_TOLERANCE_SEC = 600
|
||||
|
||||
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
|
||||
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
|
||||
# HARD-caches the /v2/mil/ response for 5 min — the quota guard: even with many
|
||||
# open tabs, at most ~288 upstream calls/day (~8.6k/month) are spent.
|
||||
ADSBX_CREDS = Path(__file__).resolve().parent / "adsbxcredentials.json"
|
||||
ADSBX_CACHE_SEC = 300
|
||||
_adsbx_cache = {"body": None, "ts": 0.0}
|
||||
|
||||
|
||||
def load_adsbx_creds():
|
||||
try:
|
||||
d = json.loads(ADSBX_CREDS.read_text())
|
||||
return d.get("host"), d.get("key")
|
||||
except (OSError, ValueError):
|
||||
return None, None
|
||||
|
||||
|
||||
def opensky_cache_file():
|
||||
override = os.environ.get("OPENSKY_CACHE_FILE")
|
||||
@ -110,6 +126,8 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
if not self.path.startswith("/proxy/"):
|
||||
return super().do_GET()
|
||||
name, _, query = self.path[len("/proxy/"):].partition("?")
|
||||
if name == "adsbx-mil":
|
||||
return self._serve_adsbx_mil()
|
||||
base = UPSTREAMS.get(name)
|
||||
if not base:
|
||||
return self.send_error(404, f"unknown upstream {name!r}")
|
||||
@ -205,6 +223,34 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
out.write_bytes(png)
|
||||
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
|
||||
|
||||
def _serve_adsbx_mil(self):
|
||||
host, key = load_adsbx_creds()
|
||||
if not host or not key:
|
||||
# Not configured (e.g. no key file) — the layer degrades gracefully.
|
||||
return self._send_json(503, {"error": "ADS-B Exchange not configured"})
|
||||
now = time.time()
|
||||
if _adsbx_cache["body"] is not None and now - _adsbx_cache["ts"] < ADSBX_CACHE_SEC:
|
||||
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "hit"})
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"https://{host}/v2/mil/",
|
||||
headers={"x-rapidapi-host": host, "x-rapidapi-key": key,
|
||||
"User-Agent": "godsigh-dev/0.1"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
body = r.read()
|
||||
_adsbx_cache["body"] = body
|
||||
_adsbx_cache["ts"] = now
|
||||
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
|
||||
except urllib.error.HTTPError as e:
|
||||
if _adsbx_cache["body"] is not None:
|
||||
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(e.code, {"error": f"adsbx upstream {e.code}"})
|
||||
except Exception as e:
|
||||
if _adsbx_cache["body"] is not None:
|
||||
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(502, {"error": str(e)})
|
||||
|
||||
def _send_json(self, status, obj, extra=None):
|
||||
self._send(status, "application/json", json.dumps(obj).encode(), extra)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user