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) : ""}

About this place

Loading…

Stories

Loading…
`; 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)}

Composition of ${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 ? `` : "" }
` ) .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 = ``; 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 `
${ r.frozen ? `
❄ FROZEN
${escapeHtml(r.label)}
` : r.error ? `
⚠ ERROR
${escapeHtml(r.label)}
` : "" }
${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 overlayBtn = ev.target.closest(".overlay-toggle-btn"); if (overlayBtn) { const idx = overlayBtn.dataset.idx; const wrap = document.getElementById(`overlay-charts-${idx}`); if (wrap.dataset.loaded) { wrap.classList.toggle("hidden"); overlayBtn.textContent = wrap.classList.contains("hidden") ? "📈 Show comparison charts" : "📉 Hide comparison charts"; } else { overlayBtn.textContent = "Loading charts…"; loadIncidentOverlayCharts(currentIncidents[idx], idx).then(() => { overlayBtn.textContent = "📉 Hide comparison charts"; }); } return; } const articlesBtn = ev.target.closest(".articles-toggle-btn"); if (articlesBtn) { const idx = articlesBtn.dataset.idx; const wrap = document.getElementById(`articles-${idx}`); const count = currentIncidents[idx].candidate_articles.length; const noun = `stor${count === 1 ? "y" : "ies"}`; wrap.classList.toggle("hidden"); articlesBtn.textContent = wrap.classList.contains("hidden") ? `📄 Show ${count} ${noun}` : `📄 Hide ${count} ${noun}`; } }); 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 = `` + rows.map((r) => ``).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.

`; const incidentDateTime = new Date(inc.detected_at).toLocaleString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); return `
${incidentDateTime}

${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)}
`; } // ---- 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();