const API = "/api";
// The globe surface's violet tint (see onGlobeReady below) — reused as the
// civilian-flight color so those icons read as "part of the globe" rather
// than a separate alert layer, per the flights-overlay color spec.
const GLOBE_TINT_HEX = 0x6a3fd9;
const MILITARY_FLIGHT_HEX = 0xff33ff;
// News icons used to gradient violet->pink by corroborating-source count;
// flattened to a single bright magenta so they stay legible against the
// (also violet) globe tint — size still scales with source/article count.
const NEWS_ICON_HEX = 0xff33ff;
// Must match INCIDENT_MERGE_HOURS in backend/app/markets.py — display label only.
const INCIDENT_MERGE_HOURS_LABEL = "1h";
// Categorical palette for the multi-instrument overlay charts — the
// dataviz skill's validated 8-hue set (dark-surface steps), reused as-is
// rather than forced into the site's monochrome violet/magenta theme: a
// genuine multi-series comparison chart needs real hue separation, which a
// single-hue-family theme can't provide. Order is the CVD-safety mechanism
// (see the skill's palette.md) — never reorder or cycle it per-render.
const OVERLAY_PALETTE = ["#3987e5", "#008300", "#d55181", "#c98500", "#199e70", "#d95926", "#9085e9", "#e66767"];
const OVERLAY_SHORT_LABELS = {
"^GSPC": "S&P", "^DJI": "DJI", "^IXIC": "NASDAQ", "^FTSE": "FTSE",
"^GDAXI": "DAX", "^FCHI": "CAC", "^N225": "N225", "^HSI": "HSI",
"000001.SS": "SSE", "^BSESN": "SENSEX", "IMOEX.ME": "MOEX", "^BVSP": "BOVESPA",
"CL=F": "WTI", "BZ=F": "BRENT",
};
let overlaySymbolStyle = {}; // symbol -> {color, dashed} — assigned once, stable across renders
// Beyond 8 series, colors repeat with a dashed stroke as a secondary
// channel (still per-entity, still fixed order) rather than cycling hue.
function assignOverlaySymbolStyles(symbols) {
overlaySymbolStyle = {};
symbols.forEach((sym, i) => {
overlaySymbolStyle[sym] = { color: OVERLAY_PALETTE[i % OVERLAY_PALETTE.length], dashed: i >= OVERLAY_PALETTE.length };
});
}
let config = { weather_enabled: false, conflict_enabled: false };
let clusters = []; // raw, server-side ~11km-grid clusters
let displayedClusters = []; // zoom-adaptive regrouping of `clusters`, currently on screen
let conflictEvents = [];
let clickRings = [];
let weatherMeshes = {}; // layer -> THREE.Mesh (built lazily, cached)
let activeWeatherLayer = null;
let currentAltitude = 2.2;
// ---------------------------------------------------------------- globe --
const globe = Globe()(document.getElementById("globeViz"))
.globeImageUrl("https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg")
.bumpImageUrl("https://unpkg.com/three-globe/example/img/earth-topology.png")
.backgroundColor("#0f0d16")
.objectLat("lat")
.objectLng("lon")
.objectAltitude(0.014)
.objectThreeObject(buildGlobeObject)
.objectLabel(objectLabelFor)
.onObjectClick(onGlobeObjectClick)
.onZoom(({ altitude }) => {
currentAltitude = altitude;
scheduleRegroup();
})
.ringLat("lat")
.ringLng("lon")
.ringColor((d) => (t) =>
d.kind === "conflict" ? `rgba(245,5,5,${1 - t})` : `rgba(228,0,70,${1 - t})`
)
.ringMaxRadius((d) => d.maxR)
.ringPropagationSpeed(3)
.ringRepeatPeriod((d) => d.repeatPeriod);
// Degrees-of-arc -> scene units, so icon sprite sizes can be specified in
// the same "degrees" unit pointRadiusFor/declutter already use, and stay
// visually consistent with them.
const sceneUnitsPerDeg = (2 * Math.PI * globe.getGlobeRadius()) / 360;
const HELMET_ICON_DEG = 1.6;
globe.pointOfView({ lat: 20, lng: 20, altitude: 2.2 }, 0);
// Tint the globe surface (blue-marble texture) into the theme's violet
// range by multiplying it against the material's diffuse color, rather
// than a CSS filter on the whole canvas — that would also hue-shift the
// point/ring accent colors set below, which need to stay true to the
// palette. Must wait for onGlobeReady: three-globe (re)builds the mesh's
// material once the texture has loaded, which would otherwise orphan a
// tint applied immediately after construction.
globe.onGlobeReady(() => {
const globeMaterial = globe.globeMaterial();
globeMaterial.color = new THREE.Color(GLOBE_TINT_HEX);
globeMaterial.emissive = new THREE.Color(0x1a0e33);
globeMaterial.emissiveIntensity = 0.25;
});
// National border overlay: transparent fill (so it doesn't obscure the globe
// texture or news points) with a bright themed stroke. Natural Earth's
// admin-0 country polygons, served as a static asset from three-globe's own
// npm package via unpkg.
globe
.polygonAltitude(0.001)
.polygonCapColor(() => "rgba(0,0,0,0)")
.polygonSideColor(() => "rgba(0,0,0,0)")
.polygonStrokeColor(() => "#c9a6ff")
.polygonLabel((d) => escapeHtml(d.properties.ADMIN || d.properties.NAME || ""));
fetch("https://unpkg.com/three-globe@2.32.0/example/country-polygons/ne_110m_admin_0_countries.geojson")
.then((res) => res.json())
.then((geo) => globe.polygonsData(geo.features))
.catch((e) => console.warn("Could not load country borders", e));
function resizeGlobe() {
const el = document.getElementById("globeViz");
globe.width(el.clientWidth).height(el.clientHeight);
}
window.addEventListener("resize", resizeGlobe);
setTimeout(resizeGlobe, 0);
// ---- globe icon sprites (news clusters + conflict events) --------------
//
// Both are white-on-transparent alpha-mask canvases so a SpriteMaterial's
// `color` can tint them per-datum (news: the violet->pink gradient above;
// conflict: magenta) without needing separate colored art assets — same
// technique as the flight-plane icon further down.
let _newspaperIconTexture = null;
function newspaperIconTexture() {
if (_newspaperIconTexture) return _newspaperIconTexture;
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(8, 8);
ctx.lineTo(46, 8);
ctx.lineTo(58, 20);
ctx.lineTo(58, 58);
ctx.lineTo(8, 58);
ctx.closePath();
ctx.fill();
ctx.globalCompositeOperation = "destination-out"; // cut "text line" gaps into the page
ctx.fillRect(14, 16, 30, 4);
ctx.fillRect(14, 26, 38, 3);
ctx.fillRect(14, 33, 38, 3);
ctx.fillRect(14, 40, 34, 3);
ctx.fillRect(14, 47, 28, 3);
ctx.globalCompositeOperation = "source-over";
_newspaperIconTexture = new THREE.CanvasTexture(canvas);
return _newspaperIconTexture;
}
let _helmetIconTexture = null;
function helmetIconTexture() {
if (_helmetIconTexture) return _helmetIconTexture;
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(32, 30, 21, Math.PI, 0, false); // dome
ctx.lineTo(53, 36);
ctx.quadraticCurveTo(53, 43, 45, 43); // brim, right
ctx.lineTo(19, 43);
ctx.quadraticCurveTo(11, 43, 11, 36); // brim, left
ctx.closePath();
ctx.fill();
_helmetIconTexture = new THREE.CanvasTexture(canvas);
return _helmetIconTexture;
}
function makeIconSprite(texture, colorHex, scale) {
const material = new THREE.SpriteMaterial({
map: texture,
color: colorHex,
transparent: true,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(scale, scale, 1);
return sprite;
}
function buildGlobeObject(d) {
if (d.kind === "conflict") {
return makeIconSprite(helmetIconTexture(), MILITARY_FLIGHT_HEX, HELMET_ICON_DEG * sceneUnitsPerDeg);
}
return makeIconSprite(newspaperIconTexture(), NEWS_ICON_HEX, pointRadiusFor(d) * sceneUnitsPerDeg * 2.4);
}
function objectLabelFor(d) {
if (d.kind === "conflict") {
return `
${escapeHtml(d.event_type)}
${escapeHtml(d.actor1)}${d.actor2 ? " vs " + escapeHtml(d.actor2) : ""}
${escapeHtml(d.location_name)}, ${escapeHtml(d.country)}
${d.fatalities ? `${d.fatalities} fatalities ` : ""}
`;
}
return `
${escapeHtml(d.headline)}
${d.article_count} stor${d.article_count === 1 ? "y" : "ies"} · ${d.source_count} source(s)
${d.location_name ? ` ${escapeHtml(d.location_name)}` : ""}
`;
}
function onGlobeObjectClick(d) {
if (d.kind === "conflict") {
flyTo(d.lat, d.lon);
militaryDrawer.classList.remove("collapsed");
return;
}
onClusterClick(d);
}
// pointRadius is in degrees of arc on the globe surface (three-globe's own
// unit for this accessor — not pixels, not a fraction of globe radius), so
// it's directly comparable to lat/lon distances for the decluttering pass
// below. A fixed degree-size reads as bigger on screen the closer the
// camera gets, so we also shrink it toward zoomed-in altitudes — that both
// matches "smaller when zoomed in" and leaves more breathing room for
// decluttering dense regions like the Levant.
function pointRadiusFor(d, altitude = currentAltitude) {
const zoomScale = Math.max(0.4, Math.min(1.2, altitude / 2.2));
return zoomScale * Math.min(2.0, 0.22 + Math.log2(d.article_count + 1) * 0.28);
}
// ---------------------------------------------- zoom-adaptive regrouping --
//
// The server clusters articles onto a fixed ~11km grid (see backend
// clustering.py). That's the right resolution when zoomed in on a city, but
// when zoomed out to see a continent it leaves dozens of separate dots that
// visually belong together. Rather than re-query the server per zoom level,
// we do a second, cheap client-side merge pass: nearby base-clusters get
// folded into a single displayed point, with the fold radius growing with
// camera altitude. Clicking a merged point fetches articles from every
// constituent grid cell.
let regroupScheduled = false;
function scheduleRegroup() {
if (regroupScheduled) return;
regroupScheduled = true;
requestAnimationFrame(() => {
regroupScheduled = false;
displayedClusters = regroupForAltitude(clusters, currentAltitude);
syncGlobeObjects();
});
}
// News clusters and conflict events share one three-globe "objects" layer
// (see buildGlobeObject) so both can be custom icon sprites; this merges
// the two data sources into it, respecting the conflict-overlay toggle.
function syncGlobeObjects() {
const newsObjs = displayedClusters.map((c) => ({ ...c, kind: "news" }));
const conflictObjs = document.getElementById("toggleConflict").checked
? conflictEvents.map((e) => ({ ...e, kind: "conflict" }))
: [];
globe.objectsData([...newsObjs, ...conflictObjs]);
}
function mergeRadiusDeg(altitude) {
// altitude ~0.4 (close, city-level) -> ~0deg (no extra merging beyond the server grid)
// altitude ~2.2 (default view) -> ~13deg (nearby-country scale, e.g. Levant stays
// together but Ukraine/Iran/China stay apart)
// altitude ~4+ (fully zoomed out) -> capped at 32deg (subcontinent scale)
return Math.max(0, Math.min(32, (altitude - 0.4) * 7));
}
function approxDegDistance(a, b) {
const avgLatRad = (((a.lat + b.lat) / 2) * Math.PI) / 180;
const dx = (a.lon - b.lon) * Math.cos(avgLatRad);
const dy = a.lat - b.lat;
return Math.sqrt(dx * dx + dy * dy);
}
function regroupForAltitude(baseClusters, altitude) {
const radius = mergeRadiusDeg(altitude);
let result;
if (radius <= 0.05) {
result = baseClusters.map((c) => ({ ...c, cluster_keys: [c.cluster_key] }));
} else {
const bySize = [...baseClusters].sort((a, b) => b.article_count - a.article_count);
const visited = new Set();
const groups = [];
for (const seed of bySize) {
if (visited.has(seed.cluster_key)) continue;
const group = [seed];
visited.add(seed.cluster_key);
for (const other of bySize) {
if (visited.has(other.cluster_key)) continue;
if (approxDegDistance(seed, other) <= radius) {
group.push(other);
visited.add(other.cluster_key);
}
}
groups.push(group);
}
result = groups.map(mergeGroup);
}
// Grouping alone doesn't stop dense regions (e.g. the Levant, where Gaza/
// Israel/West Bank/Jerusalem/Palestine all sit within ~1deg of each other)
// from rendering as several large dots stacked on top of each other. Push
// still-overlapping points apart so every one stays individually visible
// and clickable, rather than the biggest one blotting out its neighbors.
return declutter(result, altitude);
}
function declutter(points, altitude, iterations = 8) {
// Work on plain {lat, lon} + a back-reference so we don't mutate the
// input objects mid-pass (order of processing would otherwise bias the
// result).
const pts = points.map((p) => ({ lat: p.lat, lon: p.lon, r: pointRadiusFor(p, altitude) }));
for (let iter = 0; iter < iterations; iter++) {
let moved = false;
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const a = pts[i];
const b = pts[j];
const minDist = (a.r + b.r) * 0.85; // slight overlap tolerance reads as "touching," not gapless
const avgLatRad = (((a.lat + b.lat) / 2) * Math.PI) / 180;
const cosLat = Math.max(0.05, Math.cos(avgLatRad));
let dx = (b.lon - a.lon) * cosLat;
let dy = b.lat - a.lat;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-4) {
const angle = Math.random() * Math.PI * 2;
dx = Math.cos(angle) * 0.01;
dy = Math.sin(angle) * 0.01;
dist = 0.01;
}
if (dist < minDist) {
moved = true;
const push = (minDist - dist) / 2;
const ux = dx / dist;
const uy = dy / dist;
a.lon -= (ux * push) / cosLat;
a.lat -= uy * push;
b.lon += (ux * push) / cosLat;
b.lat += uy * push;
}
}
}
if (!moved) break;
}
return points.map((p, i) => ({
...p,
lat: Math.max(-85, Math.min(85, pts[i].lat)),
lon: pts[i].lon,
}));
}
function mergeGroup(group) {
const totalArticles = group.reduce((sum, g) => sum + g.article_count, 0);
const lat = group.reduce((sum, g) => sum + g.lat * g.article_count, 0) / totalArticles;
const lon = group.reduce((sum, g) => sum + g.lon * g.article_count, 0) / totalArticles;
const sources = new Set(group.flatMap((g) => g.sources));
const countries = new Set(group.map((g) => g.country).filter(Boolean));
const representative = group.reduce((best, g) => (g.article_count > best.article_count ? g : best), group[0]);
let locationName;
if (group.length === 1) locationName = group[0].location_name;
else if (countries.size === 1) locationName = `${[...countries][0]} (${group.length} locations)`;
else locationName = `${group.length} locations across ${countries.size} countries`;
return {
cluster_key: group.map((g) => g.cluster_key).join("|"),
cluster_keys: group.map((g) => g.cluster_key),
lat,
lon,
location_name: locationName,
country: countries.size === 1 ? [...countries][0] : null,
article_count: totalArticles,
source_count: sources.size,
sources: [...sources],
topic: representative.topic,
latest_published_at: group.reduce((max, g) => (g.latest_published_at > max ? g.latest_published_at : max), group[0].latest_published_at),
headline: representative.headline,
};
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function flyTo(lat, lon) {
globe.pointOfView({ lat, lng: lon, altitude: 1.4 }, 1200);
const ring = { lat, lon, kind: "click", maxR: 6, repeatPeriod: 4000 };
clickRings.push(ring);
syncRings();
setTimeout(() => {
clickRings = clickRings.filter((r) => r !== ring);
syncRings();
}, 1600);
}
function syncRings() {
const conflictRingData = document.getElementById("toggleConflict").checked
? conflictEvents.map((e) => ({ lat: e.lat, lon: e.lon, kind: "conflict", maxR: 4, repeatPeriod: 2200 }))
: [];
globe.ringsData([...conflictRingData, ...clickRings]);
}
// ------------------------------------------------------------ clusters --
async function loadClusters() {
try {
const res = await fetch(`${API}/clusters`);
clusters = await res.json();
scheduleRegroup();
document.getElementById("lastUpdated").textContent = "updated " + new Date().toLocaleTimeString();
} catch (e) {
console.error("Failed to load clusters", e);
}
}
function onClusterClick(cluster) {
flyTo(cluster.lat, cluster.lon);
openClusterModal(cluster);
}
// -------------------------------------------------------------- modal --
const clusterModal = document.getElementById("clusterModal");
const modalContent = document.getElementById("modalContent");
document.querySelectorAll("[data-close]").forEach((el) => {
el.onclick = () => document.getElementById(el.dataset.close).classList.add("hidden");
});
async function openClusterModal(cluster) {
clusterModal.classList.remove("hidden");
modalContent.innerHTML = `
${escapeHtml(cluster.headline)}
${cluster.article_count} stories · ${cluster.source_count} source(s)
${cluster.location_name ? " · " + escapeHtml(cluster.location_name) : ""}
`;
loadWikiSummary(cluster.location_name || cluster.country);
if (cluster.country) loadParliament(cluster.country);
loadClusterArticles(cluster.cluster_keys || [cluster.cluster_key]);
}
async function loadClusterArticles(clusterKeys) {
const el = document.getElementById("articleList");
try {
const results = await Promise.all(
clusterKeys.map((key) => fetch(`${API}/clusters/${encodeURIComponent(key)}/articles`).then((r) => r.json()))
);
const seen = new Set();
const articles = results
.flat()
.filter((a) => (seen.has(a.id) ? false : (seen.add(a.id), true)))
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at));
el.innerHTML = articles.map(articleItemHtml).join("");
} catch (e) {
el.textContent = "Could not load stories.";
}
}
// Decorative publisher favicon, derived from the article's own URL so it
// works for any source without hand-curating logo assets (and without
// hotlinking an outlet's actual trademarked logo — favicons are the
// standard low-risk "site icon" browsers/RSS readers already show).
function faviconFor(url) {
try {
const host = new URL(url).hostname;
return `https://www.google.com/s2/favicons?sz=32&domain=${encodeURIComponent(host)}`;
} catch (e) {
return "";
}
}
function faviconImgHtml(a) {
const src = faviconFor(a.url);
if (!src) return "";
return ` `;
}
function articleItemHtml(a) {
return `
${faviconImgHtml(a)}${escapeHtml(a.title)}
${escapeHtml(a.source)}${escapeHtml(a.source_bias || "")}
· ${new Date(a.published_at).toLocaleString()}
`;
}
async function loadWikiSummary(title) {
const el = document.getElementById("wikiSection");
if (!title) {
el.innerHTML = "";
return;
}
try {
const res = await fetch(`${API}/wikipedia/summary?title=${encodeURIComponent(title)}`);
if (!res.ok) throw new Error("not found");
const w = await res.json();
el.innerHTML = `
About ${escapeHtml(w.title)}
${w.thumbnail ? `
` : ""}
${escapeHtml((w.extract || "").slice(0, 260))}${(w.extract || "").length > 260 ? "…" : ""}
Read on Wikipedia →
`;
} catch (e) {
el.innerHTML = "";
}
}
async function loadParliament(country) {
const el = document.getElementById("parliamentSection");
try {
const res = await fetch(`${API}/wikipedia/parliament?country=${encodeURIComponent(country)}`);
if (!res.ok) throw new Error("not found");
const p = await res.json();
el.innerHTML = `
${escapeHtml(p.legislature)}
Full composition on Wikipedia →
`;
} catch (e) {
el.innerHTML = "";
}
}
// ----------------------------------------------------------- news rail --
async function loadNewsFeed() {
const el = document.getElementById("newsFeed");
try {
const res = await fetch(`${API}/articles?limit=60`);
const articles = await res.json();
el.innerHTML = articles
.map(
(a) => `
${faviconImgHtml(a)}${escapeHtml(a.title)}
${escapeHtml(a.source)}
${escapeHtml(a.source_bias || "")}
${new Date(a.published_at).toLocaleString()}
${
a.lat != null
? `📍 ${escapeHtml(a.location_name || "")} `
: ""
}
`
)
.join("");
el.querySelectorAll(".locate-btn").forEach((btn) => {
btn.onclick = () => flyTo(parseFloat(btn.dataset.lat), parseFloat(btn.dataset.lon));
});
} catch (e) {
console.error("Failed to load news feed", e);
}
}
// -------------------------------------------------- wikipedia text-select
const wikiPopup = document.getElementById("wikiSelectPopup");
document.addEventListener("mouseup", (ev) => {
if (wikiPopup.contains(ev.target)) return;
const sel = window.getSelection();
const text = sel ? sel.toString().trim() : "";
if (text.length < 3 || text.length > 120) {
wikiPopup.classList.add("hidden");
return;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
wikiPopup.style.left = Math.min(rect.left + window.scrollX, window.innerWidth - 300) + "px";
wikiPopup.style.top = rect.bottom + window.scrollY + 6 + "px";
wikiPopup.innerHTML = `🔎 Search Wikipedia for "${escapeHtml(
text.length > 40 ? text.slice(0, 40) + "…" : text
)}" `;
wikiPopup.classList.remove("hidden");
wikiPopup.querySelector(".trigger").onclick = () => searchWikipedia(text);
});
async function searchWikipedia(query) {
wikiPopup.innerHTML = `Searching…
`;
try {
const res = await fetch(`${API}/wikipedia/search?q=${encodeURIComponent(query)}`);
const results = await res.json();
if (!results.length) {
wikiPopup.innerHTML = `No Wikipedia results.
`;
return;
}
wikiPopup.innerHTML = results
.map(
(r) =>
`
${escapeHtml(r.title)} ${escapeHtml(r.snippet || "")}
`
)
.join("");
} catch (e) {
wikiPopup.innerHTML = `Search failed.
`;
}
}
document.addEventListener("mousedown", (ev) => {
if (!wikiPopup.contains(ev.target)) wikiPopup.classList.add("hidden");
});
// -------------------------------------------------------------- weather --
const TILE_ZOOM = 2; // 4x4 tiles => manageable fetch count for a demo overlay
async function buildWeatherMesh(layer) {
const n = 2 ** TILE_ZOOM;
const canvas = document.createElement("canvas");
canvas.width = n * 256;
canvas.height = n * 256;
const ctx = canvas.getContext("2d");
const loads = [];
for (let x = 0; x < n; x++) {
for (let y = 0; y < n; y++) {
loads.push(
loadImage(`${API}/weather/tiles/${layer}/${TILE_ZOOM}/${x}/${y}.png`).then((img) => {
ctx.drawImage(img, x * 256, y * 256, 256, 256);
})
);
}
}
await Promise.all(loads);
const texture = new THREE.CanvasTexture(canvas);
const radius = globe.getGlobeRadius() * 1.012;
const geometry = new THREE.SphereGeometry(radius, 64, 64);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.55,
depthWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.y = Math.PI / 2;
return mesh;
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
async function setWeatherLayer(layer) {
Object.values(weatherMeshes).forEach((m) => (m.visible = false));
if (!layer) return;
if (!weatherMeshes[layer]) {
weatherMeshes[layer] = await buildWeatherMesh(layer);
globe.scene().add(weatherMeshes[layer]);
}
weatherMeshes[layer].visible = true;
}
// --------------------------------------------- conflict / military drawer
const militaryDrawer = document.getElementById("militaryDrawer");
document.getElementById("drawerHandle").onclick = () => militaryDrawer.classList.toggle("collapsed");
async function loadConflictEvents() {
const logEl = document.getElementById("militaryLog");
const countEl = document.getElementById("militaryCount");
if (!config.conflict_enabled) {
logEl.innerHTML = `Conflict-event overlay not configured — set ACLED_API_KEY / ACLED_EMAIL in .env to enable (see README).
`;
countEl.textContent = "";
return;
}
try {
const res = await fetch(`${API}/conflict-events`);
conflictEvents = await res.json();
conflictEvents.sort((a, b) => new Date(b.event_date) - new Date(a.event_date));
countEl.textContent = `(${conflictEvents.length})`;
logEl.innerHTML = conflictEvents.length
? conflictEvents
.map(
(e) => `
${escapeHtml(e.event_type)} — ${escapeHtml(e.actor1)}${e.actor2 ? " vs " + escapeHtml(e.actor2) : ""}
${escapeHtml(e.location_name)}, ${escapeHtml(e.country)}
${e.fatalities ? ` · ${e.fatalities} fatalities ` : ""}
${new Date(e.event_date).toLocaleDateString()}
`
)
.join("")
: `No events recorded in the current window.
`;
syncRings();
syncGlobeObjects();
} catch (e) {
console.error("Failed to load conflict events", e);
}
}
// ------------------------------------------------------------ markets --
async function loadMarkets() {
const el = document.getElementById("marketsList");
try {
const res = await fetch(`${API}/markets/latest`);
const rows = await res.json();
el.innerHTML = rows
.map((r, i) => {
const dir = r.change_pct > 0 ? "up" : r.change_pct < 0 ? "down" : "";
const arrow = r.change_pct > 0 ? "▲" : r.change_pct < 0 ? "▼" : "";
return `
${escapeHtml(r.label)}
${r.price.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${r.currency}
${new Date(r.recorded_at).toLocaleTimeString()}
${arrow} ${r.change_pct != null ? r.change_pct.toFixed(2) + "%" : ""}
`;
})
.join("");
el.querySelectorAll(".market-item").forEach((item) => {
item.onclick = () => toggleMarketDetail(item.dataset.symbol, item.dataset.idx);
});
rows.forEach((r, i) => loadSparkline(r.symbol, i));
} catch (e) {
console.error("Failed to load markets", e);
}
}
// 7-day price history as a small inline line+area chart, with a hover
// crosshair/tooltip (per dataviz interaction guidance: line charts ship
// hover by default). Single series per instrument, so no legend — the
// stroke reuses the theme's existing up/down status colors rather than
// introducing a new categorical hue.
async function loadSparkline(symbol, idx) {
const container = document.getElementById(`spark-${idx}`);
if (!container) return;
try {
const res = await fetch(`${API}/markets/history?symbol=${encodeURIComponent(symbol)}&hours=168`);
const history = await res.json();
mountSparkline(container, history);
} catch (e) {
console.error("Failed to load sparkline for", symbol, e);
}
}
function mountSparkline(container, history) {
container.innerHTML = "";
if (!history || history.length < 2) {
container.innerHTML = `not enough history yet
`;
return;
}
const width = 280;
const height = 44;
const pad = 3;
const prices = history.map((h) => h.price);
const min = Math.min(...prices);
const max = Math.max(...prices);
const span = max - min || Math.abs(prices[0]) * 0.001 || 1;
const up = prices[prices.length - 1] >= prices[0];
const color = up ? "var(--c-green)" : "var(--c-red)";
const pts = history.map((h, i) => ({
x: pad + (i / (history.length - 1)) * (width - 2 * pad),
y: height - pad - ((h.price - min) / span) * (height - 2 * pad),
price: h.price,
t: h.recorded_at,
}));
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("preserveAspectRatio", "none");
svg.classList.add("spark");
const linePath = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
const areaPath = `${linePath} L${pts[pts.length - 1].x.toFixed(1)},${height - pad} L${pts[0].x.toFixed(1)},${height - pad} Z`;
const area = document.createElementNS(svgNS, "path");
area.setAttribute("d", areaPath);
area.setAttribute("fill", color);
area.setAttribute("fill-opacity", "0.14");
area.setAttribute("stroke", "none");
svg.appendChild(area);
const line = document.createElementNS(svgNS, "path");
line.setAttribute("d", linePath);
line.setAttribute("fill", "none");
line.setAttribute("stroke", color);
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-linecap", "round");
line.setAttribute("stroke-linejoin", "round");
svg.appendChild(line);
const crosshair = document.createElementNS(svgNS, "line");
crosshair.setAttribute("y1", "0");
crosshair.setAttribute("y2", String(height));
crosshair.setAttribute("stroke", "var(--c-text-muted)");
crosshair.setAttribute("stroke-width", "1");
crosshair.setAttribute("stroke-dasharray", "2,2");
crosshair.style.display = "none";
svg.appendChild(crosshair);
const dot = document.createElementNS(svgNS, "circle");
dot.setAttribute("r", "2.6");
dot.setAttribute("fill", color);
dot.style.display = "none";
svg.appendChild(dot);
container.appendChild(svg);
const tooltip = document.createElement("div");
tooltip.className = "spark-tooltip hidden";
container.appendChild(tooltip);
svg.addEventListener("mousemove", (ev) => {
const rect = svg.getBoundingClientRect();
const relX = ((ev.clientX - rect.left) / rect.width) * width;
let nearest = 0;
let bestDist = Infinity;
pts.forEach((p, i) => {
const d = Math.abs(p.x - relX);
if (d < bestDist) {
bestDist = d;
nearest = i;
}
});
const p = pts[nearest];
crosshair.setAttribute("x1", p.x.toFixed(1));
crosshair.setAttribute("x2", p.x.toFixed(1));
crosshair.style.display = "";
dot.setAttribute("cx", p.x.toFixed(1));
dot.setAttribute("cy", p.y.toFixed(1));
dot.style.display = "";
tooltip.textContent = `${p.price.toLocaleString(undefined, { maximumFractionDigits: 2 })} · ${new Date(p.t).toLocaleString()}`;
tooltip.classList.remove("hidden");
const leftPct = p.x / width;
tooltip.style.left = `${Math.min(70, Math.max(0, leftPct * 100 - 30))}%`;
});
svg.addEventListener("mouseleave", () => {
crosshair.style.display = "none";
dot.style.display = "none";
tooltip.classList.add("hidden");
});
}
// "Likely factors": the words that recur most across every headline
// published in a spike's window (backend textutil.py — stopword-filtered
// word frequency, no AI/LLM). A hint worth reading the linked articles
// over, not an explanation, so it's labeled as such wherever it's shown.
function keywordTagsHtml(keywords) {
if (!keywords || !keywords.length) return "";
const tags = keywords.map(([word, count]) => `${escapeHtml(word)} · ${count} `).join("");
return `Likely factors (common words in this window): ${tags}
`;
}
// Spikes are only reported when a move clears both an absolute floor AND
// (once there's enough price history) a multiple of this instrument's own
// recent typical move — see backend markets.py _recent_volatility. This
// renders that comparison so "why did this count as a spike" is visible.
function volatilityLabel(pctChange, baselinePct) {
if (baselinePct == null || baselinePct <= 0) return "";
const ratio = Math.abs(pctChange) / baselinePct;
return ` (${ratio.toFixed(1)}× this instrument's typical ${baselinePct.toFixed(2)}% move) `;
}
async function toggleMarketDetail(symbol, idx) {
const el = document.getElementById(`market-detail-${idx}`);
const wasHidden = el.classList.contains("hidden");
document.querySelectorAll(".market-detail").forEach((d) => d.classList.add("hidden"));
if (!wasHidden) return;
el.classList.remove("hidden");
el.innerHTML = `Loading recent moves…
`;
try {
const res = await fetch(`${API}/markets/spikes?symbol=${encodeURIComponent(symbol)}&hours=720`);
const spikes = await res.json();
if (!spikes.length) {
el.innerHTML = `No moves ≥ the spike threshold recorded yet.
`;
return;
}
el.innerHTML = spikes
.map((s) => {
const dir = s.pct_change > 0 ? "up" : "down";
const arrow = s.pct_change > 0 ? "▲" : "▼";
const articlesHtml = s.candidate_articles.length
? s.candidate_articles.slice(0, 5).map(articleItemHtml).join("")
: `No strongly-matching stories found in that window.
`;
return `
${arrow} ${s.pct_change.toFixed(2)}% ${volatilityLabel(s.pct_change, s.baseline_volatility_pct)}
at ${new Date(s.detected_at).toLocaleString()}
(${s.from_price.toLocaleString()} → ${s.to_price.toLocaleString()})
${keywordTagsHtml(s.top_keywords)}
${articlesHtml}
`;
})
.join(" ");
} catch (e) {
el.innerHTML = `Could not load spike data.
`;
}
}
// ----------------------------------------------- economic incident history
const econHistoryView = document.getElementById("econHistoryView");
let econHistorySymbolsLoaded = false;
let currentIncidents = [];
let allTrackedInstruments = null; // [{symbol,label}], fetched once
document.getElementById("infoBtn").onclick = () => {
econHistoryView.classList.remove("hidden");
loadEconHistory();
};
document.getElementById("econHistorySymbolFilter").onchange = () => loadEconHistory();
// Delegated once (not per-render) so re-rendering the list doesn't stack
// duplicate listeners: expand/collapse a card's comparison charts, fetching
// them lazily on first expand only.
document.getElementById("econHistoryList").addEventListener("click", (ev) => {
const btn = ev.target.closest(".overlay-toggle-btn");
if (!btn) return;
const idx = btn.dataset.idx;
const wrap = document.getElementById(`overlay-charts-${idx}`);
if (wrap.dataset.loaded) {
wrap.classList.toggle("hidden");
btn.textContent = wrap.classList.contains("hidden") ? "📈 Show comparison charts" : "📉 Hide comparison charts";
} else {
btn.textContent = "Loading charts…";
loadIncidentOverlayCharts(currentIncidents[idx], idx).then(() => {
btn.textContent = "📉 Hide comparison charts";
});
}
});
async function loadEconHistory() {
const listEl = document.getElementById("econHistoryList");
const filterEl = document.getElementById("econHistorySymbolFilter");
const symbol = filterEl.value;
if (!econHistorySymbolsLoaded) {
try {
const rows = await (await fetch(`${API}/markets/latest`)).json();
filterEl.innerHTML =
`All instruments ` +
rows.map((r) => `${escapeHtml(r.label)} `).join("");
econHistorySymbolsLoaded = true;
} catch (e) {
// non-fatal — the filter just stays at "All instruments"
}
}
listEl.innerHTML = `Loading…
`;
try {
const url = symbol
? `${API}/markets/incidents?symbol=${encodeURIComponent(symbol)}&hours=8760`
: `${API}/markets/incidents?hours=8760`;
currentIncidents = await (await fetch(url)).json();
if (!currentIncidents.length) {
listEl.innerHTML = `No moves ≥ the spike threshold recorded yet.
`;
return;
}
listEl.innerHTML = currentIncidents.map((inc, idx) => econIncidentHtml(inc, idx)).join("");
} catch (e) {
listEl.innerHTML = `Could not load incident history.
`;
}
}
// An "incident" is one or more spikes merged because they landed within an
// hour of each other (backend: markets.merge_spikes_into_incidents) — e.g.
// WTI and Brent crude spiking together shows as one incident, not two.
function econIncidentHtml(inc, idx) {
const fmtHour = (iso) =>
new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const instrumentsHtml = inc.instruments
.map((i) => {
const dir = i.pct_change > 0 ? "up" : "down";
const arrow = i.pct_change > 0 ? "▲" : "▼";
return `${escapeHtml(i.label)} ${arrow} ${i.pct_change.toFixed(2)}% `;
})
.join("");
const articlesHtml = inc.candidate_articles.length
? inc.candidate_articles.map(articleItemHtml).join("")
: `No stories found published in that window.
`;
return `
${instrumentsHtml}
${fmtHour(inc.window_start)} – ${fmtHour(inc.window_end)}
· ${inc.candidate_articles.length} stor${inc.candidate_articles.length === 1 ? "y" : "ies"} in window
${inc.instruments.length > 1 ? ` · ${inc.instruments.length} instruments moved within ${INCIDENT_MERGE_HOURS_LABEL} of each other` : ""}
${keywordTagsHtml(inc.top_keywords)}
📈 Show comparison charts
All tracked instruments, overlaid (% change from window start)
This incident's instruments only
${articlesHtml}
`;
}
// ---- multi-instrument overlay charts ----
//
// Normalizes every series to % change from its first point in the window
// (raw prices span wildly different scales/currencies) and plots them on
// one shared axis so counter-reactions — one instrument up while another
// is down over the same span — are visible at a glance.
async function ensureAllTrackedInstruments() {
if (allTrackedInstruments) return allTrackedInstruments;
allTrackedInstruments = await (await fetch(`${API}/markets/latest`)).json();
assignOverlaySymbolStyles(allTrackedInstruments.map((r) => r.symbol));
return allTrackedInstruments;
}
async function fetchHistoryFor(instruments, start, end) {
const results = await Promise.all(
instruments.map(async (inst) => {
const url = `${API}/markets/history?symbol=${encodeURIComponent(inst.symbol)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
const history = await (await fetch(url)).json();
return { symbol: inst.symbol, label: inst.label, history };
})
);
return results;
}
async function loadIncidentOverlayCharts(inc, idx) {
const wrap = document.getElementById(`overlay-charts-${idx}`);
wrap.classList.remove("hidden");
wrap.dataset.loaded = "1";
const allEl = document.getElementById(`overlay-all-${idx}`);
const incEl = document.getElementById(`overlay-inc-${idx}`);
allEl.innerHTML = `Loading…
`;
incEl.innerHTML = `Loading…
`;
try {
const instruments = await ensureAllTrackedInstruments();
const allSeries = await fetchHistoryFor(instruments, inc.window_start, inc.window_end);
buildOverlayChart(allSeries, allEl);
const incSymbols = new Set(inc.instruments.map((i) => i.symbol));
buildOverlayChart(
allSeries.filter((s) => incSymbols.has(s.symbol)),
incEl
);
} catch (e) {
allEl.innerHTML = `Could not load comparison charts.
`;
incEl.innerHTML = "";
}
}
function buildOverlayChart(seriesList, container) {
const width = 640;
const height = 220;
const padL = 8;
const padR = 92;
const padT = 10;
const padB = 10;
const usable = seriesList.filter((s) => s.history.length >= 2);
if (!usable.length) {
container.innerHTML = `Not enough price history in this window yet.
`;
return;
}
const normalized = usable.map((s) => {
const base = s.history[0].price;
return {
symbol: s.symbol,
label: s.label,
points: s.history.map((h) => ({
t: new Date(h.recorded_at).getTime(),
pct: base ? ((h.price - base) / base) * 100 : 0,
})),
};
});
const allT = normalized.flatMap((s) => s.points.map((p) => p.t));
const tMin = Math.min(...allT);
const tMax = Math.max(...allT, tMin + 1);
const allPct = normalized.flatMap((s) => s.points.map((p) => p.pct));
const pMin = Math.min(0, ...allPct);
const pMax = Math.max(0, ...allPct);
const pSpan = pMax - pMin || 1;
const xScale = (t) => padL + ((t - tMin) / (tMax - tMin)) * (width - padL - padR);
const yScale = (pct) => padT + (1 - (pct - pMin) / pSpan) * (height - padT - padB);
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.classList.add("overlay-chart-svg");
const zero = document.createElementNS(svgNS, "line");
zero.setAttribute("x1", padL);
zero.setAttribute("x2", width - padR);
zero.setAttribute("y1", yScale(0).toFixed(1));
zero.setAttribute("y2", yScale(0).toFixed(1));
zero.setAttribute("stroke", "var(--c-panel-border)");
svg.appendChild(zero);
normalized.forEach((s) => {
const style = overlaySymbolStyle[s.symbol] || { color: "#999", dashed: false };
const d = s.points.map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.t).toFixed(1)},${yScale(p.pct).toFixed(1)}`).join(" ");
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.setAttribute("stroke", style.color);
path.setAttribute("stroke-width", "2");
if (style.dashed) path.setAttribute("stroke-dasharray", "5,3");
svg.appendChild(path);
const last = s.points[s.points.length - 1];
const label = document.createElementNS(svgNS, "text");
label.setAttribute("x", (xScale(last.t) + 5).toFixed(1));
label.setAttribute("y", (yScale(last.pct) + 3).toFixed(1));
label.setAttribute("fill", style.color);
label.setAttribute("font-size", "9");
label.textContent = OVERLAY_SHORT_LABELS[s.symbol] || s.symbol;
svg.appendChild(label);
});
const crosshair = document.createElementNS(svgNS, "line");
crosshair.setAttribute("y1", String(padT));
crosshair.setAttribute("y2", String(height - padB));
crosshair.setAttribute("stroke", "var(--c-text-muted)");
crosshair.setAttribute("stroke-width", "1");
crosshair.setAttribute("stroke-dasharray", "2,2");
crosshair.style.display = "none";
svg.appendChild(crosshair);
container.innerHTML = "";
container.appendChild(svg);
const tooltip = document.createElement("div");
tooltip.className = "overlay-tooltip hidden";
container.appendChild(tooltip);
svg.addEventListener("mousemove", (ev) => {
const rect = svg.getBoundingClientRect();
const relX = ((ev.clientX - rect.left) / rect.width) * width;
const t = tMin + ((relX - padL) / (width - padL - padR)) * (tMax - tMin);
crosshair.setAttribute("x1", relX.toFixed(1));
crosshair.setAttribute("x2", relX.toFixed(1));
crosshair.style.display = "";
const rows = normalized
.map((s) => {
let nearest = s.points[0];
let best = Infinity;
s.points.forEach((p) => {
const d = Math.abs(p.t - t);
if (d < best) {
best = d;
nearest = p;
}
});
const style = overlaySymbolStyle[s.symbol] || { color: "#999" };
return `${escapeHtml(s.label)} ${nearest.pct >= 0 ? "+" : ""}${nearest.pct.toFixed(2)}%
`;
})
.join("");
tooltip.innerHTML = `${new Date(t).toLocaleString()}
${rows}`;
tooltip.classList.remove("hidden");
tooltip.style.left = `${Math.min(60, Math.max(0, (relX / width) * 100 - 20))}%`;
});
svg.addEventListener("mouseleave", () => {
crosshair.style.display = "none";
tooltip.classList.add("hidden");
});
const legend = document.createElement("div");
legend.className = "overlay-legend";
legend.innerHTML = normalized
.map((s) => {
const style = overlaySymbolStyle[s.symbol] || { color: "#999" };
return ` ${escapeHtml(s.label)} `;
})
.join("");
container.appendChild(legend);
}
// -------------------------------------------------------------- flights --
//
// Rendered as a single THREE.Points cloud (not three-globe's per-datum
// pointsData/objectsData layers, already used by news clusters) since a
// live global snapshot can be several thousand aircraft — one draw call
// keeps that cheap. The tradeoff: point sprites all face the camera and
// don't support per-instance rotation, so icons aren't heading-aligned.
// Military classification is a best-effort heuristic from the backend (see
// backend/app/data/military_ranges.yaml); color is the only encoding
// (magenta = flagged military, globe-tint purple = everything else) and
// isn't itself a claim of certainty — the hover tooltip states as much.
let flightsPoints = null;
let flightsData = [];
let flightsPollTimer = null;
let flightsPollSeconds = 300;
const flightRaycaster = new THREE.Raycaster();
flightRaycaster.params.Points.threshold = 2.5;
function altitudeForFlight(f) {
const m = f.altitude_m || 0;
return 0.02 + Math.min(1, m / 12000) * 0.03;
}
// A small top-down airplane silhouette, drawn once as a white-on-transparent
// alpha mask so PointsMaterial's vertexColors can tint it per-aircraft
// (military vs civilian) without needing separate red/gray sprite sheets.
let _planeIconTexture = null;
function planeIconTexture() {
if (_planeIconTexture) return _planeIconTexture;
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(32, 3); // nose
ctx.lineTo(36, 24);
ctx.lineTo(61, 39); // right wingtip
ctx.lineTo(61, 45);
ctx.lineTo(37, 38);
ctx.lineTo(35, 50);
ctx.lineTo(47, 59); // right tailtip
ctx.lineTo(47, 63);
ctx.lineTo(32, 57); // tail center
ctx.lineTo(17, 63); // left tailtip
ctx.lineTo(17, 59);
ctx.lineTo(29, 50);
ctx.lineTo(27, 38);
ctx.lineTo(3, 45); // left wingtip
ctx.lineTo(3, 39);
ctx.lineTo(28, 24);
ctx.closePath();
ctx.fill();
_planeIconTexture = new THREE.CanvasTexture(canvas);
return _planeIconTexture;
}
function buildFlightsPoints(flights) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(flights.length * 3);
const colors = new Float32Array(flights.length * 3);
const milColor = new THREE.Color(MILITARY_FLIGHT_HEX);
const civColor = new THREE.Color(GLOBE_TINT_HEX);
flights.forEach((f, i) => {
const { x, y, z } = globe.getCoords(f.lat, f.lon, altitudeForFlight(f));
positions[i * 3] = x;
positions[i * 3 + 1] = y;
positions[i * 3 + 2] = z;
const c = f.is_military ? milColor : civColor;
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
});
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 4,
map: planeIconTexture(),
alphaTest: 0.3,
vertexColors: true,
sizeAttenuation: true,
transparent: true,
depthWrite: false,
});
return new THREE.Points(geometry, material);
}
async function loadFlights() {
try {
const res = await fetch(`${API}/flights`);
flightsData = await res.json();
renderFlights();
} catch (e) {
console.error("Failed to load flights", e);
}
}
// Rebuilds the points cloud from the last-fetched snapshot, applying the
// "Military only" filter — kept separate from loadFlights so toggling the
// filter is instant and doesn't wait on a network round-trip.
let flightsRendered = []; // the array actually backing the current geometry (post-filter), for hover lookups
function renderFlights() {
if (flightsPoints) {
globe.scene().remove(flightsPoints);
flightsPoints.geometry.dispose();
flightsPoints.material.dispose();
flightsPoints = null;
}
if (!document.getElementById("toggleFlights").checked) return;
const militaryOnly = document.getElementById("toggleMilitaryOnly").checked;
flightsRendered = militaryOnly ? flightsData.filter((f) => f.is_military) : flightsData;
flightsPoints = buildFlightsPoints(flightsRendered);
globe.scene().add(flightsPoints);
const milCount = flightsData.filter((f) => f.is_military).length;
document.getElementById("flightCount").textContent = flightsData.length
? militaryOnly
? `${milCount} military-flagged (of ${flightsData.length})`
: `${flightsData.length} aircraft · ${milCount} military-flagged`
: "no data yet";
}
function setFlightsVisible(visible) {
if (visible) {
loadFlights();
flightsPollTimer = setInterval(loadFlights, Math.max(15, flightsPollSeconds) * 1000);
} else {
if (flightsPollTimer) clearInterval(flightsPollTimer);
flightsPollTimer = null;
if (flightsPoints) {
globe.scene().remove(flightsPoints);
flightsPoints.geometry.dispose();
flightsPoints.material.dispose();
flightsPoints = null;
}
document.getElementById("flightCount").textContent = "";
document.getElementById("flightTooltip").classList.add("hidden");
}
}
const flightTooltip = document.getElementById("flightTooltip");
document.getElementById("globeViz").addEventListener("mousemove", (ev) => {
if (!flightsPoints) return;
const el = document.getElementById("globeViz");
const rect = el.getBoundingClientRect();
const mouse = new THREE.Vector2(
((ev.clientX - rect.left) / rect.width) * 2 - 1,
-((ev.clientY - rect.top) / rect.height) * 2 + 1
);
flightRaycaster.setFromCamera(mouse, globe.camera());
const hits = flightRaycaster.intersectObject(flightsPoints);
const f = hits.length ? flightsRendered[hits[0].index] : null;
if (!f) {
flightTooltip.classList.add("hidden");
return;
}
flightTooltip.innerHTML = `
${escapeHtml(f.callsign || f.icao24)} ${f.is_military ? ' MIL (heuristic) ' : ""}
${escapeHtml(f.origin_country || "")}
${f.altitude_m != null ? Math.round(f.altitude_m) + " m" : ""}${
f.velocity_ms != null ? " · " + Math.round(f.velocity_ms * 3.6) + " km/h" : ""
}
`;
flightTooltip.style.left = ev.clientX + 12 + "px";
flightTooltip.style.top = ev.clientY + 12 + "px";
flightTooltip.classList.remove("hidden");
});
// --------------------------------------------------------------- wiring --
document.getElementById("refreshBtn").onclick = async () => {
await fetch(`${API}/refresh`, { method: "POST" });
setTimeout(loadClusters, 1500);
setTimeout(loadNewsFeed, 1500);
};
document.getElementById("toggleWeather").onchange = (ev) => {
activeWeatherLayer = ev.target.checked ? document.getElementById("weatherLayer").value : null;
setWeatherLayer(activeWeatherLayer);
};
document.getElementById("weatherLayer").onchange = (ev) => {
if (document.getElementById("toggleWeather").checked) setWeatherLayer(ev.target.value);
};
document.getElementById("toggleConflict").onchange = () => {
if (!conflictEvents.length) loadConflictEvents();
else {
syncRings();
syncGlobeObjects();
}
};
document.getElementById("toggleFlights").onchange = (ev) => setFlightsVisible(ev.target.checked);
document.getElementById("toggleMilitaryOnly").onchange = () => {
if (document.getElementById("toggleFlights").checked) renderFlights();
};
async function init() {
try {
config = await (await fetch(`${API}/config`)).json();
} catch (e) {
console.warn("Could not load config; assuming weather/conflict disabled", e);
}
document.getElementById("toggleWeather").disabled = !config.weather_enabled;
document.getElementById("toggleConflict").disabled = !config.conflict_enabled;
flightsPollSeconds = config.flights_poll_seconds || 300;
await loadClusters();
await loadNewsFeed();
await loadMarkets();
await loadConflictEvents();
resizeGlobe();
setInterval(loadClusters, 60_000);
setInterval(loadNewsFeed, 60_000);
setInterval(loadMarkets, 5 * 60_000);
setInterval(loadConflictEvents, 10 * 60_000);
}
init();