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,
};