import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { scaleLinear } from "d3-scale"; import { interpolateRgb } from "d3-interpolate"; import { fetchTimeline } from "../api/client.js"; import { getCachedTimeline, setCachedTimeline } from "../utils/investigationCache.js"; import { viewportTooltipPosition } from "../utils/viewportTooltip.js"; import { getNewsTimeframeLabel } from "../constants/timeframes.js"; import "./TimelineView.css"; // Sentiment color: red (negative) → gold (mixed) → teal (positive) const sentimentColor = scaleLinear() .domain([-1, 0, 1]) .range(["#E05252", "#C8A96E", "#4A9E8A"]) .interpolate(interpolateRgb) .clamp(true); /** Avoid d3 degenerate domains when all points share the same time (ms). */ function padTimeDomain(minTs, maxTs) { const min = Number(minTs); const max = Number(maxTs); if (!Number.isFinite(min) || !Number.isFinite(max)) { const now = Date.now(); return [now - 7 * 86400000, now]; } if (min >= max) { const half = 12 * 3600000; return [min - half, max + half]; } const span = max - min; const pad = Math.max(span * 0.04, 3600000); return [min - pad, max + pad]; } function safeSentiment(v) { const n = Number(v); return Number.isFinite(n) ? Math.max(-1, Math.min(1, n)) : 0; } /** News scatter height: bounded from width only (do not use full page height). */ function newsChartHeightPx(width) { const w = Math.max(320, width); return Math.min(340, Math.max(240, Math.round(w * 0.26))); } export default function TimelineView({ query }) { const plotClipId = useId().replace(/:/g, ""); const newsClipUrl = `url(#tl-${plotClipId}-news)`; const interestClipUrl = `url(#tl-${plotClipId}-interest)`; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [size, setSize] = useState({ w: 1100, newsH: newsChartHeightPx(1100) }); const [hover, setHover] = useState(null); const containerRef = useRef(null); const timelineTooltipRef = useRef(null); const topic = query?.topic?.trim() ?? ""; const newsTimeframe = query?.newsTimeframe ?? "6m"; useEffect(() => { let cancelled = false; if (!topic) { setData(null); setLoading(false); return () => { cancelled = true; }; } const cached = getCachedTimeline(topic, newsTimeframe); if (cached) { setData(cached); setLoading(false); return () => { cancelled = true; }; } setLoading(true); fetchTimeline({ topic, newsTimeframe }) .then((d) => { if (cancelled) return; setData(d); setCachedTimeline(topic, newsTimeframe, d); }) .finally(() => !cancelled && setLoading(false)); return () => { cancelled = true; }; }, [topic, newsTimeframe]); useEffect(() => { const el = containerRef.current; if (!el) return; let debounceT = 0; let raf = 0; const applyWidth = (w) => { const nextW = Math.max(320, Math.floor(w) || 1100); setSize({ w: nextW, newsH: newsChartHeightPx(nextW) }); }; /** Debounce: mobile chrome + layout thrash fires ResizeObserver in bursts. */ const schedule = (w) => { window.clearTimeout(debounceT); debounceT = window.setTimeout(() => { if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { raf = 0; applyWidth(w); }); }, 100); }; const updateFromRect = () => { schedule(el.getBoundingClientRect().width); }; updateFromRect(); if (typeof ResizeObserver === "undefined") { window.addEventListener("resize", updateFromRect); return () => { window.removeEventListener("resize", updateFromRect); window.clearTimeout(debounceT); if (raf) cancelAnimationFrame(raf); }; } const ro = new ResizeObserver((entries) => { schedule(entries[0].contentRect.width); }); ro.observe(el); return () => { ro.disconnect(); window.clearTimeout(debounceT); if (raf) cancelAnimationFrame(raf); }; }, []); const normalizedData = useMemo(() => normalizeTimelineData(data, query), [data, query]); const { xScale, yScale, rScale, ticksX, ticksY, padding, isCompact, hasPoints } = useMemo(() => { if (!normalizedData?.newsDistribution) return {}; const isCompact = size.w < 760; const padding = isCompact ? { top: 46, right: 20, bottom: 56, left: 42 } : { top: 60, right: 60, bottom: 70, left: 60 }; const points = normalizedData.newsDistribution.points ?? []; if (!points.length) { return { xScale: null, yScale: null, rScale: null, ticksX: [], ticksY: [], padding, isCompact, hasPoints: false, }; } const xMin = Math.min(...points.map((p) => p.timestamp)); const xMax = Math.max(...points.map((p) => p.timestamp)); const winS = normalizedData.newsDistribution.windowStartMs; const winE = normalizedData.newsDistribution.windowEndMs; const hasWin = Number.isFinite(winS) && Number.isFinite(winE) && Number(winE) > Number(winS); let xd0; let xd1; if (hasWin) { xd0 = Number(winS); xd1 = Number(winE); } else { [xd0, xd1] = padTimeDomain(xMin, xMax); } const yMax = Math.max(1, ...points.map((p) => Number(p.articleCount) || 0)); const innerW = size.w - padding.left - padding.right; const innerH = size.newsH - padding.top - padding.bottom; const x = scaleLinear().domain([xd0, xd1]).range([padding.left, padding.left + innerW]); const y = scaleLinear().domain([0, yMax * 1.1]).range([padding.top + innerH, padding.top]); const r = scaleLinear().domain([0, yMax]).range([2, 14]); const ticksX = x.ticks(6); const ticksY = y.ticks(5); return { xScale: x, yScale: y, rScale: r, ticksX, ticksY, padding, isCompact, hasPoints: true }; }, [normalizedData, size.w, size.newsH]); const newsWindowLabel = getNewsTimeframeLabel(query.newsTimeframe); const axisTimeframe = normalizedData?.newsDistribution?.selectedWindow ?? query?.newsTimeframe ?? "6m"; const interestSource = normalizedData?.interestOverTime?.source ?? "unknown"; const trendKeywords = normalizedData?.interestOverTime?.keywords ?? []; const trendsTimeframe = normalizedData?.interestOverTime?.trendsTimeframe ?? null; const trendsFetchError = normalizedData?.interestOverTime?.trendsFetchError ?? null; const interestPanel = useMemo(() => { const windowNote = `Uses the same range as the news chart (${newsWindowLabel}).`; if (interestSource === "google-trends") { return { title: "Google Trends", lede: `Relative search interest (Google’s 0–100 scale) for the terms below. ${windowNote}`, }; } if (interestSource === "google-trends-empty") { return { title: "Google Trends", lede: trendsTimeframe ? `No daily series from Google for ${trendsTimeframe}. ${windowNote} You can change the investigation timeframe when editing the topic.` : `No daily series for this range. ${windowNote} You can change the investigation timeframe when editing the topic.`, }; } if (interestSource === "google-trends-unavailable") { const detail = trendsFetchError === "disabled_by_config" ? "Google Trends is turned off on the server (set GOOGLE_TRENDS_ENABLED=true to enable)." : trendsFetchError === "missing_dependency" ? "The API server is missing the `pytrends-modern` or `pytrends` package (install dependencies and restart)." : trendsFetchError === "rate_limited_or_blocked" ? "Google blocked or rate-limited the request. This unofficial Trends endpoint is fragile from datacenters and under load." : trendsFetchError === "empty_keywords_or_series" ? "No usable keywords or time series came back for this topic and date span." : trendsFetchError === "timeout" ? "Google Trends exceeded the server wait limit." : trendsFetchError ? `Request failed (${trendsFetchError}). Check server logs for the full traceback.` : "Try again later, or inspect server logs if you operate this deployment."; return { title: "Google Trends", lede: `Search-interest data could not be loaded. ${detail}`, }; } if (interestSource === "derived-from-news-volume") { return { title: "Article volume", lede: `Fallback curve from indexed article dates. ${windowNote}`, }; } return { title: "Search interest", lede: `External attention signal. ${windowNote}`, }; }, [interestSource, trendsTimeframe, newsWindowLabel, trendsFetchError]); const interestSeries = useMemo(() => { const all = normalizedData?.interestOverTime?.points ?? []; if (!all.length) return []; let filtered = all.filter((p) => Number.isFinite(Number(p?.timestamp))); const winS = normalizedData?.newsDistribution?.windowStartMs; const winE = normalizedData?.newsDistribution?.windowEndMs; const hasWin = Number.isFinite(winS) && Number.isFinite(winE) && Number(winE) > Number(winS); if (hasWin) { const ws = Number(winS); const we = Number(winE); filtered = filtered.filter((p) => { const t = Number(p.timestamp); return t >= ws && t <= we; }); } return filtered; }, [normalizedData]); const interestChart = useMemo(() => { if (!interestSeries.length) return null; const isCompact = size.w < 760; const cHeight = isCompact ? 260 : 300; const chartPadding = isCompact ? { top: 24, right: 20, bottom: 44, left: 42 } : { top: 28, right: 42, bottom: 48, left: 56 }; const winS = normalizedData?.newsDistribution?.windowStartMs; const winE = normalizedData?.newsDistribution?.windowEndMs; const hasWin = Number.isFinite(winS) && Number.isFinite(winE) && Number(winE) > Number(winS); let ix0; let ix1; if (hasWin) { ix0 = Number(winS); ix1 = Number(winE); } else { const xMin = Math.min(...interestSeries.map((p) => p.timestamp)); const xMax = Math.max(...interestSeries.map((p) => p.timestamp)); [ix0, ix1] = padTimeDomain(xMin, xMax); } const yMax = Math.max(1, ...interestSeries.map((p) => Number(p.value) || 0)); const innerW = size.w - chartPadding.left - chartPadding.right; const innerH = cHeight - chartPadding.top - chartPadding.bottom; const xScale = scaleLinear() .domain([ix0, ix1]) .range([chartPadding.left, chartPadding.left + innerW]); const yScale = scaleLinear() .domain([0, yMax * 1.15]) .range([chartPadding.top + innerH, chartPadding.top]); const ticksX = xScale.ticks(6); const ticksY = yScale.ticks(4); const linePath = interestSeries .map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.timestamp)} ${yScale(Number(p.value) || 0)}`) .join(" "); const singleInterest = interestSeries.length === 1; const lone = singleInterest ? interestSeries[0] : null; return { cHeight, chartPadding, xScale, yScale, ticksX, ticksY, linePath, singleInterest, lonePoint: lone, }; }, [interestSeries, size.w, normalizedData?.newsDistribution]); useLayoutEffect(() => { if (!hover) return; const el = timelineTooltipRef.current; if (!el || hover.clientX == null || hover.clientY == null) return; const { width, height } = el.getBoundingClientRect(); const estWidth = width > 0 ? width : 236; const estHeight = height > 0 ? height : 92; const { left, top } = viewportTooltipPosition(hover.clientX, hover.clientY, { estWidth, estHeight, }); el.style.left = `${left}px`; el.style.top = `${top}px`; }, [hover]); return (
Timeline

