| 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 ( |
| <div className="map-view-page"> |
| <div className="map-view"> |
| <MapLegend /> |
| |
| <WorldMap |
| colorFor={colorFor} |
| selectedNumeric={selectedNumeric} |
| onCountryEnter={onCountryEnter} |
| onCountryMove={onCountryMove} |
| onCountryLeave={onCountryLeave} |
| onCountryClick={onCountryClick} |
| /> |
| |
| {loading && ( |
| <div className="map-view__loading"> |
| <Loader2 size={14} strokeWidth={1.5} className="spin" /> |
| <span>compiling coverage</span> |
| </div> |
| )} |
| |
| <div className="map-view__hint">drag to pan 路 scroll to zoom 路 click a country</div> |
| |
| {hover && ( |
| <div |
| ref={tooltipRef} |
| className="map-tooltip" |
| style={{ position: "fixed", left: 0, top: 0, transform: "none" }} |
| > |
| <div className="map-tooltip__name">{hover.name}</div> |
| {hover.articleCount != null ? ( |
| <> |
| <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">Density</span> |
| <span className="mono" style={{ color: heatScale(hover.divergence) }}> |
| {hover.divergence.toFixed(2)} |
| </span> |
| </div> |
| </> |
| ) : ( |
| <div className="map-tooltip__row faint">no coverage indexed</div> |
| )} |
| </div> |
| )} |
| </div> |
|
|
| <section className="map-articles"> |
| <div className="map-articles__head"> |
| <div> |
| <div className="label">Articles</div> |
| <h2 className="map-articles__title"> |
| {selectedIso ? `Coverage from ${selectedIso}` : "All indexed coverage"} |
| </h2> |
| </div> |
| {selectedIso && ( |
| <button className="ghost" onClick={() => setSelectedIso(null)}> |
| Clear country filter |
| </button> |
| )} |
| </div> |
| |
| {articlesLoading ? ( |
| <div className="map-articles__loading"> |
| <Loader2 size={14} className="spin" /> |
| <span>loading articles</span> |
| </div> |
| ) : !articles.length ? ( |
| <div className="map-articles__loading"> |
| <span>no articles found for this selection</span> |
| </div> |
| ) : ( |
| <ul className="map-articles__list"> |
| {articles.map((article) => ( |
| <li key={article.id} className="map-article"> |
| <a href={article.url} target="_blank" rel="noreferrer" className="map-article__title"> |
| {article.headline} |
| </a> |
| <div className="map-article__meta"> |
| <span>{article.source || "unknown source"}</span> |
| {article.countryName && ( |
| <> |
| <span className="faint">路</span> |
| <span>{article.countryName}</span> |
| </> |
| )} |
| {article.publishedAt && ( |
| <> |
| <span className="faint">路</span> |
| <span>{article.publishedAt}</span> |
| </> |
| )} |
| </div> |
| {article.summary && <p className="map-article__summary">{article.summary}</p>} |
| </li> |
| ))} |
| </ul> |
| )} |
| </section> |
| </div> |
| ); |
| } |
|
|
| function MapLegend() { |
| const stops = [0, 0.25, 0.5, 0.75, 1]; |
| return ( |
| <div className="map-legend"> |
| <div className="label map-legend__title">Article density</div> |
| <div className="map-legend__bar"> |
| {stops.map((s, i) => ( |
| <div |
| key={i} |
| className="map-legend__cell" |
| style={{ background: heatScale(s) }} |
| /> |
| ))} |
| </div> |
| <div className="map-legend__scale" aria-label="Article density from low to high"> |
| <span className="map-legend__scale-label map-legend__scale-label--low">low density</span> |
| <span className="map-legend__scale-gap" aria-hidden="true" /> |
| <span className="map-legend__scale-label map-legend__scale-label--high">high density</span> |
| </div> |
| </div> |
| ); |
| } |
|
|