Horizon / src /pages /MapView.jsx
NKessler's picture
Upload 6270 files
4083225 verified
Raw
History Blame Contribute Delete
9.29 kB
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>
);
}