Narrative and attention over time

News distribution

{newsWindowLabel}

Controlled by the main investigation timeframe.

{loading || !data ? (
plotting article volume
) : !hasPoints ? (
no timeline data for this query
) : ( setHover(null)} > {/* Y axis */} {ticksY.map((t) => ( {t} ))} {/* X axis */} {ticksX.map((t) => ( {formatDateTick(t, axisTimeframe)} ))} {/* Axis labels */} ARTICLE VOLUME COLOR · SENTIMENT (RED ← → TEAL) {/* Plot region only (stops scatter sitting on the y-axis when x is slightly out of domain). */} {(normalizedData.newsDistribution.events ?? []).map((e) => ( {e.label} ))} {normalizedData.newsDistribution.points.map((p, i) => { const cx = xScale(p.timestamp); const cy = yScale(p.articleCount); const r = rScale(p.articleCount); const fill = sentimentColor(safeSentiment(p.sentiment)); return ( { setHover({ ...p, sentiment: safeSentiment(p.sentiment), cx, cy, fill, clientX: ev.clientX, clientY: ev.clientY, }); }} onMouseLeave={() => setHover(null)} style={{ cursor: "pointer" }} /> ); })} )}
Interest over time

{interestPanel.title}

{interestPanel.lede}

{trendKeywords.length > 0 && (

Terms {trendKeywords.join(" · ")}

)}
{!interestChart ? (
{hover && (
{new Date(hover.timestamp).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", })}
Articles {hover.articleCount}
Sentiment {safeSentiment(hover.sentiment).toFixed(2)}
)}
); } function formatDateTick(timestamp, windowValue) { const date = new Date(timestamp); if (windowValue === "48h") { return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); } if (windowValue === "1w" || windowValue === "2w") { return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short" }); } return date.toLocaleDateString("en-GB", { month: "short", year: "2-digit" }); } function TimelineLegend() { const stops = [-1, -0.5, 0, 0.5, 1]; return (
Sentiment
{stops.map((s, i) => (
))}
negative positive
); } function defaultAvailableYears() { const currentYear = new Date().getFullYear(); const years = []; for (let yr = currentYear; yr >= 2019; yr -= 1) years.push(yr); return years; } function normalizeTimelineData(raw, query) { if (!raw) return null; // News chart must work even when interestOverTime is missing or Trends failed server-side. if (raw.newsDistribution) { const rawIo = raw.interestOverTime; const hasIo = rawIo && typeof rawIo === "object"; const interestOverTime = hasIo ? { source: "google-trends-unavailable", keywords: [], trendsTimeframe: null, trendsFetchError: null, defaultWindow: "12m", availableYears: defaultAvailableYears(), points: [], ...rawIo, points: rawIo.points ?? [], availableYears: Array.isArray(rawIo.availableYears) && rawIo.availableYears.length ? rawIo.availableYears : defaultAvailableYears(), keywords: rawIo.keywords ?? [], } : { source: "google-trends-unavailable", keywords: [], trendsTimeframe: null, trendsFetchError: null, defaultWindow: "12m", availableYears: defaultAvailableYears(), points: [], }; return { ...raw, newsDistribution: { ...raw.newsDistribution, points: raw.newsDistribution.points ?? [], events: raw.newsDistribution.events ?? [], windowStartMs: raw.newsDistribution.windowStartMs, windowEndMs: raw.newsDistribution.windowEndMs, }, interestOverTime, }; } const points = raw.points ?? []; const events = raw.events ?? []; const availableYears = defaultAvailableYears(); const byMonth = new Map(); points.forEach((point) => { const d = new Date(point.timestamp); const key = `${d.getFullYear()}-${d.getMonth()}`; const current = byMonth.get(key); if (!current) { byMonth.set(key, { timestamp: new Date(d.getFullYear(), d.getMonth(), 1).getTime(), value: Math.max(0, Math.round(point.articleCount || 0)), }); return; } current.value += Math.max(0, Math.round(point.articleCount || 0)); }); const trendPoints = Array.from(byMonth.values()).sort( (a, b) => a.timestamp - b.timestamp ); return { newsDistribution: { selectedWindow: query?.newsTimeframe ?? "6m", points, events: events ?? [], }, interestOverTime: { source: "derived-from-news-volume", keywords: [], trendsTimeframe: null, trendsFetchError: null, defaultWindow: "12m", availableYears, points: trendPoints, }, }; }