Download src/pages/TimelineView.jsx from NKessler/Horizon: direct link, hf CLI and curl.
- Browser
- Download file 27.4 kB
-
https://huggingface.co/NKessler/Horizon/resolve/main/src/pages/TimelineView.jsx
- Command line
-
hf download hf://NKessler/Horizon/src/pages/TimelineView.jsx
-
curl -L -o TimelineView.jsx https://huggingface.co/NKessler/Horizon/resolve/main/src/pages/TimelineView.jsx
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, | |
| }, | |
| }; | |
| } | |