Fix WorldMap hover highlight by memoizing style objects and handlers
Memoize per-Geography style objects with useMemo so React.memo can skip re-renders when only the tooltip position changes. Stabilize mouse event handlers with useCallback using data-* attributes instead of per-Geography closures. This eliminates the state-update race condition that caused hover fill colors to flash back to defaults.
This commit is contained in:
@@ -8,10 +8,11 @@
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
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";
|
||||
@@ -117,6 +118,73 @@ function GeoLayer({
|
||||
[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
|
||||
@@ -133,14 +201,6 @@ function GeoLayer({
|
||||
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,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
);
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
@@ -172,54 +232,13 @@ 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);
|
||||
}}
|
||||
style={{
|
||||
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",
|
||||
},
|
||||
}}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user