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

- -
-
-
- + +
+
+
+
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; } }