From 4e5a317b7f8ee81ce4fc6b83ced4d35bd6fb5f65 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 23 Jul 2026 03:12:40 -0400 Subject: [PATCH 1/5] fix(dashboard): replace news pulse with recent activity + trend hover tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the dashboard: 1. Default tab reverted to 'Active watchlist' (was 'news pulse' via STATE.tab override at app.js init). HTML already had watchlist as tab--active; the JS was silently overriding it. 2. News pulse tab removed. It showed a flat list of ALL cumulative news (783 items) grouped by incident type with no temporal structure — redundant with the per-incident drawer and not useful for monitoring. Replaced with 'Recent activity' (48h) tab: a chronological feed of what changed in the last 48 hours — new incidents (NEW badge), new logs written (LOG badge), and news bursts grouped per incident per day (NEWS badge). Each row links to the incident drawer. Summary line shows counts by kind and the cutoff date. 3. Trend chart legends removed. The disease panel enumerated up to 17 pathogen names with totals and peaks, consuming significant chart vertical space. Replaced with a hover tooltip overlay: moving the cursor over either trend panel (geophysical or disease) shows a positioned HTML tooltip with the date and all active series values at that x position, plus a vertical tracker line. Only series with non-zero totals in the window are plotted and listed in the tooltip. Files changed: - dashboard/index.html: tab buttons, panels, trend panel structure - dashboard/app.js: renderTrendPanel rewrite (tooltip), renderRecentActivity (new), renderNewsPulse removed, STATE.tab default, STATE.newsOpen removed, stale 'news' tab references in KPI click handlers updated to 'watchlist' - dashboard/styles.css: trend-tooltip + recent-feed styles, news-pulse styles removed --- dashboard/app.js | 272 +++++++++++++++++++++++++++++-------------- dashboard/index.html | 26 ++--- dashboard/styles.css | 91 +++++++-------- 3 files changed, 240 insertions(+), 149 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index bdb21651..a0752a48 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -75,9 +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 +106,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}`); } @@ -241,7 +240,7 @@ function renderAll() { renderTrend(); renderWatchlist(); renderDiseaseGrid(); - renderNewsPulse(); + renderRecentActivity(); } /* ---------- filters ---------- */ @@ -354,7 +353,7 @@ function applyKpiAction(act) { k.sev = null; f.severities = new Set(SEV_ORDER); showToast("Severity filter cleared"); - targetTab = allClear() ? "news" : null; + targetTab = allClear() ? "watchlist" : null; } renderSevChips(); renderAll(); } else if (act.startsWith("sort:")) { @@ -368,16 +367,16 @@ function applyKpiAction(act) { } else { k.sort = null; STATE.sort = { key: "severity", dir: "asc" }; showToast("Sort reset to severity"); - targetTab = allClear() ? "news" : null; + targetTab = allClear() ? "watchlist" : null; } renderWatchlist(); renderKPIs(); } else if (act === "type:ALL") { - if (k.type === "ALL") { k.type = null; targetTab = allClear() ? "news" : null; } + if (k.type === "ALL") { k.type = null; targetTab = allClear() ? "watchlist" : 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; } + if (k.type === name) { k.type = null; targetTab = allClear() ? "watchlist" : null; } else { k.type = name; if (name === "disease") { f.types = new Set(); renderTypeChips(); } renderAll(); targetTab = name; } renderKPIs(); } @@ -506,7 +505,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 +516,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 +544,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 +567,66 @@ 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 `
+ + ${esc(s.key)} + ${c} +
`; + }).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 +649,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 +735,112 @@ 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} event(s) in the last 48h (since ${cutoffLbl}) + ${newCt} new incident(s) · ${logCt} new log(s) · ${newsCt} news burst(s) + Newest first · click a row to open the incident · grouped chronologically.`; + + 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 ---------- */ @@ -994,7 +1090,7 @@ function wireGlobalEvents() { 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 diff --git a/dashboard/index.html b/dashboard/index.html index b26220e6..10547890 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -69,7 +69,7 @@

Disaster Surveillance

Trend

- Cumulative history · log scale · hover for exact counts + Daily · log scale · hover for exact counts
@@ -82,19 +82,19 @@

Trend

-
+
-

Geophysical events by kind

- +

Geophysical events by kind · hover for detail

+
-
+
-

Disease outbreaks by pathogen

- +

Disease outbreaks by pathogen · hover for detail

+
@@ -122,7 +122,7 @@

Geographic overview

- +
@@ -154,11 +154,11 @@

Geographic overview

- -
-
-
- + +
+
+
+
diff --git a/dashboard/styles.css b/dashboard/styles.css index 83ee19bc..1831fcc1 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -327,21 +327,29 @@ code { font-family: var(--mono); font-size: .9em; } display: grid; grid-template-columns: 1fr 1fr; gap: 0; border-top: 1px solid var(--rc-line); } -.trend-panel { padding: 12px 18px 16px; min-width: 0; } +.trend-panel { position: relative; padding: 12px 18px 16px; min-width: 0; } .trend-panel + .trend-panel { border-left: 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 +491,34 @@ 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-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 { @@ -639,7 +634,7 @@ 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; } From d07126417bb00a3a3f633e3fb8022a82660ebe00 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 23 Jul 2026 03:17:03 -0400 Subject: [PATCH 2/5] fix(dashboard): sort trend tooltip rows by value desc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooltip rows were in series-volume order (largest cumulative total first). Now sorted by the actual count at the hovered date, descending — so the tallest line on the chart at that x position is listed first. --- dashboard/app.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index a0752a48..1bf3f323 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -587,12 +587,13 @@ function renderTrendPanel(svgSel, tooltipSel, tdata, series, bucket) { const rows = plotted.map((s) => { const v = s.values.find((vv) => vv.date === iso); const c = v ? v.count : 0; - return `
- - ${esc(s.key)} - ${c} -
`; - }).join(""); + 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) => { From 6b97b8e63bb9e81721410610b99f0cf0fb1681ce Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 23 Jul 2026 03:18:53 -0400 Subject: [PATCH 3/5] fix(dashboard): cleaner recent activity summary strip Replace the cramped news-summary flex layout with a dedicated recent-summary style: total count + cutoff date + 3 colored stat chips (NEW/LOG/NEWS with counts). Drops the verbose parenthetical text and the redundant explanation line. --- dashboard/app.js | 12 +++++++++--- dashboard/styles.css | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 1bf3f323..17a24d9d 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -816,9 +816,15 @@ function renderRecentActivity() { const newsCt = events.filter((e) => e.kind === "NEWS").length; const cutoffLbl = cutoff ? cutoff.toISOString().slice(0, 10) : "—"; $("#recentSummary").innerHTML = ` - ${events.length} event(s) in the last 48h (since ${cutoffLbl}) - ${newCt} new incident(s) · ${logCt} new log(s) · ${newsCt} news burst(s) - Newest first · click a row to open the incident · grouped chronologically.`; +
+ ${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" }; diff --git a/dashboard/styles.css b/dashboard/styles.css index 1831fcc1..997b9c84 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -492,6 +492,20 @@ code { font-family: var(--mono); font-size: .9em; } .tag--status { background: var(--rc-red-tint); color: var(--rc-red); } /* ============ 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); From 26a3eaa030fd172c7a16312d417dba387490a1d4 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 23 Jul 2026 03:38:21 -0400 Subject: [PATCH 4/5] fix(dashboard): remove KPI sidebar, map+trend side-by-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the KPI sidebar (aside.card--kpi-side) — redundant with the top severity/type/region filter chips. - Drop renderKPIs(), applyKpiAction(), STATE.kpiSel and all #kpis event wiring from app.js. - Remove .card--kpi-side / .kpi-axis / .kpis--pair / .card--kpi-side .kpi CSS (kept base .kpi* rules for any reuse). - Reorder HTML so map (span 6, left) precedes trend (span 6, right). - Stack the two trend panels vertically (.trend-grid 1fr) inside the now-narrower span-6 trend card. - Update responsive breakpoints to drop the kpi-side rules. --- dashboard/app.js | 106 ++----------------------------------------- dashboard/index.html | 33 ++++++-------- dashboard/styles.css | 45 +++--------------- 3 files changed, 25 insertions(+), 159 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 17a24d9d..6bde0507 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -77,7 +77,6 @@ const STATE = { sort: { key: "severity", dir: "asc" }, tab: "watchlist", trend: { window: "30", metric: "n" }, // metric: n(news)|e(events); two panels: disease(by pathogen) + geo(by kind) - kpiSel: { sev: null, sort: null, type: null }, // explicit per-axis KPI selection (null = none) }; if (typeof window !== "undefined") window.STATE = STATE; // debug hook @@ -226,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(); @@ -235,7 +233,6 @@ async function loadDigest(file) { /* ---------- top-level render ---------- */ function renderAll() { - renderKPIs(); renderMap(); renderTrend(); renderWatchlist(); @@ -300,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() ? "watchlist" : 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() ? "watchlist" : null; - } - renderWatchlist(); renderKPIs(); - } else if (act === "type:ALL") { - if (k.type === "ALL") { k.type = null; targetTab = allClear() ? "watchlist" : 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() ? "watchlist" : 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); @@ -1037,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; @@ -1081,20 +995,10 @@ 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("watchlist"); @@ -1106,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 10547890..7511f2ea 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -64,8 +64,20 @@

Disaster Surveillance

- -
+ +
+
+

Geographic overview

+ Bubble = country · colour = max severity · size = news volume · click to filter +
+
+ +
+
+
+ + +

Trend

@@ -99,23 +111,6 @@

Disease outbreaks by pathogen · hover for de

- -
-
-

Geographic overview

- Bubble = country · colour = max severity · size = news volume · click to filter -
-
- -
-
-
- - - -
diff --git a/dashboard/styles.css b/dashboard/styles.css index 997b9c84..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,11 +300,11 @@ 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 { position: relative; padding: 12px 18px 16px; min-width: 0; } -.trend-panel + .trend-panel { border-left: 1px solid var(--rc-line); } +.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); } @@ -634,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; } @@ -653,13 +629,4 @@ code { font-family: var(--mono); font-size: .9em; } .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; } } From f945bcd3d8435d91d2470a1aa35fa35ac568f590 Mon Sep 17 00:00:00 2001 From: nullhack Date: Thu, 23 Jul 2026 03:45:08 -0400 Subject: [PATCH 5/5] fix(dashboard): disease trend on top, geophysical on bottom --- dashboard/index.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/dashboard/index.html b/dashboard/index.html index 7511f2ea..44713445 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -94,13 +94,6 @@

Trend

-
-
-

Geophysical events by kind · hover for detail

-
- - -

Disease outbreaks by pathogen · hover for detail

@@ -108,6 +101,13 @@

Disease outbreaks by pathogen · hover for de

+
+
+

Geophysical events by kind · hover for detail

+
+ + +