Files
BanGUI/Docs/Tasks.md
Lukas bfe0daf754 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.
2026-04-01 14:53:38 +02:00

5.3 KiB
Raw Blame History

BanGUI — Task List

This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.

Reference: Docs/Refactoring.md for full analysis of each issue.


Open Issues

WorldMap hover highlight: fill color sometimes does not change Done

Status: Completed.

Summary: Memoized Geography style objects via useMemo keyed by geographies, countries, selectedCountry, and threshold props. Stabilized onMouseEnter, onMouseMove, and onMouseLeave handlers with useCallback using data-* attributes to avoid per-Geography closures. This ensures React.memo(Geography) correctly skips re-renders when only tooltip position changes, eliminating the state-update race that caused hover fills to flash back to defaults.

Symptom: On mouse-over, a country's stroke correctly becomes bold (strokeWidth: 1), but the background fill colour sometimes stays at the default value instead of switching to the hover colour.

Root cause — React.memo is defeated by new style objects on every render:

Geography.js (react-simple-maps-main/src/components/Geography.js) is wrapped in memo(). It keeps an internal isFocused state that toggles between style.default and style.hover. This works correctly in isolation.

However, in WorldMap.tsx (frontend/src/components/WorldMap.tsx):

  1. onMouseMove calls setTooltip(...) on every pixel of mouse movement, triggering a GeoLayer re-render.
  2. Each GeoLayer render creates a new style object literal for every <Geography> (lines ~199222). Because memo() does a shallow reference comparison, it sees a "new" style prop every time and re-renders all ~200 Geography components on every mouse-move event.
  3. During these rapid cascading re-renders, React's batched state updates can cause Geography's internal setFocus(true) (fired in handleMouseEnter) and the parent's setTooltip to race. When the parent re-render is processed before isFocused is committed, Geography momentarily renders with isFocused = false and picks style.default — the fill stays at the default colour while the stroke (which changes less dramatically) appears correct.

A secondary contributing factor: for countries with count === 0 that are not selected, style.hover.fill equals style.default.fill (both resolve to fillColor), so there is intentionally no visible fill change on hover for those countries. This may overlap with the perceived bug if the user expects all countries to highlight.

Fix — memoize the style objects so memo() can skip re-renders:

In WorldMap.tsx, compute each Geography's style object with useMemo (or move it outside the .map() callback with stable references). Concretely:

  1. Create a useMemo-based style map keyed by geo.rsmKey that only recomputes when countries, selectedCountry, or the threshold props change — not on every tooltip update.

    const styleMap = useMemo(() => {
      const map: Record<string, { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties }> = {};
      for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
        const numericId = String(geo.id);
        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);
        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]);
    
  2. Pass the memoized style in the .map() callback:

    <Geography style={styleMap[geo.rsmKey]} ... />
    
  3. Wrap event handlers in useCallback (onMouseEnter, onMouseMove, onMouseLeave) or move them outside the .map() so they are stable references and do not defeat memo(). Consider passing cc, count, and countryNames as data attributes and reading them from e.currentTarget inside a single shared handler.

With stable style references, memo(Geography) will correctly skip re-renders for countries whose data has not changed, eliminating the state-update race condition and the unnecessary rendering of ~200 SVG paths on every mouse-move event.

Files to change:

  • frontend/src/components/WorldMap.tsx — memoize style objects and stabilize event handlers.
  • No changes needed in react-simple-maps-main/src/components/Geography.js.