`
)
.onPointClick(onClusterClick)
.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);
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(0x6a3fd9);
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];
}
// 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);
globe.pointsData(displayedClusters);
});
}
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.getElementById("closeModal").onclick = () => clusterModal.classList.add("hidden");
document.getElementById("modalBackdrop").onclick = () => clusterModal.classList.add("hidden");
async function openClusterModal(cluster) {
clusterModal.classList.remove("hidden");
modalContent.innerHTML = `
`;
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 `
`;
})
.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 = `