diff --git a/dashboard/app.js b/dashboard/app.js
index bdb21651..6bde0507 100644
--- a/dashboard/app.js
+++ b/dashboard/app.js
@@ -75,10 +75,8 @@ const STATE = {
agg: {}, // cache: window -> aggregation object (cumulative history)
filters: { severities: new Set(["CRITICAL", "HIGH", "MEDIUM", "LOW"]), types: new Set(), regions: new Set(), q: "" },
sort: { key: "severity", dir: "asc" },
- tab: "news",
+ tab: "watchlist",
trend: { window: "30", metric: "n" }, // metric: n(news)|e(events); two panels: disease(by pathogen) + geo(by kind)
- newsOpen: new Set(), // expanded news-pulse group keys (persist across re-renders)
- kpiSel: { sev: null, sort: null, type: null }, // explicit per-axis KPI selection (null = none)
};
if (typeof window !== "undefined") window.STATE = STATE; // debug hook
@@ -107,7 +105,7 @@ const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const latest = STATE.manifest.digests[STATE.manifest.digests.length - 1];
await loadDigest(latest.file);
- selectTab(STATE.tab); // sync tab DOM to default (news pulse)
+ selectTab(STATE.tab); // sync tab DOM to default (active watchlist)
} catch (err) {
showError(`Failed to load: ${err.message}`);
}
@@ -227,7 +225,6 @@ async function loadDigest(file) {
refreshDateControls();
// reset filters to a clean view on digest switch
STATE.filters = { severities: new Set(SEV_ORDER), types: new Set(), regions: new Set(), q: "" };
- STATE.kpiSel = { sev: null, sort: null, type: null };
STATE.sort = { key: "severity", dir: "asc" };
$("#searchFilter").value = "";
renderSevChips();
@@ -236,12 +233,11 @@ async function loadDigest(file) {
/* ---------- top-level render ---------- */
function renderAll() {
- renderKPIs();
renderMap();
renderTrend();
renderWatchlist();
renderDiseaseGrid();
- renderNewsPulse();
+ renderRecentActivity();
}
/* ---------- filters ---------- */
@@ -301,89 +297,6 @@ function renderSevChips() {
).join("");
}
-/* ---------- KPIs (3 axes, 2 tiles each, explicit toggle selection) ---------- */
-function renderKPIs() {
- const d = STATE.digest;
- const s = d.summary;
- const k = STATE.kpiSel;
- const asOf = d.as_of || d.report_date;
- const countriesToday = new Set(d.incidents.map((i) => i.country).filter(Boolean)).size;
- const axes = [
- { label: "Severity", tiles: [
- { label: "High+", value: s.critical + s.high, sub: `${s.critical} critical`, cls: "kpi--high", act: "sev:HIGH_PLUS", sel: k.sev === "HIGH_PLUS", hint: "Filter to High + Critical" },
- { label: "Critical", value: s.critical, sub: `${s.high} high`, cls: "kpi--crit", act: "sev:CRITICAL", sel: k.sev === "CRITICAL", hint: "Filter to Critical only" },
- ]},
- { label: "Sort", tiles: [
- { label: "Countries today", value: countriesToday, sub: `${d.incidents.length} incidents`, cls: "kpi--country", act: "sort:event_date:desc", sel: k.sort === "event_date", hint: "Sort by most recent first" },
- { label: "News linked", value: s.news_total, sub: "articles", cls: "kpi--news", act: "sort:news_total:desc", sel: k.sort === "news_total", hint: "Sort by news coverage" },
- ]},
- { label: "Type", tiles: [
- { label: "Active incidents", value: s.reportable_total, sub: `+${s.new_today} today`, cls: "kpi--active", act: "type:ALL", sel: k.type === "ALL", hint: "All types · open watchlist" },
- { label: "Disease outbreaks", value: s.disease_outbreaks, sub: "biological track", cls: "kpi--disease", act: "tab:disease", sel: k.type === "disease", hint: "Open disease pane" },
- ]},
- ];
- $("#kpis").innerHTML = axes.map((ax) => `
-
-
${ax.label}
-
- ${ax.tiles.map((it) => `
-
- ${it.value}
- ${esc(it.label)}
- ${esc(it.sub)}
-
`).join("")}
-
-
`).join("");
-}
-
-function applyKpiAction(act) {
- const f = STATE.filters;
- const k = STATE.kpiSel;
- const allClear = () => !k.sev && !k.sort && !k.type; // nothing selected in any axis
- let targetTab = null; // null = leave the current tab alone
- if (act === "sev:HIGH_PLUS" || act === "sev:CRITICAL") {
- const target = act === "sev:HIGH_PLUS" ? "HIGH_PLUS" : "CRITICAL";
- const turningOn = k.sev !== target;
- if (turningOn) {
- k.sev = target;
- f.severities = (target === "HIGH_PLUS") ? new Set(["CRITICAL", "HIGH"]) : new Set(["CRITICAL"]);
- showToast(target === "HIGH_PLUS" ? "Filtered to High + Critical" : "Filtered to Critical");
- targetTab = "watchlist";
- } else {
- k.sev = null;
- f.severities = new Set(SEV_ORDER);
- showToast("Severity filter cleared");
- targetTab = allClear() ? "news" : null;
- }
- renderSevChips(); renderAll();
- } else if (act.startsWith("sort:")) {
- const [, key, dir] = act.split(":");
- const tag = key; // "event_date" | "news_total"
- const turningOn = k.sort !== tag;
- if (turningOn) {
- k.sort = tag; STATE.sort = { key, dir: dir || "asc" };
- showToast(tag === "event_date" ? "Sorted by most recent" : "Sorted by news coverage");
- targetTab = "watchlist";
- } else {
- k.sort = null; STATE.sort = { key: "severity", dir: "asc" };
- showToast("Sort reset to severity");
- targetTab = allClear() ? "news" : null;
- }
- renderWatchlist(); renderKPIs();
- } else if (act === "type:ALL") {
- if (k.type === "ALL") { k.type = null; targetTab = allClear() ? "news" : null; }
- else { k.type = "ALL"; f.types = new Set(); renderTypeChips(); renderAll(); showToast("Type filter cleared"); targetTab = "watchlist"; }
- renderKPIs();
- } else if (act.startsWith("tab:")) {
- const name = act.split(":")[1];
- if (k.type === name) { k.type = null; targetTab = allClear() ? "news" : null; }
- else { k.type = name; if (name === "disease") { f.types = new Set(); renderTypeChips(); } renderAll(); targetTab = name; }
- renderKPIs();
- }
- if (targetTab) selectTab(targetTab);
-}
-
function showToast(msg) {
const t = $("#toast"); t.textContent = msg; t.hidden = false;
clearTimeout(t._timer);
@@ -506,7 +419,7 @@ function diseaseToIncidentType(disease) {
return "Disease";
}
-function renderTrendPanel(svgSel, legendSel, tdata, series, bucket) {
+function renderTrendPanel(svgSel, tooltipSel, tdata, series, bucket) {
const svg = d3.select(svgSel);
svg.selectAll("*").remove();
const t = STATE.trend;
@@ -517,16 +430,10 @@ function renderTrendPanel(svgSel, legendSel, tdata, series, bucket) {
const totals = series.map((s) => ({ key: s.key, color: s.color, total: d3.sum(s.values, (v) => v.count), peak: d3.max(s.values, (v) => v.count) || 0 }));
totals.sort((a, b) => b.total - a.total);
const globalMax = d3.max(totals, (d) => d.peak) || 1;
+ // active series = those with any non-zero value across the window
+ const activeSeries = totals.filter((d) => d.total > 0);
- // legend (color index) — ordered by total volume, only non-zero shown
- const legendItems = totals.filter((d) => d.total > 0);
- document.querySelector(legendSel).innerHTML = (legendItems.length ? legendItems : totals).map((d) =>
- `
-
- ${esc(d.key)} (Σ${d.total} · peak ${d.peak}/${perLbl})
- `).join("");
-
- if (!tdata.length || globalMax <= 1 && legendItems.length === 0) {
+ if (!tdata.length || (globalMax <= 1 && activeSeries.length === 0)) {
svg.attr("viewBox", `0 0 ${width} ${height}`);
svg.append("text").attr("x", width / 2).attr("y", height / 2).attr("text-anchor", "middle")
.style("font-size", "13px").style("fill", "#6E6E6E").text("No data for this window.");
@@ -551,8 +458,12 @@ function renderTrendPanel(svgSel, legendSel, tdata, series, bucket) {
svg.append("text").attr("x", m.l).attr("y", m.t - 6)
.classed("trend-axis-label", true).text(`${METRIC_LABEL[t.metric]} / ${bucket === "week" ? "wk" : "day"}`);
+ // active series only — keeps hover tooltip concise
+ const seriesByKey = new Map(series.map((s) => [s.key, s]));
+ const plotted = activeSeries.map((d) => seriesByKey.get(d.key)).filter(Boolean);
+
const line = d3.line().x((d) => x(d.date)).y((d) => y(d.count)).curve(d3.curveMonotoneX);
- series.forEach((s) => {
+ plotted.forEach((s) => {
svg.append("path").datum(s.values).attr("d", line)
.attr("fill", "none").attr("stroke", s.color)
.attr("stroke-width", 2).attr("stroke-linejoin", "round").attr("stroke-opacity", .9);
@@ -570,6 +481,67 @@ function renderTrendPanel(svgSel, legendSel, tdata, series, bucket) {
.call((g) => g.select(".domain").remove())
.selectAll("text").classed("trend-axis-label", true)
.attr("transform", "rotate(-30)").style("text-anchor", "end");
+
+ // ---- hover tooltip overlay ----
+ const tipEl = document.querySelector(tooltipSel);
+ if (!tipEl || !plotted.length) return;
+ const dates = tdata.map((d) => d.date);
+ // vertical tracker line (hidden until hover)
+ const tracker = svg.append("line")
+ .attr("class", "trend-tracker")
+ .attr("x1", 0).attr("x2", 0)
+ .attr("y1", m.t).attr("y2", height - m.b)
+ .style("opacity", 0);
+ const panelEl = svg.node().closest(".trend-panel");
+ const fmtDateLong = (iso) => {
+ const dt = new Date(iso + (iso.length === 10 ? "T00:00:00Z" : ""));
+ return dt.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
+ };
+ const renderTip = (iso) => {
+ const rows = plotted.map((s) => {
+ const v = s.values.find((vv) => vv.date === iso);
+ const c = v ? v.count : 0;
+ return { key: s.key, color: s.color, count: c };
+ }).sort((a, b) => b.count - a.count).map((r) =>
+ `
+
+ ${esc(r.key)}
+ ${r.count}
+
`).join("");
+ return `${fmtDateLong(iso)}
${rows}`;
+ };
+ const showTip = (evt, iso) => {
+ tipEl.innerHTML = renderTip(iso);
+ tipEl.hidden = false;
+ const px = x(iso);
+ tracker.attr("x1", px).attr("x2", px).style("opacity", 1);
+ // position tooltip near the cursor, clamped to panel
+ const panelRect = panelEl.getBoundingClientRect();
+ const tipW = tipEl.offsetWidth, tipH = tipEl.offsetHeight;
+ let lx = evt.clientX - panelRect.left + 12;
+ let ly = evt.clientY - panelRect.top - tipH - 8;
+ if (lx + tipW > panelRect.width - 4) lx = evt.clientX - panelRect.left - tipW - 12;
+ if (ly < 4) ly = evt.clientY - panelRect.top + 16;
+ tipEl.style.left = `${Math.max(4, lx)}px`;
+ tipEl.style.top = `${Math.max(4, ly)}px`;
+ };
+ const hideTip = () => {
+ tipEl.hidden = true;
+ tracker.style("opacity", 0);
+ };
+ svg.on("mousemove", function (evt) {
+ const rect = svg.node().getBoundingClientRect();
+ const px = (evt.clientX - rect.left) * (width / rect.width);
+ if (px < m.l || px > width - m.r) { hideTip(); return; }
+ // nearest date by x position
+ let best = dates[0], bestDist = Infinity;
+ for (const d of dates) {
+ const dist = Math.abs(x(d) - px);
+ if (dist < bestDist) { bestDist = dist; best = d; }
+ }
+ showTip(evt, best);
+ });
+ svg.on("mouseleave", hideTip);
}
function renderTrend() {
@@ -592,8 +564,8 @@ function renderTrend() {
const tGeo = geoSeries.map((s) => ({ ...s, values: s.values.slice(firstAct) }));
const tDis = disSeries.map((s) => ({ ...s, values: s.values.slice(firstAct) }));
- renderTrendPanel("#trendGeo", "#trendLegendGeo", tdata, tGeo, bucket);
- renderTrendPanel("#trendDisease", "#trendLegendDisease", tdata, tDis, bucket);
+ renderTrendPanel("#trendGeo", "#trendTipGeo", tdata, tGeo, bucket);
+ renderTrendPanel("#trendDisease", "#trendTipDisease", tdata, tDis, bucket);
const endIso = (agg && agg.as_of) || (tdata.length ? tdata[tdata.length - 1].date : "");
const startIso = tdata.length ? tdata[0].date : endIso;
@@ -678,73 +650,118 @@ function renderDiseaseGrid() {
c.addEventListener("click", () => openDrawer(c.dataset.id)));
}
-/* ---------- NEWS PULSE (grouped by kind, disease sub-grouped by pathogen) ---------- */
-function renderNewsPulse() {
- const items = getFiltered().flatMap((i) =>
- (i.news || []).map((n) => ({ ...n, incident: i })));
- items.sort((a, b) => (b.published_date || "").localeCompare(a.published_date || ""));
-
- const totalDigest = STATE.digest.summary.news_total || 0;
- const outlets = [...new Set(items.map((n) => n.outlet).filter(Boolean))];
- const dates = items.map((n) => n.published_date).filter(Boolean).sort();
- const range = dates.length ? `${fmtDate(dates[0])} → ${fmtDate(dates[dates.length - 1])}` : "—";
- const cap = 200;
-
- $("#cntNews").textContent = items.length;
- $("#newsEmpty").hidden = items.length > 0;
- $("#newsSummary").innerHTML = `
- ${items.length} article(s) linked to the filtered incidents
- ${outlets.length} outlet(s)
- published: ${range}
- showing ${Math.min(items.length, cap)} of ${items.length}${totalDigest ? ` (digest total ${totalDigest})` : ""}
- Grouped by incident kind (disease split by pathogen), largest coverage first · click a bar to expand · click a row to open the linked incident.`;
-
- // group: disease -> pathogen name; otherwise incident_type
- const groups = new Map();
- items.forEach((n) => {
- const inc = n.incident;
- const isDisease = inc.is_disease;
- const key = isDisease ? ("Disease: " + (inc.disease_name || "Disease")) : inc.incident_type;
- const label = isDisease ? (inc.disease_name || "Disease") : inc.incident_type;
- const color = typeColor(isDisease ? "Disease" : inc.incident_type);
- let g = groups.get(key);
- if (!g) { g = { key, label, color, items: [], incidents: new Set() }; groups.set(key, g); }
- g.items.push(n); g.incidents.add(inc.incident_id);
+/* ---------- RECENT ACTIVITY (48h) ---------- */
+function renderRecentActivity() {
+ // cutoff = digest.as_of - 48h; fall back to latest log/news date if as_of missing
+ const asOf = STATE.digest.as_of || STATE.digest.report_date || "";
+ const asOfDt = asOf ? new Date(asOf.endsWith("Z") ? asOf : asOf + "T00:00:00Z") : null;
+ const cutoff = asOfDt ? new Date(asOfDt.getTime() - 48 * 3600 * 1000) : null;
+ const within = (iso) => {
+ if (!iso || !cutoff) return false;
+ const dt = new Date((iso.length === 10 ? iso + "T00:00:00Z" : iso));
+ return dt >= cutoff && dt <= asOfDt;
+ };
+
+ // collect events from filtered incidents
+ const events = [];
+ getFiltered().forEach((inc) => {
+ const id = inc.incident_id;
+ const name = inc.canonical_name || id;
+ const country = inc.iso2 === "XX" ? "Global" : (inc.country || "");
+ // new incident: first_reported_date within 48h
+ if (within(inc.first_reported_date || inc.event_date)) {
+ events.push({
+ ts: inc.first_reported_date || inc.event_date,
+ kind: "NEW",
+ title: "New incident reported",
+ detail: name,
+ country,
+ severity: inc.severity,
+ incident_id: id,
+ });
+ }
+ // new logs
+ (inc.logs || []).forEach((log) => {
+ if (!within(log.log_date)) return;
+ events.push({
+ ts: log.log_date,
+ kind: "LOG",
+ title: "New log written",
+ detail: (log.summary || "").slice(0, 140),
+ incident_name: name,
+ country,
+ severity: inc.severity,
+ incident_id: id,
+ });
+ });
+ // new news (group as one event per incident per day with count)
+ const freshNews = (inc.news || []).filter((n) => within(n.published_date));
+ if (freshNews.length) {
+ // group by date for a concise per-day count
+ const byDay = new Map();
+ freshNews.forEach((n) => {
+ const d = (n.published_date || "").slice(0, 10);
+ if (!byDay.has(d)) byDay.set(d, []);
+ byDay.get(d).push(n);
+ });
+ [...byDay.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1)).forEach(([d, items]) => {
+ events.push({
+ ts: items[0].published_date,
+ kind: "NEWS",
+ title: `${items.length} new article${items.length === 1 ? "" : "s"}`,
+ detail: items[0].headline || name,
+ incident_name: name,
+ country,
+ severity: inc.severity,
+ incident_id: id,
+ });
+ });
+ }
});
- const groupList = [...groups.values()].map((g) => ({
- key: g.key, label: g.label, color: g.color, items: g.items, total: g.items.length, incidentCount: g.incidents.size,
- })).sort((a, b) => b.total - a.total);
-
- const maxTotal = groupList.length ? groupList[0].total : 1;
- const shown = (arr) => arr.slice(0, cap);
- $("#newsPulse").innerHTML = groupList.map((g) => `
-
-
-
-
- ${esc(g.label)}
- ${g.total} news · ${g.incidentCount} incident(s)
-
-
-
- ${shown(g.items).map((n) => `
-
-
${fmtDate(n.published_date) || "—"}
-
-
- ${esc(n.outlet || "")} · ${esc(n.incident.country)} · ${esc(n.incident.incident_id)}
-
-
${n.incident.severity}
-
`).join("")}
-
- `).join("");
- // persist expansion across re-renders
- $$("#newsPulse details.news-group").forEach((d) =>
- d.addEventListener("toggle", () => {
- if (d.open) STATE.newsOpen.add(d.dataset.key); else STATE.newsOpen.delete(d.dataset.key);
- }));
- $$("#newsPulse .news-row").forEach((row) =>
- row.addEventListener("click", (e) => { if (e.target.tagName !== "A") openDrawer(row.dataset.id); }));
+
+ events.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
+
+ $("#cntRecent").textContent = events.length;
+ $("#recentEmpty").hidden = events.length > 0;
+
+ // summary line
+ const newCt = events.filter((e) => e.kind === "NEW").length;
+ const logCt = events.filter((e) => e.kind === "LOG").length;
+ const newsCt = events.filter((e) => e.kind === "NEWS").length;
+ const cutoffLbl = cutoff ? cutoff.toISOString().slice(0, 10) : "—";
+ $("#recentSummary").innerHTML = `
+
+ ${events.length} events
+ since ${cutoffLbl}
+
+ NEW ${newCt}
+ LOG ${logCt}
+ NEWS ${newsCt}
+
+
`;
+
+ const BADGE = { NEW: "recent-badge--new", LOG: "recent-badge--log", NEWS: "recent-badge--news" };
+ const LABEL = { NEW: "NEW", LOG: "LOG", NEWS: "NEWS" };
+ $("#recentFeed").innerHTML = events.map((e) => {
+ const tsLbl = (e.ts || "").slice(0, 16).replace("T", " ") || "—";
+ const name = e.incident_name || e.detail;
+ const detailLine = e.kind === "NEW" ? e.detail : e.detail;
+ return `
+
+
${esc(tsLbl)}
+
${LABEL[e.kind]}
+
+ ${esc(name)}
+ ${esc(detailLine)}
+
+
+ ${esc(e.severity)} · ${esc(e.country)}
+
+
`;
+ }).join("");
+
+ $$("#recentFeed .recent-row").forEach((row) =>
+ row.addEventListener("click", () => openDrawer(row.dataset.id)));
}
/* ---------- DRAWER ---------- */
@@ -934,13 +951,13 @@ function wireGlobalEvents() {
const set = STATE.filters.severities;
set.has(sev) ? set.delete(sev) : set.add(sev);
if (set.size === 0) SEV_ORDER.forEach((s) => set.add(s)); // never empty
- STATE.kpiSel.sev = null; renderSevChips(); renderAll();
+ renderSevChips(); renderAll();
});
$("#typeChips").addEventListener("click", (e) => {
const b = e.target.closest(".chip"); if (!b) return;
const v = b.dataset.val, set = STATE.filters.types;
set.has(v) ? set.delete(v) : set.add(v);
- STATE.kpiSel.type = null; renderTypeChips(); renderAll();
+ renderTypeChips(); renderAll();
});
$("#regionChips").addEventListener("click", (e) => {
const b = e.target.closest(".chip"); if (!b) return;
@@ -978,23 +995,13 @@ function wireGlobalEvents() {
renderTrend();
});
- // KPI actions (click + keyboard)
- $("#kpis").addEventListener("click", (e) => {
- const k = e.target.closest(".kpi.is-action"); if (!k) return;
- applyKpiAction(k.dataset.action);
- });
- $("#kpis").addEventListener("keydown", (e) => {
- if (e.key !== "Enter" && e.key !== " ") return;
- const k = e.target.closest(".kpi.is-action"); if (!k) return;
- e.preventDefault(); applyKpiAction(k.dataset.action);
- });
+ // KPI sidebar removed — filters handled by chips above
$("#clearFilters").addEventListener("click", () => {
STATE.filters = { severities: new Set(SEV_ORDER), types: new Set(), regions: new Set(), q: "" };
- STATE.kpiSel = { sev: null, sort: null, type: null };
STATE.sort = { key: "severity", dir: "asc" };
$("#searchFilter").value = "";
- renderSevChips(); renderTypeChips(); renderRegionChips(); renderAll(); selectTab("news");
+ renderSevChips(); renderTypeChips(); renderRegionChips(); renderAll(); selectTab("watchlist");
});
// table sort
@@ -1003,11 +1010,11 @@ function wireGlobalEvents() {
const key = th.dataset.sort;
if (STATE.sort.key === key) STATE.sort.dir = STATE.sort.dir === "asc" ? "desc" : "asc";
else { STATE.sort.key = key; STATE.sort.dir = "asc"; }
- STATE.kpiSel.sort = null; renderWatchlist(); renderKPIs();
+ renderWatchlist();
}));
// tabs
- $$(".tab").forEach((t) => t.addEventListener("click", () => { STATE.kpiSel.type = null; selectTab(t.dataset.tab); renderKPIs(); }));
+ $$(".tab").forEach((t) => t.addEventListener("click", () => { selectTab(t.dataset.tab); }));
// drawer close
$("#drawerClose").addEventListener("click", closeDrawer);
diff --git a/dashboard/index.html b/dashboard/index.html
index b26220e6..44713445 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -64,12 +64,24 @@ Disaster Surveillance
-
-
+
+
+
+
Geographic overview
+ Bubble = country · colour = max severity · size = news volume · click to filter
+
+
+
+
+
+
Trend
- Cumulative history · log scale · hover for exact counts
+ Daily · log scale · hover for exact counts
@@ -82,47 +94,30 @@
Trend
-
+
-
Geophysical events by kind
-
+
Disease outbreaks by pathogen · hover for detail
-
+
+
-
+
-
Disease outbreaks by pathogen
-
+
Geophysical events by kind · hover for detail
-
+
+
-
-
-
-
Geographic overview
- Bubble = country · colour = max severity · size = news volume · click to filter
-
-
-
-
-
-
-
-
+
@@ -154,11 +149,11 @@ Geographic overview
No active disease outbreaks in this digest.
-
-
-
-
-
No linked news in this digest.
+
+
+
+
+
No activity in the last 48 hours.
diff --git a/dashboard/styles.css b/dashboard/styles.css
index 83ee19bc..e84a9490 100644
--- a/dashboard/styles.css
+++ b/dashboard/styles.css
@@ -231,31 +231,7 @@ code { font-family: var(--mono); font-size: .9em; }
}
.kpi.is-action:hover .kpi__hint { opacity: 1; }
-/* ============ KPI SIDEBAR (beside map) ============ */
-.card--kpi-side { grid-column: span 5; align-self: stretch; padding: 0; display: flex; }
-.card--kpi-side > .kpis {
- flex: 1; display: flex; flex-direction: column;
- margin: 0; padding: 14px; gap: 10px; max-width: none;
-}
-.kpi-axis {
- display: flex; flex-direction: column;
- flex: 1 1 0; min-height: 0;
-}
-.kpi-axis + .kpi-axis { margin-top: 0; padding-top: 10px; border-top: 1px solid var(--rc-line); }
-.kpi-axis__label {
- font-size: 10px; font-weight: 700; color: var(--rc-muted);
- text-transform: uppercase; letter-spacing: .08em; margin: 0 0 8px 2px;
-}
-.kpis--pair {
- flex: 1; margin: 0; padding: 0; max-width: none;
- display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
-}
-.card--kpi-side .kpi {
- padding: 10px 12px; display: flex; flex-direction: column; justify-content: center;
-}
-.card--kpi-side .kpi__value { font-size: 26px; }
-.card--kpi-side .kpi__label { margin-top: 4px; font-size: 11px; }
-.card--kpi-side .kpi__sub { margin-top: 1px; font-size: 10px; }
+/* ============ (KPI sidebar removed — redundant with top filters) ============ */
.kpi--active::before { background: var(--rc-red); }
/* selected toggle state */
@@ -297,8 +273,8 @@ code { font-family: var(--mono); font-size: .9em; }
display: flex; flex-direction: column; overflow: hidden;
}
.span-full { grid-column: span 12; }
-.card--map { grid-column: span 7; }
-.card--trend { grid-column: span 12; }
+.card--map { grid-column: span 6; }
+.card--trend { grid-column: span 6; }
.card--table { grid-column: span 12; }
.card__head {
display: flex; align-items: baseline; justify-content: space-between;
@@ -324,24 +300,32 @@ code { font-family: var(--mono); font-size: .9em; }
/* ============ TREND PANELS ============ */
.trend-grid {
- display: grid; grid-template-columns: 1fr 1fr; gap: 0;
+ display: grid; grid-template-columns: 1fr; gap: 0;
border-top: 1px solid var(--rc-line);
}
-.trend-panel { padding: 12px 18px 16px; min-width: 0; }
-.trend-panel + .trend-panel { border-left: 1px solid var(--rc-line); }
+.trend-panel { position: relative; padding: 12px 18px 16px; min-width: 0; }
+.trend-panel + .trend-panel { border-left: none; border-top: 1px solid var(--rc-line); }
.trend-panel__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
.trend-panel__head h3 { font-size: 13px; font-weight: 700; color: var(--rc-ink); }
.trend-panel__sub { font-size: 11px; font-weight: 500; color: var(--rc-muted); }
.trend-panel svg { width: 100%; height: 240px; display: block; }
-/* ============ TREND LEGEND ============ */
-.trend-legend {
- display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end;
- padding: 0; font-size: 11px; color: var(--rc-slate);
-}
-.trend-legend__item { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
-.trend-legend__swatch { width: 16px; height: 3px; border-radius: 2px; }
+/* ============ TREND TOOLTIP ============ */
+.trend-tooltip {
+ position: absolute; pointer-events: none; z-index: 5;
+ background: var(--rc-surface, #fff); border: 1px solid var(--rc-line-strong, #d0d4d9);
+ border-radius: var(--radius-sm, 6px); padding: 8px 10px; font-size: 11px;
+ color: var(--rc-ink, #1f2328); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ min-width: 160px; max-width: 260px; line-height: 1.45;
+}
+.trend-tooltip__date { font-weight: 700; margin-bottom: 4px; padding-bottom: 4px; border-bottom: 1px solid var(--rc-line, #e8eaed); }
+.trend-tooltip__row { display: flex; align-items: center; gap: 6px; padding: 1px 0; }
+.trend-tooltip__swatch { width: 14px; height: 3px; border-radius: 2px; flex-shrink: 0; }
+.trend-tooltip__name { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.trend-tooltip__value { font-variant-numeric: tabular-nums; color: var(--rc-slate, #4a5158); }
+.trend-tooltip__empty { color: var(--rc-muted, #6e7681); font-style: italic; }
.trend-axis-label { font-size: 10px; fill: #6E6E6E; }
+.trend-tracker { stroke: var(--rc-ink, #1f2328); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0.4; pointer-events: none; }
/* ============ TREND CONTROLS ============ */
.card__head--trend { flex-wrap: wrap; gap: 8px 16px; align-items: center; }
@@ -483,47 +467,48 @@ code { font-family: var(--mono); font-size: .9em; }
.tag--pp { background: #FBEEE6; color: #9A4A12; }
.tag--status { background: var(--rc-red-tint); color: var(--rc-red); }
-/* ============ NEWS PULSE ============ */
-.news-pulse { margin: 0; padding: 0; max-height: 600px; overflow-y: auto; }
-.news-row {
- padding: 12px 18px; border-bottom: 1px solid var(--rc-line);
- display: grid; grid-template-columns: 96px 1fr auto; gap: 14px; align-items: start;
+/* ============ RECENT ACTIVITY (48h) ============ */
+.recent-summary {
+ display: flex; align-items: center;
+ padding: 10px 18px; border-bottom: 1px solid var(--rc-line);
+ background: #FAFBFC;
+}
+.recent-stats { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
+.recent-stats__total { font-size: 14px; color: var(--rc-ink); }
+.recent-stats__total b { font-weight: 700; }
+.recent-stats__since { font-size: 11px; color: var(--rc-muted); font-family: var(--mono); }
+.recent-stats__breakdown { display: inline-flex; gap: 6px; }
+.recent-stats__chip {
+ display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm, 4px);
+ font-size: 10px; font-weight: 700; letter-spacing: 0.4px; font-family: var(--mono);
+}
+.recent-feed { margin: 0; padding: 0; max-height: 600px; overflow-y: auto; }
+.recent-row {
+ padding: 10px 18px; border-bottom: 1px solid var(--rc-line);
+ display: grid; grid-template-columns: 96px 56px 1fr auto; gap: 12px; align-items: start;
cursor: pointer;
}
-.news-row:hover { background: #FAFBFC; }
-.news-date { font-size: 11px; color: var(--rc-muted); font-family: var(--mono); padding-top: 2px; }
-.news-headline { font-size: 13px; font-weight: 600; }
-.news-headline a { color: var(--rc-ink); text-decoration: none; }
-.news-headline a:hover { color: var(--rc-red); text-decoration: underline; }
-.news-outlet { font-size: 11px; color: var(--rc-muted); margin-top: 3px; }
-.news-link { padding-top: 2px; }
-.news-sev { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: var(--rc-muted); white-space: nowrap; }
-
-.news-group { border-bottom: 1px solid var(--rc-line); }
-.news-group > summary { list-style: none; }
-.news-group > summary::-webkit-details-marker { display: none; }
-.news-group__head {
- position: sticky; top: 0; z-index: 2; cursor: pointer;
- display: flex; align-items: center; gap: 10px;
- padding: 9px 18px; background: #F2F4F7; border-bottom: 1px solid var(--rc-line);
- font-size: 12px; font-weight: 700; color: var(--rc-ink); user-select: none;
-}
-.news-group__head:hover { background: #EAEDF1; }
-.news-group__head:focus-visible { outline: 2px solid var(--rc-red); outline-offset: -2px; }
-.news-group__chev {
- width: 0; height: 0; flex: none;
- border-left: 5px solid var(--rc-slate);
- border-top: 4px solid transparent; border-bottom: 4px solid transparent;
- transition: transform .14s ease;
-}
-.news-group[open] > .news-group__head .news-group__chev { transform: rotate(90deg); }
-.news-group__swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
-.news-group__name { flex: none; }
-.news-group__count { font-weight: 600; color: var(--rc-muted); font-size: 11px; }
-.news-group__bar { flex: 1; height: 4px; background: #E3E5E8; border-radius: 2px; overflow: hidden; max-width: 220px; min-width: 40px; }
-.news-group__bar > span { display: block; height: 100%; background: var(--rc-red); }
-.news-group__items { background: #fff; }
-.news-pulse { max-height: 600px; }
+.recent-row:hover { background: #FAFBFC; }
+.recent-ts { font-size: 11px; color: var(--rc-muted); font-family: var(--mono); padding-top: 2px; }
+.recent-badge {
+ display: inline-block; padding: 2px 6px; border-radius: var(--radius-sm, 4px);
+ font-size: 10px; font-weight: 700; letter-spacing: 0.5px; text-align: center;
+ font-family: var(--mono);
+}
+.recent-badge--new { background: #FCEEEE; color: var(--rc-red, #cf222e); border: 1px solid #F4D4D4; }
+.recent-badge--log { background: #EEF4FC; color: #1864AB; border: 1px solid #C7DCF2; }
+.recent-badge--news { background: #F2F4EE; color: #44612A; border: 1px solid #D4DEC4; }
+.recent-main { min-width: 0; }
+.recent-name { font-size: 13px; font-weight: 600; color: var(--rc-ink); }
+.recent-detail {
+ font-size: 11px; color: var(--rc-muted); margin-top: 3px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.recent-meta {
+ display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
+ font-size: 11px; font-weight: 600; color: var(--rc-muted);
+}
+.recent-feed { max-height: 600px; }
/* ============ DRAWER ============ */
.scrim {
@@ -625,7 +610,7 @@ code { font-family: var(--mono); font-size: .9em; }
}
@media (max-width: 1100px) {
.kpis { grid-template-columns: repeat(3, 1fr); }
- .card--map, .card--trend, .card--kpi-side { grid-column: span 12; }
+ .card--map, .card--trend { grid-column: span 12; }
}
@media (max-width: 900px) {
.trend-grid { grid-template-columns: 1fr; }
@@ -639,18 +624,9 @@ code { font-family: var(--mono); font-size: .9em; }
.topbar__controls { gap: 8px; }
.topbar__controls .freshness { display: none; }
.layout, .footer { padding-left: 16px; padding-right: 16px; }
- .news-row { grid-template-columns: 1fr; gap: 4px; }
+ .recent-row { grid-template-columns: 1fr; gap: 4px; }
.drawer { width: 98vw; max-height: 95vh; }
.filters { padding: 8px 12px; gap: 6px 12px; }
.filter-group__label { font-size: 9px; }
.chip { padding: 5px 8px; font-size: 10px; }
- .card--kpi-side > .kpis { padding: 8px; gap: 6px; flex-direction: row; }
- .kpi-axis { flex: 1 1 0; }
- .kpi-axis + .kpi-axis { padding-top: 0; padding-left: 6px; border-top: none; border-left: 1px solid var(--rc-line); }
- .kpi-axis__label { font-size: 8px; margin-bottom: 4px; }
- .kpis--pair { grid-template-columns: 1fr; gap: 4px; }
- .card--kpi-side .kpi { padding: 5px 6px; }
- .card--kpi-side .kpi__value { font-size: 16px; }
- .card--kpi-side .kpi__label { font-size: 8px; margin-top: 1px; }
- .card--kpi-side .kpi__sub { font-size: 7px; margin-top: 0; }
}