Add hover tooltip to WorldMap and update task list
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
* country filters the companion table.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
@@ -50,6 +51,28 @@ const useStyles = makeStyles({
|
||||
gap: tokens.spacingVerticalXS,
|
||||
zIndex: 10,
|
||||
},
|
||||
tooltip: {
|
||||
position: "fixed",
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXXS,
|
||||
boxShadow: tokens.shadow4,
|
||||
},
|
||||
tooltipCountry: {
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
},
|
||||
tooltipCount: {
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +81,7 @@ const useStyles = makeStyles({
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames?: Record<string, string>;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
@@ -67,6 +91,7 @@ interface GeoLayerProps {
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow,
|
||||
@@ -76,6 +101,17 @@ function GeoLayer({
|
||||
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);
|
||||
@@ -98,7 +134,7 @@ function GeoLayer({
|
||||
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;
|
||||
|
||||
|
||||
// Compute the fill color based on ban count
|
||||
const fillColor = getBanCountColor(
|
||||
count,
|
||||
@@ -106,7 +142,7 @@ function GeoLayer({
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
);
|
||||
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
@@ -136,6 +172,30 @@ function GeoLayer({
|
||||
handleClick(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!cc) return;
|
||||
setTooltip({
|
||||
cc,
|
||||
count,
|
||||
name: countryNames?.[cc] ?? cc,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(e): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setTooltip(null);
|
||||
}}
|
||||
>
|
||||
<Geography
|
||||
geography={geo}
|
||||
@@ -179,6 +239,22 @@ function GeoLayer({
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +266,8 @@ function GeoLayer({
|
||||
export interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Optional mapping from country code to display name. */
|
||||
countryNames?: Record<string, string>;
|
||||
/** Currently selected country filter (null means no filter). */
|
||||
selectedCountry: string | null;
|
||||
/** Called when the user clicks a country or deselects. */
|
||||
@@ -204,6 +282,7 @@ export interface WorldMapProps {
|
||||
|
||||
export function WorldMap({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow = 20,
|
||||
@@ -286,6 +365,7 @@ export function WorldMap({
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
|
||||
51
frontend/src/components/__tests__/WorldMap.test.tsx
Normal file
51
frontend/src/components/__tests__/WorldMap.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tests for WorldMap component.
|
||||
*
|
||||
* Verifies that hovering a country shows a tooltip with the country name and ban count.
|
||||
*/
|
||||
|
||||
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 }: { children?: React.ReactNode }) => <g>{children}</g>,
|
||||
useGeographies: () => ({
|
||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||
path: { centroid: () => [10, 10] },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { WorldMap } from "../WorldMap";
|
||||
|
||||
describe("WorldMap", () => {
|
||||
it("shows a tooltip with country name and ban count on hover", () => {
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<WorldMap
|
||||
countries={{ US: 42 }}
|
||||
countryNames={{ US: "United States" }}
|
||||
selectedCountry={null}
|
||||
onSelectCountry={vi.fn()}
|
||||
/>
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Tooltip should not be present initially
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,7 @@ export function MapPage(): React.JSX.Element {
|
||||
{!loading && !error && (
|
||||
<WorldMap
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={setSelectedCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
|
||||
@@ -32,8 +32,12 @@ vi.mock("../api/config", async () => ({
|
||||
fetchMapColorThresholds: mockFetchMapColorThresholds,
|
||||
}));
|
||||
|
||||
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
|
||||
vi.mock("../components/WorldMap", () => ({
|
||||
WorldMap: () => <div data-testid="world-map" />,
|
||||
WorldMap: (props: unknown) => {
|
||||
mockWorldMap(props);
|
||||
return <div data-testid="world-map" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MapPage", () => {
|
||||
@@ -49,6 +53,11 @@ describe("MapPage", () => {
|
||||
// Initial load should call useMapData with default filters.
|
||||
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
|
||||
|
||||
// Map should receive country names from the hook so tooltips can show human-readable labels.
|
||||
expect(mockWorldMap).toHaveBeenCalled();
|
||||
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
|
||||
expect(firstCallArgs).toMatchObject({ countryNames: {} });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||
expect(lastArgs.range).toBe("7d");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user