pages/ValuePartnership/GlobePage/GlobePage.jsxjavascript
import { useState, useMemo, useEffect, lazy, Suspense } from "react";
import PropTypes from "prop-types";
import { useContent } from "../../../services/content";
import { useConfig } from "../../../services/config/config";
import { INITIAL_FILTERS } from "../../../services/config/globeDefaults";
import { useIdle } from "../../../hooks/idle";
import { useElementSize } from "../../../hooks/elementSize";
import { resolveAssetPath } from "../../../utils/resolveAssetPath";
import { resolveCountryColors } from "../../../components/InteractiveGlobe/countryColors";
import { ValueCheckboxPanel } from "./ValueCheckboxPanel";
import { GlobeDetailCard } from "./GlobeDetailCard";
import { GlobeBottomNav } from "./GlobeBottomNav";
import "./GlobePage.scss";
// code-split: three.js/react-globe.gl/world-atlas are heavy (~MBs) and
// otherwise get bundled into every page's initial load even though only
// this one uses them - loading them lazily means the app-selection screen,
// VAS, RAD and Value Partnership's own Excellence/Partnerships tabs never
// pay for them
const InteractiveGlobe = lazy(
() => import("../../../components/InteractiveGlobe/InteractiveGlobe")
);
// matches the "Open search" field against the fields shown on the marker's
// detail card (customer, location, headline) - not part of the old app,
// which had no search on this page
const matchesSearch = (point, search) => {
const term = search.trim().toLowerCase();
if (!term) return true;
return [point.customer, point.country, point.city, point.headline]
.filter(Boolean)
.some((field) => field.toLowerCase().includes(term));
};
// ported from value-globe.js's updated()/loadPoints() visibility predicate
const isPointVisible = (point, filters) => {
if (!matchesSearch(point, filters.search)) return false;
if (point.type === "outcome") {
if (!filters.outcomeCasesActive) return false;
const matchesDrivers = point.drivers.some((d) =>
filters.checkedOutcomeCases.some((group) =>
group.items.some((item) => item.checked && item.value === d)
)
);
// Strategy (Access/Efficiency/Clinical Excellence) narrows the driver
// match further (AND, not an alternative view) - a handful of points
// have no `strategy` in the content yet (or an intentionally empty
// array, per the source CSV), those stay visible regardless of this
// filter rather than disappearing for lack of classification.
const matchesStrategy =
!point.strategy?.length ||
point.strategy.some((s) =>
filters.checkedStrategyCases.some((item) => item.checked && item.value === s)
);
return matchesDrivers && matchesStrategy;
}
if (point.type === "impact") {
if (!filters.impactCasesActive) return false;
return point.drivers.some((d) =>
filters.checkedImpactCases.some((item) => item.checked && item.value === d)
);
}
return true;
};
/**
* "Globe" tab of Value Partnership: the 3D {@link InteractiveGlobe} (lazy-
* loaded, see below) with its {@link ValueCheckboxPanel} filter/search
* drawer, {@link GlobeBottomNav} home/prev/next bar and, once a marker is
* selected, its {@link GlobeDetailCard}. `isPointVisible` (ported from the
* old app's value-globe.js) decides which of `content.valuePartnership.points`
* are shown, combining the checkbox panel's filters and search field.
*/
export const GlobePage = ({ active = true, onSelectionChange }) => {
const { content } = useContent();
const { globeAutoRotateIdleMs, globeFallbackCountryColor } = useConfig();
const { points = [], countryDisplay, labels = {} } = content.valuePartnership || {};
const [filters, setFilters] = useState(INITIAL_FILTERS);
const [selected, setSelected] = useState(null);
// ambient "screensaver-ish" auto-rotate while nobody is touching the globe -
// intentionally its own short local idle timer (globeAutoRotateIdleMs,
// config-schema.js), not the kiosk-wide screensaver timeout
// (useScreensaver/idle.jsx): by the time the *kiosk* screensaver timeout
// fires, its full-screen video already covers the globe, so coupling to
// that timeout would never be visible - see the Value Partnership
// implementation plan, open question 5.
const { isIdle } = useIdle(globeAutoRotateIdleMs);
// react-globe.gl otherwise defaults its canvas to window.innerWidth/
// innerHeight (captured once, never updated on resize) instead of this
// container's own box - wrong even before Stage (see InteractiveGlobe.jsx),
// and definitely wrong once Stage scales this page to something other than
// the real viewport. `.globe-page` stays measurable via ResizeObserver even
// while hidden (ValuePartnership.scss toggles `visibility`, not `display`,
// while another tab is active).
const [globePageRef, { width: globeWidth, height: globeHeight }] = useElementSize();
const locations = useMemo(
() => points.filter((p) => isPointVisible(p, filters)).map((p) => ({ ...p, lng: p.lon })),
[points, filters]
);
// see countryColors.js for the continents/countries/regions resolution
// rules - shared with the print map (PrintMap.jsx) so both render the
// exact same country colors from the same content.
const { defaultCountryColor, countryColorByCcn3 } = useMemo(
() => resolveCountryColors(countryDisplay, globeFallbackCountryColor),
[countryDisplay, globeFallbackCountryColor]
);
// tells the parent to unmount ValuePartnershipNav while GlobeBottomNav
// occupies its slot instead. GlobePage now stays mounted while the user is
// on the Excellence/Partnerships tabs (see ValuePartnership.jsx) rather
// than being torn down, so `active` has to be part of this - otherwise
// switching tabs wouldn't change `selected`, and the effect would never
// re-run to update ValuePartnershipNav's visibility for the newly active
// tab. The unmount-reset effect stays as a fallback for when this
// component genuinely unmounts (leaving Value Partnership entirely).
useEffect(() => {
onSelectionChange?.(active && !!selected);
}, [active, selected, onSelectionChange]);
useEffect(() => () => onSelectionChange?.(false), [onSelectionChange]);
if (!content.valuePartnership) return null;
return (
<div className="globe-page" ref={globePageRef}>
<Suspense fallback={null}>
<InteractiveGlobe
locations={locations}
countryColorByCcn3={countryColorByCcn3}
defaultCountryColor={defaultCountryColor}
width={globeWidth}
height={globeHeight}
active={active}
autoRotate={isIdle}
focusedLocation={selected}
onSelectLocation={setSelected}
/>
</Suspense>
<ValueCheckboxPanel filters={filters} onChange={setFilters} />
<div className="globe-page__headline">
<h1>{labels.globeHeader}</h1>
</div>
{!selected && (
<div className="globe-page__rotate-hint">
{/* TODO: swap for the real icon once it's pulled from Figma - see
InteractiveGlobe's MARKER_ICONS/globeDefaults.js for where
other globe-page SVG assets live
(public/assets/images/value-partnership/). */}
<img
className="globe-page__rotate-hint-icon"
src={resolveAssetPath("/assets/images/value-partnership/rotate_hint.svg")}
alt=""
/>
<span>Rotate the globe</span>
</div>
)}
{selected && (
<GlobeDetailCard
key={selected.id}
point={selected}
labels={labels}
onClose={() => setSelected(null)}
/>
)}
<GlobeBottomNav
locations={locations}
selectedId={selected?.id}
labels={labels}
onSelect={setSelected}
onHome={() => setSelected(null)}
/>
</div>
);
};
GlobePage.propTypes = {
// whether the "globe" tab is the currently visible one - see
// ValuePartnership.jsx, which keeps GlobePage mounted (just hidden) while
// on the other tabs instead of unmounting it
active: PropTypes.bool,
// called with true while a marker is selected, false once cleared - lets
// the parent unmount ValuePartnershipNav so GlobeBottomNav can take its slot
onSelectionChange: PropTypes.func,
};