809 lines
30 KiB
JavaScript
809 lines
30 KiB
JavaScript
const API = "/api";
|
|
|
|
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")
|
|
.pointLat("lat")
|
|
.pointLng("lon")
|
|
.pointAltitude(0.012)
|
|
.pointRadius(pointRadiusFor)
|
|
.pointColor(colorForCluster)
|
|
.pointLabel(
|
|
(d) =>
|
|
`<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>`
|
|
)
|
|
.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 = `
|
|
<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();
|
|
} 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>`;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------- 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();
|
|
};
|
|
|
|
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;
|
|
|
|
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();
|