| 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"; |
|
|
| |
| const sentimentColor = scaleLinear() |
| .domain([-1, 0, 1]) |
| .range(["#E05252", "#C8A96E", "#4A9E8A"]) |
| .interpolate(interpolateRgb) |
| .clamp(true); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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) }); |
| }; |
| |
| 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; |
|
|
| |
| 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, |
| }, |
| }; |
| } |
|
|