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