import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { scaleLinear } from "d3-scale"; import { interpolateRgb } from "d3-interpolate"; import { Loader2 } from "lucide-react"; import WorldMap from "../components/WorldMap.jsx"; import { fetchCountryDivergence, fetchMapArticles, } from "../api/client.js"; import { getCachedMapArticles, getCachedMapDivergence, setCachedMapArticles, setCachedMapDivergence, } from "../utils/investigationCache.js"; import { viewportTooltipPosition } from "../utils/viewportTooltip.js"; import "./MapView.css"; const heatScale = scaleLinear() .domain([0, 0.5, 1]) .range(["#1B202C", "#C8A96E", "#E05252"]) .interpolate(interpolateRgb) .clamp(true); const NO_DATA_FILL = "#1A1E29"; export default function MapView({ query }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [hover, setHover] = useState(null); const [selectedIso, setSelectedIso] = useState(null); const tooltipRef = useRef(null); const lastPointerRef = useRef({ x: 0, y: 0 }); const [articles, setArticles] = useState([]); const [articlesLoading, setArticlesLoading] = useState(true); const topic = query?.topic?.trim() ?? ""; const newsTimeframe = query?.newsTimeframe ?? "6m"; const qParams = { topic, newsTimeframe }; useEffect(() => { let cancelled = false; if (!topic) { setData(null); setLoading(false); return () => { cancelled = true; }; } const cached = getCachedMapDivergence(topic, newsTimeframe); if (cached) { setData(cached); setLoading(false); return () => { cancelled = true; }; } setLoading(true); setData(null); fetchCountryDivergence(qParams) .then((d) => { if (cancelled) return; setData(d); setCachedMapDivergence(topic, newsTimeframe, d); }) .finally(() => !cancelled && setLoading(false)); return () => { cancelled = true; }; }, [topic, newsTimeframe]); useEffect(() => { let cancelled = false; if (!topic) { setArticles([]); setArticlesLoading(false); return () => { cancelled = true; }; } const cached = getCachedMapArticles(topic, newsTimeframe, selectedIso); if (cached) { setArticles(cached); setArticlesLoading(false); return () => { cancelled = true; }; } setArticlesLoading(true); fetchMapArticles(qParams, selectedIso) .then((d) => { if (cancelled) return; const list = d?.articles ?? []; setArticles(list); setCachedMapArticles(topic, newsTimeframe, selectedIso, list); }) .finally(() => !cancelled && setArticlesLoading(false)); return () => { cancelled = true; }; }, [topic, newsTimeframe, selectedIso]); const byNumeric = useMemo(() => { const m = new Map(); data?.countries?.forEach((c) => m.set(c.numericCode, c)); return m; }, [data]); const selectedNumeric = useMemo(() => { if (!selectedIso || !data) return null; const c = data.countries.find((x) => x.iso3 === selectedIso); return c?.numericCode ?? null; }, [selectedIso, data]); const colorFor = useCallback( (numeric) => { if (!data) return NO_DATA_FILL; const c = byNumeric.get(numeric); return c ? heatScale(c.divergence) : NO_DATA_FILL; }, [data, byNumeric] ); const applyTooltipDom = useCallback((clientX, clientY) => { lastPointerRef.current = { x: clientX, y: clientY }; const el = tooltipRef.current; if (!el) return; const { width, height } = el.getBoundingClientRect(); const estWidth = width > 0 ? width : 236; const estHeight = height > 0 ? height : 92; const { left, top } = viewportTooltipPosition(clientX, clientY, { estWidth, estHeight, }); el.style.left = `${left}px`; el.style.top = `${top}px`; }, []); const onCountryEnter = useCallback( (country, e) => { lastPointerRef.current = { x: e.clientX, y: e.clientY }; const c = byNumeric.get(country.numericCode); setHover(c ? { ...c } : { name: country.name }); }, [byNumeric] ); const onCountryMove = useCallback( (e) => { applyTooltipDom(e.clientX, e.clientY); }, [applyTooltipDom] ); const onCountryLeave = useCallback(() => { setHover(null); }, []); const onCountryClick = useCallback( (country) => { const c = byNumeric.get(country.numericCode); if (c) { setSelectedIso((prev) => (prev === c.iso3 ? null : c.iso3)); } }, [byNumeric] ); useLayoutEffect(() => { if (!hover) return; const { x, y } = lastPointerRef.current; applyTooltipDom(x, y); }, [hover, applyTooltipDom]); return (
{loading && (
compiling coverage
)}
drag to pan · scroll to zoom · click a country
{hover && (
{hover.name}
{hover.articleCount != null ? ( <>
Articles {hover.articleCount}
Density {hover.divergence.toFixed(2)}
) : (
no coverage indexed
)}
)}
Articles

{selectedIso ? `Coverage from ${selectedIso}` : "All indexed coverage"}

{selectedIso && ( )}
{articlesLoading ? (
loading articles
) : !articles.length ? (
no articles found for this selection
) : ( )}
); } function MapLegend() { const stops = [0, 0.25, 0.5, 0.75, 1]; return (
Article density
{stops.map((s, i) => (
))}
low density
); }