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 (
Controlled by the main investigation timeframe.
{interestPanel.lede}
{trendKeywords.length > 0 && (Terms {trendKeywords.join(" · ")}
)}