components/InteractiveGlobe/InteractiveGlobe.jsxjavascript
import { useEffect, useMemo, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import Globe from "react-globe.gl";
import * as THREE from "three";
import { useConfig } from "../../services/config/config";
import { MARKER_ICONS } from "../../services/config/globeDefaults";
import { loadCountryPolygons } from "./countryPolygons";
import "./InteractiveGlobe.scss";

// the only accessor that never depends on a config value - kept at module
// scope for a stable identity across renders, same reasoning as the other
// polygon accessors (see their useCallback calls below)
const polygonLabel = (feature) => feature.properties.name;

/**
 * 3D globe with location markers (always rendered individually - no
 * clustering, per the customer's request) and vector country polygons
 * (world-atlas topology via countryPolygons.js), replacing an earlier
 * version that textured the sphere with a customer-supplied equirectangular
 * map image. That image wasn't a true pole-to-pole projection, so its
 * landmasses were visually offset from the lat/lon grid the markers use -
 * markers sat at the right coordinate but next to the wrong country.
 * Rendering countries as polygons derived from the same coordinate system
 * as the markers removes that class of bug by construction. Marker
 * rendering itself (plain DOM elements via htmlElementsData, not CSS2DObject)
 * is unrelated to that fix and unchanged. Selection/detail-card rendering is
 * left to the parent (onSelectLocation) rather than owned internally, so it
 * composes with GlobePage's own state.
 *
 * Visual/timing constants (colors, zoom thresholds, durations) come from
 * useConfig() - see the `globe*` entries in config-schema.js - so they can
 * be tuned per kiosk via URL param without a code change. Structured
 * defaults that don't fit a single URL param (marker icon paths) live in
 * services/config/globeDefaults.js instead.
 *
 * @param {Object} props
 * @param {Array<{id: string|number, lat: number, lng: number, type: "outcome"|"impact"}>} props.locations
 *   - Markers to render. Any extra fields (title, headline, ...) are passed through untouched
 *   to `onSelectLocation` when a marker is clicked.
 * @param {Map<string,string>} props.countryColorByCcn3 - Per-country fill color, keyed by ISO
 *   numeric (ccn3) country code - see GlobePage, which resolves this from
 *   content.valuePartnership.countryDisplay (cca3-keyed) via countryMeta.js.
 * @param {string} props.defaultCountryColor - Fill color for any country not present in
 *   `countryColorByCcn3`.
 * @param {number} props.width - Canvas width in px, measured by GlobePage from its own
 *   container (see useElementSize there) rather than left to react-globe.gl's own default
 *   (window.innerWidth, captured once and never updated on resize/Stage scale changes).
 * @param {number} props.height - Canvas height in px, see `width`.
 * @param {boolean} [props.active=true] - Whether this globe is the currently visible one.
 *   GlobePage now stays mounted (just visually hidden) while the user is on the Excellence/
 *   Partnerships tabs instead of being unmounted/remounted on every tab switch - both to
 *   avoid rebuilding the whole WebGL scene each time and because repeated Three.js
 *   mount/unmount cycles are a known memory-leak risk class. `active=false` calls the globe's
 *   `pauseAnimation()` so its render loop (and the CPU/GPU cost that comes with it) actually
 *   stops while hidden, rather than continuing to render off-screen.
 * @param {boolean} [props.autoRotate=false] - Enables OrbitControls auto-rotation (e.g. while idle).
 * @param {{lat: number, lng: number}|null} [props.focusedLocation] - Recenters the camera on this
 *   coordinate whenever it changes (marker click, or a parent-driven selection e.g. bottom-nav
 *   prev/next) - decoupled from click handling so all selection sources share one camera behavior.
 * @param {function(Object): void} props.onSelectLocation - Called with the clicked location's
 *   full properties object, or `null` when the globe background is clicked (deselect).
 */
export default function InteractiveGlobe({
  locations,
  countryColorByCcn3,
  defaultCountryColor,
  width,
  height,
  active = true,
  autoRotate = false,
  focusedLocation = null,
  onSelectLocation,
}) {
  const {
    globeOceanColor,
    globeLandStrokeColor,
    globePolygonSideColor,
    globeAtmosphereColor,
    globeAtmosphereAltitude,
    globePolygonAltitude,
    globeMinZoomAltitude,
    globePolygonsTransitionDurationMs,
    globeZoomTransitionDurationMs,
    globeFocusZoomAltitude,
    globeAutoRotateSpeed,
    globeMarkerWidth,
  } = useConfig();

  // rendered marker size, kept at the source SVGs' 92:71 aspect ratio
  const markerHeight = Math.round((globeMarkerWidth * 71) / 92);

  const globeRef = useRef();
  const countryFeatures = useMemo(() => loadCountryPolygons(), []);
  const globeMaterial = useMemo(
    () => new THREE.MeshPhongMaterial({ color: globeOceanColor }),
    [globeOceanColor]
  );

  const polygonCapColor = useCallback(
    (feature) => countryColorByCcn3.get(feature.properties.ccn3) ?? defaultCountryColor,
    [countryColorByCcn3, defaultCountryColor]
  );
  // wrapped in useCallback (not inline in JSX) so these keep a stable
  // identity across renders that don't change the underlying config value -
  // react-globe.gl's polygon layer would otherwise redo internal work on
  // every unrelated re-render (e.g. every search-field keystroke, which
  // re-renders this component with a new `locations` array but doesn't
  // affect how a country polygon should look)
  const polygonSideColor = useCallback(() => globePolygonSideColor, [globePolygonSideColor]);
  const polygonStrokeColor = useCallback(() => globeLandStrokeColor, [globeLandStrokeColor]);

  // react-globe.gl hides html markers on the far side of the globe via an
  // "isBehindGlobe" occlusion check that's normally only recomputed from
  // OrbitControls' own 'change' listener - i.e. on the next user drag/zoom -
  // plus once synchronously during the globe's own internal mount. That
  // first computation races the globe's underlying WebGL/geometry setup
  // (radius, world matrix, shader compilation) still settling over the
  // following frames, so most markers are stuck wrongly hidden - the
  // visible marker count keeps climbing over several frames after mount
  // even with no user input at all. Re-applying the current point of view
  // on every frame for ~1.5s after mount forces react-globe.gl to keep
  // redoing that occlusion check until it lands on properly settled state,
  // so markers appear on first paint instead of only after the user's first
  // interaction - a fixed delay isn't reliable here since how long the
  // underlying settling takes varies with device/GPU speed.
  useEffect(() => {
    if (!globeRef.current) return;
    let frame = 0;
    let id;
    const tick = () => {
      globeRef.current?.pointOfView(globeRef.current.pointOfView(), 0);
      frame += 1;
      if (frame < 90) id = requestAnimationFrame(tick);
    };
    id = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(id);
  }, []);

  useEffect(() => {
    if (!globeRef.current) return;
    if (active) globeRef.current.resumeAnimation();
    else globeRef.current.pauseAnimation();
  }, [active]);

  // react-globe.gl's own default zoom-in limit (controls.minDistance) sits
  // just above the *base* sphere, well below globeMinZoomAltitude - manual
  // scroll/pinch zoom could get much closer than intended, right into the
  // black-wall zone explained on globeMinZoomAltitude's entry in
  // config-schema.js: close-together
  // countries' raised side-wall geometry (polygonSideColor, semi-transparent
  // black) can surround the camera and stack up to an opaque black wall
  // filling the whole view. This runs once on mount rather than via the
  // `onGlobeReady` prop: with no globeImageUrl to load, three-globe's
  // internal "ready" event fires synchronously during its own first prop
  // application - which can race ahead of react-kapsule wiring up our
  // onGlobeReady handler in the first place, so it silently never fires.
  // globeRef.current is set by the time effects run regardless of that
  // internal readiness race.
  useEffect(() => {
    if (!globeRef.current) return;
    globeRef.current.controls().minDistance =
      globeRef.current.getGlobeRadius() * (1 + globeMinZoomAltitude);
  }, [globeMinZoomAltitude]);

  useEffect(() => {
    if (!globeRef.current) return;
    const controls = globeRef.current.controls();
    controls.autoRotate = autoRotate;
    controls.autoRotateSpeed = globeAutoRotateSpeed;
  }, [autoRotate, globeAutoRotateSpeed]);

  const handleMarkerClick = useCallback(
    (e, d) => {
      e.stopPropagation();
      onSelectLocation(d);
    },
    [onSelectLocation]
  );

  // camera pose captured right before the *first* marker of a browsing
  // session gets focused, so closing (focusedLocation -> null) can zoom back
  // out to it - not re-captured on every focusedLocation change, so
  // prev/next-ing between already-open markers doesn't overwrite it with
  // the last-viewed marker's own position
  const preFocusPointOfViewRef = useRef(null);

  useEffect(() => {
    if (!globeRef.current) return;

    if (focusedLocation) {
      if (!preFocusPointOfViewRef.current) {
        preFocusPointOfViewRef.current = globeRef.current.pointOfView();
      }
      const { lat, lng } = focusedLocation;
      globeRef.current.pointOfView(
        { lat, lng, altitude: globeFocusZoomAltitude },
        globeZoomTransitionDurationMs
      );
    } else if (preFocusPointOfViewRef.current) {
      globeRef.current.pointOfView(preFocusPointOfViewRef.current, globeZoomTransitionDurationMs);
      preFocusPointOfViewRef.current = null;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- react only to the focus target changing
  }, [focusedLocation?.lat, focusedLocation?.lng]);

  const htmlElementBuilder = useCallback(
    (d) => {
      const container = document.createElement("div");
      container.style.position = "relative";
      // container is double the marker height so the CSS2DRenderer's
      // default center-anchoring (translate(-50%, -50%) on `container`)
      // lands on the pin's tip - the bottom edge of `el` - rather than
      // its middle, matching how a map pin should sit on its coordinate.
      container.style.height = `${markerHeight * 2}px`;

      const el = document.createElement("div");
      el.style.cursor = "pointer";
      el.style.pointerEvents = "auto";
      el.onpointerdown = (e) => e.stopPropagation();
      el.onpointerup = (e) => e.stopPropagation();
      el.className = "custom-marker";
      el.style.width = `${globeMarkerWidth}px`;
      el.style.height = `${markerHeight}px`;
      el.style.backgroundImage = `url("${MARKER_ICONS[d.type] ?? MARKER_ICONS.outcome}")`;
      el.onclick = (e) => handleMarkerClick(e, d);

      container.appendChild(el);
      return container;
    },
    [handleMarkerClick, markerHeight, globeMarkerWidth]
  );

  return (
    <div className="interactive-globe">
      <Globe
        ref={globeRef}
        width={width}
        height={height}
        backgroundColor="rgba(0,0,0,0)"
        globeImageUrl={null}
        globeMaterial={globeMaterial}
        showAtmosphere
        atmosphereColor={globeAtmosphereColor}
        atmosphereAltitude={globeAtmosphereAltitude}
        polygonsData={countryFeatures}
        polygonCapColor={polygonCapColor}
        polygonSideColor={polygonSideColor}
        polygonStrokeColor={polygonStrokeColor}
        polygonAltitude={globePolygonAltitude}
        polygonLabel={polygonLabel}
        polygonsTransitionDuration={globePolygonsTransitionDurationMs}
        htmlElementsData={locations}
        htmlElement={htmlElementBuilder}
        htmlLat={(d) => d.lat}
        htmlLng={(d) => d.lng}
        onGlobeClick={() => onSelectLocation(null)}
      />
    </div>
  );
}

InteractiveGlobe.propTypes = {
  locations: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
      lat: PropTypes.number.isRequired,
      lng: PropTypes.number.isRequired,
      type: PropTypes.oneOf(["outcome", "impact"]).isRequired,
    })
  ).isRequired,
  countryColorByCcn3: PropTypes.instanceOf(Map).isRequired,
  defaultCountryColor: PropTypes.string.isRequired,
  width: PropTypes.number.isRequired,
  height: PropTypes.number.isRequired,
  active: PropTypes.bool,
  autoRotate: PropTypes.bool,
  focusedLocation: PropTypes.shape({
    lat: PropTypes.number.isRequired,
    lng: PropTypes.number.isRequired,
  }),
  onSelectLocation: PropTypes.func.isRequired,
};