Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests
This commit is contained in:
@@ -1,32 +1,42 @@
|
||||
/**
|
||||
* WorldMap — SVG world map showing per-country ban counts.
|
||||
*
|
||||
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
|
||||
* jsDelivr CDN. For each country that has bans in the selected time window,
|
||||
* the total count is displayed inside the country's borders. Clicking a
|
||||
* country filters the companion table.
|
||||
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
|
||||
* and native SVG pan/zoom behaviour.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { geoMercator, geoPath, type GeoPath } from "d3-geo";
|
||||
import { feature } from "topojson-client";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
GeoJsonProperties,
|
||||
Geometry,
|
||||
} from "geojson";
|
||||
import type {
|
||||
GeometryCollection as TopoGeometryCollection,
|
||||
Topology,
|
||||
} from "topojson-specification";
|
||||
import worldData from "world-atlas/countries-110m.json";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { GeoPermissibleObjects } from "d3-geo";
|
||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||
import { getBanCountColor } from "../utils/mapColors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GEO_URL =
|
||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAP_WIDTH = 800;
|
||||
const MAP_HEIGHT = 400;
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
const ZOOM_STEP = 0.5;
|
||||
const PAN_THRESHOLD = 3;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
@@ -34,6 +44,25 @@ const useStyles = makeStyles({
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
svg: {
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
touchAction: "none",
|
||||
},
|
||||
country: {
|
||||
transition: "fill 150ms ease, stroke 150ms ease",
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
fill: "var(--country-fill)",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
},
|
||||
countryHovered: {
|
||||
fill: "var(--country-hover-fill)",
|
||||
},
|
||||
countrySelected: {
|
||||
fill: "var(--country-selected-fill)",
|
||||
},
|
||||
countLabel: {
|
||||
fontSize: "9px",
|
||||
fontWeight: "600",
|
||||
@@ -74,212 +103,21 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||
// ---------------------------------------------------------------------------
|
||||
type TopoJsonTopology = Topology & {
|
||||
objects: {
|
||||
countries: TopoGeometryCollection;
|
||||
};
|
||||
};
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames?: Record<string, string>;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
thresholdMedium: number;
|
||||
thresholdHigh: number;
|
||||
}
|
||||
type TooltipState = {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
}: GeoLayerProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||
|
||||
const [tooltip, setTooltip] = useState<
|
||||
| {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(cc: string | null): void => {
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[selectedCountry, onSelectCountry],
|
||||
);
|
||||
|
||||
// Stable event handlers — shared across all Geography components so
|
||||
// React.memo is not defeated by new function references each render.
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<SVGPathElement>): void => {
|
||||
const cc = e.currentTarget.dataset.cc;
|
||||
if (!cc) return;
|
||||
const count = Number(e.currentTarget.dataset.count);
|
||||
const name = e.currentTarget.dataset.name ?? cc;
|
||||
setTooltip({ cc, count, name, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGPathElement>): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: e.clientX, y: e.clientY }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
// Memoize style objects so Geography's React.memo can skip re-renders
|
||||
// when only the tooltip position changes.
|
||||
type GeoStyle = { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties };
|
||||
const styleMap = useMemo(() => {
|
||||
const map: Record<string, GeoStyle> = {};
|
||||
for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
|
||||
const numericId = String(geo.id);
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
map[geo.rsmKey] = {
|
||||
default: {
|
||||
fill: isSelected ? tokens.colorBrandBackground : fillColor,
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
outline: "none",
|
||||
},
|
||||
hover: {
|
||||
fill: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: cc && count > 0
|
||||
? tokens.colorNeutralBackground3
|
||||
: fillColor,
|
||||
stroke: tokens.colorNeutralStroke1,
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
},
|
||||
pressed: {
|
||||
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
|
||||
stroke: tokens.colorBrandStroke1,
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
},
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]);
|
||||
|
||||
if (geographies.length === 0) return <></>;
|
||||
|
||||
// react-simple-maps types declare path as always defined, but it can be null
|
||||
// during initial render before MapProvider context initializes. Cast to reflect
|
||||
// the true runtime type and allow safe null checking.
|
||||
const safePath = path as unknown as typeof path | null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(geographies as { rsmKey: string; id: string | number }[]).map(
|
||||
(geo) => {
|
||||
const numericId = String(geo.id);
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
if (safePath != null) {
|
||||
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
|
||||
[cx, cy] = centroid;
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={geo.rsmKey} style={{ cursor: cc ? "pointer" : "default" }}>
|
||||
<Geography
|
||||
geography={geo}
|
||||
role={cc ? "button" : undefined}
|
||||
tabIndex={cc ? 0 : undefined}
|
||||
aria-label={
|
||||
cc
|
||||
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
||||
isSelected ? " (selected)" : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={isSelected || undefined}
|
||||
onClick={(): void => {
|
||||
if (cc) handleClick(cc);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (cc && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleClick(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-cc={cc ?? undefined}
|
||||
data-count={cc ? String(count) : undefined}
|
||||
data-name={cc ? (countryNames?.[cc] ?? cc) : undefined}
|
||||
style={styleMap[geo.rsmKey]}
|
||||
/>
|
||||
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{tooltip &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||
<span className={styles.tooltipCount}>
|
||||
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorldMap — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldMapProps {
|
||||
interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Optional mapping from country code to display name. */
|
||||
@@ -307,21 +145,143 @@ export function WorldMap({
|
||||
}: WorldMapProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
const [zoom, setZoom] = useState<number>(1);
|
||||
const [zoom, setZoom] = useState<number>(MIN_ZOOM);
|
||||
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState>(null);
|
||||
|
||||
const handleZoomIn = (): void => {
|
||||
setZoom((z) => Math.min(z + 0.5, 8));
|
||||
};
|
||||
const zoomRef = useRef<number>(zoom);
|
||||
const centerRef = useRef<[number, number]>(center);
|
||||
const dragStateRef = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startCenter: [number, number];
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const clickSuppressedRef = useRef<boolean>(false);
|
||||
|
||||
const handleZoomOut = (): void => {
|
||||
setZoom((z) => Math.max(z - 0.5, 1));
|
||||
};
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const handleResetView = (): void => {
|
||||
setZoom(1);
|
||||
useEffect(() => {
|
||||
centerRef.current = center;
|
||||
}, [center]);
|
||||
|
||||
const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []);
|
||||
|
||||
const geoJson = useMemo(
|
||||
() =>
|
||||
feature(topology, topology.objects.countries) as FeatureCollection<
|
||||
Geometry,
|
||||
GeoJsonProperties
|
||||
>,
|
||||
[topology],
|
||||
);
|
||||
|
||||
const projection = useMemo(
|
||||
() => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson),
|
||||
[geoJson],
|
||||
);
|
||||
|
||||
const pathGenerator = useMemo<GeoPath<unknown, Feature<Geometry, GeoJsonProperties>>>(
|
||||
() => geoPath().projection(projection),
|
||||
[projection],
|
||||
);
|
||||
|
||||
const countryFeatures = useMemo(
|
||||
() => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null),
|
||||
[geoJson.features],
|
||||
);
|
||||
|
||||
const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []);
|
||||
|
||||
const handleCountrySelect = useCallback(
|
||||
(cc: string | null): void => {
|
||||
if (clickSuppressedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[onSelectCountry, selectedCountry],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStateRef.current = {
|
||||
active: true,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startCenter: centerRef.current,
|
||||
moved: false,
|
||||
};
|
||||
clickSuppressedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag?.active) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||
drag.moved = true;
|
||||
clickSuppressedRef.current = true;
|
||||
}
|
||||
|
||||
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
window.setTimeout(() => {
|
||||
clickSuppressedRef.current = false;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<SVGSVGElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const currentZoom = zoomRef.current;
|
||||
const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));
|
||||
if (desiredZoom === currentZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom;
|
||||
const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom;
|
||||
|
||||
setZoom(desiredZoom);
|
||||
setCenter([
|
||||
centerRef.current[0] - svgX * (desiredZoom - currentZoom),
|
||||
centerRef.current[1] - svgY * (desiredZoom - currentZoom),
|
||||
]);
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((value) => clampZoom(value + ZOOM_STEP));
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((value) => clampZoom(value - ZOOM_STEP));
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
setZoom(MIN_ZOOM);
|
||||
setCenter([0, 0]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -329,13 +289,12 @@ export function WorldMap({
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||
>
|
||||
{/* Zoom controls */}
|
||||
<div className={styles.zoomControls}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 8}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
@@ -345,7 +304,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
@@ -355,7 +314,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleResetView}
|
||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||||
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||||
title="Reset view"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
@@ -363,34 +322,126 @@ export function WorldMap({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||
width={800}
|
||||
height={400}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country."
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={zoom}
|
||||
center={center}
|
||||
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
||||
setZoom(newZoom);
|
||||
setCenter(coordinates);
|
||||
}}
|
||||
minZoom={1}
|
||||
maxZoom={8}
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
thresholdMedium={thresholdMedium}
|
||||
thresholdHigh={thresholdHigh}
|
||||
/>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||
{countryFeatures.map((featureItem) => {
|
||||
const rawId = featureItem.id;
|
||||
const numericId = String(Number(rawId));
|
||||
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count = cc !== null ? countries[cc] ?? 0 : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
const pathString = pathGenerator(featureItem) ?? "";
|
||||
if (!pathString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const centroid = pathGenerator.centroid(featureItem);
|
||||
const [cx, cy] = centroid;
|
||||
const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy);
|
||||
|
||||
return (
|
||||
<g key={String(rawId)}>
|
||||
<path
|
||||
d={pathString}
|
||||
role={cc ? "button" : undefined}
|
||||
tabIndex={cc ? 0 : undefined}
|
||||
aria-label={
|
||||
cc
|
||||
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
||||
isSelected ? " (selected)" : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={isSelected || undefined}
|
||||
className={`${styles.country} ${
|
||||
isSelected ? styles.countrySelected : ""
|
||||
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
|
||||
style={
|
||||
{
|
||||
["--country-fill" as string]: fillColor,
|
||||
["--country-hover-fill" as string]: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: tokens.colorBrandBackground2,
|
||||
["--country-selected-fill" as string]: tokens.colorBrandBackground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (cc) {
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (cc && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void => {
|
||||
if (!cc) return;
|
||||
setHoveredCountry(cc);
|
||||
setTooltip({
|
||||
cc,
|
||||
count,
|
||||
name: countryNames?.[cc] ?? cc,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(event): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: event.clientX, y: event.clientY }
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHoveredCountry(null);
|
||||
setTooltip(null);
|
||||
}}
|
||||
/>
|
||||
{count > 0 && isCentroidValid && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{tooltip &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||
<span className={styles.tooltipCount}>
|
||||
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
||||
vi.mock("react-simple-maps", () => ({
|
||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Geography: ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => <g {...props}>{children}</g>,
|
||||
useGeographies: () => ({
|
||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||
path: { centroid: () => [10, 10] },
|
||||
vi.mock(
|
||||
"world-atlas/countries-110m.json",
|
||||
() => ({
|
||||
default: {
|
||||
type: "Topology",
|
||||
objects: {
|
||||
countries: {
|
||||
type: "GeometryCollection",
|
||||
geometries: [
|
||||
{
|
||||
type: "Polygon",
|
||||
arcs: [[0]],
|
||||
id: "840",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
|
||||
transform: {
|
||||
scale: [1, 1],
|
||||
translate: [0, 0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
import { WorldMap } from "../WorldMap";
|
||||
|
||||
@@ -34,19 +49,20 @@ describe("WorldMap", () => {
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Tooltip should not be present initially
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
// Country map area is exposed as an accessible button with an accurate label
|
||||
const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
|
||||
expect(countryButton).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("United States");
|
||||
expect(tooltip).toHaveTextContent("42 bans");
|
||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
||||
|
||||
fireEvent.mouseLeave(countryButton);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user