Horizon / src /pages /TimelineView.jsx
NKessler's picture
Upload 6270 files
4083225 verified
Raw
History Blame Contribute Delete
27.4 kB
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 (
<div className="timeline-view" ref={containerRef}>
<header className="timeline-view__header">
<div>
<div className="label">Timeline</div>
<h1 className="timeline-view__title">Narrative and attention over time</h1>
</div>
</header>
<section className="timeline-card">
<div className="timeline-card__head">
<div>
<div className="label">News distribution</div>
<h2 className="timeline-card__title">{newsWindowLabel}</h2>
</div>
<p className="timeline-card__subtle">
Controlled by the main investigation timeframe.
</p>
</div>
<TimelineLegend />
{loading || !data ? (
<div className="timeline-view__loading">
<Loader2 size={14} className="spin" />
<span>plotting article volume</span>
</div>
) : !hasPoints ? (
<div className="timeline-view__loading">
<span>no timeline data for this query</span>
</div>
) : (
<svg
viewBox={`0 0 ${size.w} ${size.newsH}`}
width="100%"
preserveAspectRatio="xMidYMid meet"
className="timeline-view__svg"
onMouseLeave={() => setHover(null)}
>
{/* Y axis */}
{ticksY.map((t) => (
<g key={t} transform={`translate(0,${yScale(t)})`}>
<line
x1={padding.left}
x2={size.w - padding.right}
stroke="var(--border)"
strokeDasharray="2 4"
opacity={t === 0 ? 0.6 : 0.25}
/>
<text
x={padding.left - 12}
y={4}
textAnchor="end"
className="timeline-view__tick"
>
{t}
</text>
</g>
))}
{/* X axis */}
{ticksX.map((t) => (
<g key={t} transform={`translate(${xScale(t)},0)`}>
<line
y1={padding.top}
y2={size.newsH - padding.bottom}
stroke="var(--border)"
strokeDasharray="2 4"
opacity={0.25}
/>
<text
y={size.newsH - padding.bottom + 22}
textAnchor="middle"
className="timeline-view__tick"
>
{formatDateTick(t, axisTimeframe)}
</text>
</g>
))}
{/* Axis labels */}
<text
x={padding.left}
y={padding.top - 18}
className="timeline-view__axis-label"
>
ARTICLE VOLUME
</text>
<text
x={size.w - padding.right}
y={size.newsH - padding.bottom + (isCompact ? 36 : 46)}
textAnchor="end"
className="timeline-view__axis-label"
>
COLOR · SENTIMENT (RED ← → TEAL)
</text>
<defs>
<clipPath id={`tl-${plotClipId}-news`}>
<rect
x={padding.left}
y={padding.top}
width={Math.max(0, size.w - padding.left - padding.right)}
height={Math.max(0, size.newsH - padding.top - padding.bottom)}
/>
</clipPath>
</defs>
{/* Plot region only (stops scatter sitting on the y-axis when x is slightly out of domain). */}
<g clipPath={newsClipUrl}>
{(normalizedData.newsDistribution.events ?? []).map((e) => (
<g key={e.id ?? e.timestamp} transform={`translate(${xScale(e.timestamp)},0)`}>
<line
y1={padding.top}
y2={size.newsH - padding.bottom}
stroke="var(--accent)"
strokeOpacity="0.55"
strokeDasharray="3 3"
/>
<g transform={`translate(8, ${padding.top + 6})`}>
<text className="timeline-view__event">{e.label}</text>
</g>
</g>
))}
{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 (
<circle
key={`${p.timestamp}-${i}`}
cx={cx}
cy={cy}
r={r}
fill={fill}
fillOpacity={0.85}
stroke={fill}
strokeOpacity={0.4}
strokeWidth={r * 0.4}
onMouseEnter={(ev) => {
setHover({
...p,
sentiment: safeSentiment(p.sentiment),
cx,
cy,
fill,
clientX: ev.clientX,
clientY: ev.clientY,
});
}}
onMouseLeave={() => setHover(null)}
style={{ cursor: "pointer" }}
/>
);
})}
</g>
</svg>
)}
</section>
<section className="timeline-card timeline-card--interest">
<div className="timeline-card__head">
<div>
<div className="label">Interest over time</div>
<h2 className="timeline-card__title">{interestPanel.title}</h2>
<p className="timeline-card__subtle timeline-card__subtle--interest">
{interestPanel.lede}
</p>
{trendKeywords.length > 0 && (
<p className="timeline-card__subtle timeline-card__subtle--keywords">
<span className="label">Terms</span> {trendKeywords.join(" · ")}
</p>
)}
</div>
</div>
{!interestChart ? (
<div
className="timeline-view__interest-chart-placeholder"
role="presentation"
aria-hidden="true"
/>
) : (
<svg
viewBox={`0 0 ${size.w} ${interestChart.cHeight}`}
width="100%"
preserveAspectRatio="xMidYMid meet"
className="timeline-view__svg"
>
<text
x={interestChart.chartPadding.left}
y={interestChart.chartPadding.top - 8}
textAnchor="start"
className="timeline-view__axis-label"
>
{interestSource === "google-trends" || interestSource === "google-trends-empty"
? "SEARCH INTEREST (0–100)"
: interestSource === "derived-from-news-volume"
? "RELATIVE VOLUME"
: "RELATIVE SCORE"}
</text>
<text
x={size.w - interestChart.chartPadding.right}
y={
interestChart.cHeight -
interestChart.chartPadding.bottom +
(size.w < 760 ? 36 : 44)
}
textAnchor="end"
className="timeline-view__axis-label"
>
DATE
</text>
{interestChart.ticksY.map((t) => (
<g key={`iy-${t}`} transform={`translate(0,${interestChart.yScale(t)})`}>
<line
x1={interestChart.chartPadding.left}
x2={size.w - interestChart.chartPadding.right}
stroke="var(--border)"
strokeDasharray="2 4"
opacity={t === 0 ? 0.55 : 0.25}
/>
<text
x={interestChart.chartPadding.left - 12}
y={4}
textAnchor="end"
className="timeline-view__tick"
>
{Math.round(t)}
</text>
</g>
))}
{interestChart.ticksX.map((t) => (
<g key={`ix-${t}`} transform={`translate(${interestChart.xScale(t)},0)`}>
<line
y1={interestChart.chartPadding.top}
y2={interestChart.cHeight - interestChart.chartPadding.bottom}
stroke="var(--border)"
strokeDasharray="2 4"
opacity={0.25}
/>
<text
y={interestChart.cHeight - interestChart.chartPadding.bottom + 20}
textAnchor="middle"
className="timeline-view__tick"
>
{new Date(t).toLocaleDateString("en-GB", {
month: "short",
year: "2-digit",
})}
</text>
</g>
))}
<defs>
<clipPath id={`tl-${plotClipId}-interest`}>
<rect
x={interestChart.chartPadding.left}
y={interestChart.chartPadding.top}
width={Math.max(
0,
size.w - interestChart.chartPadding.left - interestChart.chartPadding.right
)}
height={Math.max(
0,
interestChart.cHeight -
interestChart.chartPadding.top -
interestChart.chartPadding.bottom
)}
/>
</clipPath>
</defs>
<g clipPath={interestClipUrl}>
{interestChart.singleInterest && interestChart.lonePoint ? (
<circle
cx={interestChart.xScale(interestChart.lonePoint.timestamp)}
cy={interestChart.yScale(Number(interestChart.lonePoint.value) || 0)}
r={5}
fill="var(--accent)"
fillOpacity={0.95}
stroke="var(--accent)"
strokeOpacity={0.35}
strokeWidth={10}
/>
) : (
<path
d={interestChart.linePath}
fill="none"
stroke="var(--accent)"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
</g>
</svg>
)}
</section>
{hover && (
<div
ref={timelineTooltipRef}
className="map-tooltip"
style={{
position: "fixed",
left: 0,
top: 0,
transform: "none",
}}
>
<div className="map-tooltip__name">
{new Date(hover.timestamp).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
})}
</div>
<div className="map-tooltip__row">
<span className="label">Articles</span>
<span className="mono">{hover.articleCount}</span>
</div>
<div className="map-tooltip__row">
<span className="label">Sentiment</span>
<span className="mono" style={{ color: hover.fill }}>
{safeSentiment(hover.sentiment).toFixed(2)}
</span>
</div>
</div>
)}
</div>
);
}
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 (
<div className="timeline-legend">
<div className="label timeline-legend__title">Sentiment</div>
<div className="timeline-legend__bar">
{stops.map((s, i) => (
<div
key={i}
className="timeline-legend__cell"
style={{ background: sentimentColor(s) }}
/>
))}
</div>
<div className="timeline-legend__scale">
<span>negative</span>
<span>positive</span>
</div>
</div>
);
}
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,
},
};
}