NewsAtlas/frontend/js/app.js

1183 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
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);
function colorForCluster(d) {
// violet (few corroborating sources) -> hot pink (many) — theme accent gradient
const n = Math.min(d.source_count, 6);
const palette = ["#5018DD", "#7018C4", "#9018AB", "#B01092", "#D00879", "#E40046"];
return palette[n - 1] || palette[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(), colorForCluster(d), pointRadiusFor(d) * sceneUnitsPerDeg * 2.4);
}
function objectLabelFor(d) {
if (d.kind === "conflict") {
return `<div style="background:#170b28;border:1px solid #3a2159;padding:6px 8px;border-radius:6px;max-width:220px;font-size:12px;">
<b>${escapeHtml(d.event_type)}</b><br/>
${escapeHtml(d.actor1)}${d.actor2 ? " vs " + escapeHtml(d.actor2) : ""}<br/>
${escapeHtml(d.location_name)}, ${escapeHtml(d.country)}
${d.fatalities ? `<br/><span style="color:#f50505;">${d.fatalities} fatalities</span>` : ""}
</div>`;
}
return `<div style="background:#170b28;border:1px solid #3a2159;padding:6px 8px;border-radius:6px;max-width:220px;font-size:12px;">
<b>${escapeHtml(d.headline)}</b><br/>
${d.article_count} stor${d.article_count === 1 ? "y" : "ies"} · ${d.source_count} source(s)
${d.location_name ? `<br/>${escapeHtml(d.location_name)}` : ""}
</div>`;
}
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = `
<div class="headline">${escapeHtml(cluster.headline)}</div>
<div class="subtle">${cluster.article_count} stories · ${cluster.source_count} source(s)
${cluster.location_name ? " · " + escapeHtml(cluster.location_name) : ""}
</div>
<div class="panel-section" id="wikiSection"><h2>About this place</h2><p class="subtle">Loading…</p></div>
<div class="panel-section" id="parliamentSection"></div>
<div class="panel-section"><h2>Stories</h2><div id="articleList" class="subtle">Loading…</div></div>
`;
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 `<img class="favicon" src="${src}" alt="" loading="lazy" onerror="this.style.display='none'" />`;
}
function articleItemHtml(a) {
return `
<div class="article-item">
<a href="${a.url}" target="_blank" rel="noopener">${faviconImgHtml(a)}${escapeHtml(a.title)}</a>
<div class="article-meta">
${escapeHtml(a.source)}<span class="bias-tag">${escapeHtml(a.source_bias || "")}</span>
· ${new Date(a.published_at).toLocaleString()}
</div>
</div>`;
}
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 = `
<h2>About ${escapeHtml(w.title)}</h2>
<div class="wiki-box">
${w.thumbnail ? `<img src="${w.thumbnail}" alt="" />` : ""}
<div>
<p>${escapeHtml((w.extract || "").slice(0, 260))}${(w.extract || "").length > 260 ? "…" : ""}</p>
<a href="${w.page_url}" target="_blank" rel="noopener">Read on Wikipedia →</a>
</div>
</div>`;
} 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 = `
<h2>${escapeHtml(p.legislature)}</h2>
<img class="parliament-diagram" src="${p.diagram_url}" alt="Composition of ${escapeHtml(p.legislature)}" />
<p class="subtle" style="margin-top:6px;">
<a href="${p.page_url}" target="_blank" rel="noopener">Full composition on Wikipedia →</a>
</p>`;
} 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) => `
<div class="news-item">
<a href="${a.url}" target="_blank" rel="noopener">${faviconImgHtml(a)}${escapeHtml(a.title)}</a>
<div class="article-meta">
<span>${escapeHtml(a.source)}</span>
<span class="bias-tag">${escapeHtml(a.source_bias || "")}</span>
<span>${new Date(a.published_at).toLocaleString()}</span>
${
a.lat != null
? `<button class="locate-btn" data-lat="${a.lat}" data-lon="${a.lon}" title="Show on globe">📍 ${escapeHtml(a.location_name || "")}</button>`
: ""
}
</div>
</div>`
)
.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 = `<button class="trigger">🔎 Search Wikipedia for "${escapeHtml(
text.length > 40 ? text.slice(0, 40) + "…" : text
)}"</button>`;
wikiPopup.classList.remove("hidden");
wikiPopup.querySelector(".trigger").onclick = () => searchWikipedia(text);
});
async function searchWikipedia(query) {
wikiPopup.innerHTML = `<div class="subtle">Searching…</div>`;
try {
const res = await fetch(`${API}/wikipedia/search?q=${encodeURIComponent(query)}`);
const results = await res.json();
if (!results.length) {
wikiPopup.innerHTML = `<div class="subtle">No Wikipedia results.</div>`;
return;
}
wikiPopup.innerHTML = results
.map(
(r) =>
`<a class="wiki-result" href="${r.page_url}" target="_blank" rel="noopener">
<b>${escapeHtml(r.title)}</b>${escapeHtml(r.snippet || "")}
</a>`
)
.join("");
} catch (e) {
wikiPopup.innerHTML = `<div class="subtle">Search failed.</div>`;
}
}
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 = `<p class="subtle">Conflict-event overlay not configured — set ACLED_API_KEY / ACLED_EMAIL in .env to enable (see README).</p>`;
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) => `
<div class="conflict-item">
<div class="desc">
<b>${escapeHtml(e.event_type)}</b> — ${escapeHtml(e.actor1)}${e.actor2 ? " vs " + escapeHtml(e.actor2) : ""}
<br/><span class="subtle">${escapeHtml(e.location_name)}, ${escapeHtml(e.country)}</span>
${e.fatalities ? `<span class="fatalities"> · ${e.fatalities} fatalities</span>` : ""}
</div>
<div class="meta">${new Date(e.event_date).toLocaleDateString()}</div>
</div>`
)
.join("")
: `<p class="subtle">No events recorded in the current window.</p>`;
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 `
<div class="market-item" data-symbol="${r.symbol}" data-idx="${i}">
<div class="market-row">
<span class="label">${escapeHtml(r.label)}</span>
<span class="price">${r.price.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${r.currency}</span>
</div>
<div class="market-row">
<span class="subtle">${new Date(r.recorded_at).toLocaleTimeString()}</span>
<span class="${dir}">${arrow} ${r.change_pct != null ? r.change_pct.toFixed(2) + "%" : ""}</span>
</div>
<div class="spark-container" id="spark-${i}"></div>
<div class="market-detail hidden" id="market-detail-${i}"></div>
</div>`;
})
.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 = `<p class="subtle" style="margin:2px 0 0;">not enough history yet</p>`;
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");
});
}
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 = `<p class="subtle">Loading recent moves…</p>`;
try {
const res = await fetch(`${API}/markets/spikes?symbol=${encodeURIComponent(symbol)}&hours=720`);
const spikes = await res.json();
if (!spikes.length) {
el.innerHTML = `<p class="subtle">No moves ≥ the spike threshold recorded yet.</p>`;
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("")
: `<p class="subtle">No strongly-matching stories found in that window.</p>`;
return `
<p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span>
at ${new Date(s.detected_at).toLocaleString()}
(${s.from_price.toLocaleString()}${s.to_price.toLocaleString()})</p>
${articlesHtml}
`;
})
.join("<hr style='border-color:#223055;margin:10px 0;'/>");
} catch (e) {
el.innerHTML = `<p class="subtle">Could not load spike data.</p>`;
}
}
// ----------------------------------------------- economic incident history
const econHistoryView = document.getElementById("econHistoryView");
let econHistorySymbolsLoaded = false;
document.getElementById("infoBtn").onclick = () => {
econHistoryView.classList.remove("hidden");
loadEconHistory();
};
document.getElementById("econHistorySymbolFilter").onchange = () => loadEconHistory();
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 =
`<option value="">All instruments</option>` +
rows.map((r) => `<option value="${r.symbol}">${escapeHtml(r.label)}</option>`).join("");
econHistorySymbolsLoaded = true;
} catch (e) {
// non-fatal — the filter just stays at "All instruments"
}
}
listEl.innerHTML = `<p class="subtle">Loading…</p>`;
try {
const url = symbol
? `${API}/markets/spikes?symbol=${encodeURIComponent(symbol)}&hours=8760`
: `${API}/markets/spikes?hours=8760`;
const spikes = await (await fetch(url)).json();
if (!spikes.length) {
listEl.innerHTML = `<p class="subtle">No moves ≥ the spike threshold recorded yet.</p>`;
return;
}
listEl.innerHTML = spikes.map(econIncidentHtml).join("");
} catch (e) {
listEl.innerHTML = `<p class="subtle">Could not load incident history.</p>`;
}
}
function econIncidentHtml(s) {
const dir = s.pct_change > 0 ? "up" : "down";
const arrow = s.pct_change > 0 ? "▲" : "▼";
const fmtHour = (iso) =>
new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const articlesHtml = s.candidate_articles.length
? s.candidate_articles.map(articleItemHtml).join("")
: `<p class="subtle">No stories found published in that window.</p>`;
return `
<div class="econ-incident">
<h3>${escapeHtml(s.label)} <span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span></h3>
<div class="window">
${fmtHour(s.window_start)} ${fmtHour(s.window_end)}
· ${s.from_price.toLocaleString()}${s.to_price.toLocaleString()}
· ${s.candidate_articles.length} stor${s.candidate_articles.length === 1 ? "y" : "ies"} in window
</div>
<div class="articles">${articlesHtml}</div>
</div>
`;
}
// -------------------------------------------------------------- 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 = `
<b>${escapeHtml(f.callsign || f.icao24)}</b>${f.is_military ? ' <span class="mil">MIL (heuristic)</span>' : ""}<br/>
${escapeHtml(f.origin_country || "")}<br/>
${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();