Add Google Maps ProviderMap organism with clustering + popup flow
Introduces a full Google-Maps-backed provider map for the arrangement wizard's ProvidersStep. Clicking a pin morphs it into a MapPopup at the same coord; pins within 70px of each other collapse into a cluster (ceiling at zoom 13) that opens a ClusterPopup list on click. Row clicks pan + zoom the map to the provider and open their MapPopup. Map-background click routes through an exit transition that fades the popup out before reappearing the pin, via a matching fade-in keyframe on the atom markers. Key additions: - @vis.gl/react-google-maps + @googlemaps/markerclusterer deps - ClusterMarker atom (count badge; verified / unverified palettes) - ClusterPopup molecule (image-free rows; verified icon aligned to name; right-aligned "From $X" column; verified-first sort) - ProviderMap organism (APIProvider + Map + imperative AdvancedMarker layer via createRoot for clusterer compatibility) Component changes: - MapPin: promoted verified palette (brand-700); name now required; name-only and price-only variants dropped; active prop removed in favour of organism-level state; SVG nub with fill+stroke replaces the CSS border-triangle trick so the outline is continuous - MapPopup: `exiting` prop drives close animation; click events stop propagation so the map's onClick can't clear state mid-interaction - ProviderData type gains optional `coords`; demo fixtures populated with real NSW/QLD lat/lng for all 7 providers - ProvidersStep demo route wires ProviderMap into the mapPanel slot Memory: - docs/memory/component-registry updated (ClusterMarker, ClusterPopup, ProviderMap added; MapPin + MapPopup refined; MapCard retired) - docs/memory/session-log captures arc across 2026-04-21/22 and flags next-session work: ProvidersStep polish, mobile layout for list-map WizardLayout, and demo deploy Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
487
src/components/organisms/ProviderMap/ProviderMap.tsx
Normal file
487
src/components/organisms/ProviderMap/ProviderMap.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import React from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import {
|
||||
APIProvider,
|
||||
Map as GoogleMap,
|
||||
AdvancedMarker,
|
||||
useMap,
|
||||
useMapsLibrary,
|
||||
} from '@vis.gl/react-google-maps';
|
||||
import { MarkerClusterer, GridAlgorithm } from '@googlemaps/markerclusterer';
|
||||
import { MapPin } from '../../atoms/MapPin';
|
||||
import { ClusterMarker } from '../../atoms/ClusterMarker';
|
||||
import { MapPopup } from '../../molecules/MapPopup';
|
||||
import { ClusterPopup } from '../../molecules/ClusterPopup';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import type { ProviderData } from '../../pages/ProvidersStep';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sydney — fallback centre when no providers have coords and no default supplied */
|
||||
const FALLBACK_CENTER = { lat: -33.8688, lng: 151.2093 };
|
||||
const FALLBACK_ZOOM = 5;
|
||||
/** Google Maps requires a mapId for AdvancedMarker support */
|
||||
const MAP_ID = 'fa-provider-map';
|
||||
/** fitBounds padding (applied as google.maps.Padding) */
|
||||
const BOUNDS_PADDING = { top: 64, right: 48, bottom: 64, left: 48 };
|
||||
/** Screen-pixel radius at which nearby pins collapse into a cluster */
|
||||
const CLUSTER_GRID_SIZE = 70;
|
||||
/** Zoom level above which clustering is disabled (pins show individually) */
|
||||
const CLUSTER_MAX_ZOOM = 13;
|
||||
/** Zoom level the map animates to on cluster drill-in (street-level, past
|
||||
* CLUSTER_MAX_ZOOM so nearby cluster members break apart into their own pins) */
|
||||
const DRILL_IN_ZOOM = 15;
|
||||
/** Exit-animation duration for popups on close — keep in sync with the
|
||||
* transition values set on MapPopup/ClusterPopup. */
|
||||
const POPUP_EXIT_MS = 180;
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the FA ProviderMap organism */
|
||||
export interface ProviderMapProps {
|
||||
/** Providers to render as pins. Providers without coords are filtered out silently. */
|
||||
providers: ProviderData[];
|
||||
/** ID of the provider whose popup should open (external selection, e.g. list hover) */
|
||||
selectedProviderId?: string | null;
|
||||
/** Called when the user clicks through a popup — usually triggers navigation */
|
||||
onSelectProvider: (id: string) => void;
|
||||
/** Initial map centre — used only when no providers have coords */
|
||||
defaultCenter?: { lat: number; lng: number };
|
||||
/** Initial zoom — used only when no providers have coords */
|
||||
defaultZoom?: number;
|
||||
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
|
||||
apiKey?: string;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
interface ActiveCluster {
|
||||
providers: ProviderData[];
|
||||
position: google.maps.LatLngLiteral;
|
||||
}
|
||||
|
||||
// ─── Internal components ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fits the map to the bounds of all providers with coords. Runs whenever the
|
||||
* provider list changes. Sited inside APIProvider so `useMap()` resolves.
|
||||
*/
|
||||
const FitBounds: React.FC<{ providers: ProviderData[] }> = ({ providers }) => {
|
||||
const map = useMap();
|
||||
React.useEffect(() => {
|
||||
if (!map) return;
|
||||
const withCoords = providers.filter((p) => p.coords);
|
||||
if (withCoords.length === 0) return;
|
||||
if (withCoords.length === 1) {
|
||||
map.setCenter(withCoords[0].coords!);
|
||||
map.setZoom(13);
|
||||
return;
|
||||
}
|
||||
const bounds = new window.google.maps.LatLngBounds();
|
||||
withCoords.forEach((p) => bounds.extend(p.coords!));
|
||||
map.fitBounds(bounds, BOUNDS_PADDING);
|
||||
}, [map, providers]);
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures the Google Map instance into a parent ref so imperative
|
||||
* actions (panTo, setZoom) can be triggered from outside the Map context.
|
||||
*/
|
||||
const MapRefCapture: React.FC<{
|
||||
mapRef: React.MutableRefObject<google.maps.Map | null>;
|
||||
}> = ({ mapRef }) => {
|
||||
const map = useMap();
|
||||
React.useEffect(() => {
|
||||
mapRef.current = map;
|
||||
}, [map, mapRef]);
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Imperative marker layer — builds AdvancedMarker instances with React
|
||||
* content, groups them via MarkerClusterer, and rebuilds whenever the
|
||||
* visible provider set changes.
|
||||
*
|
||||
* Providers listed in `hiddenIds` are excluded from the map (their popup is
|
||||
* currently showing instead).
|
||||
*/
|
||||
const MarkerLayer: React.FC<{
|
||||
providers: ProviderData[];
|
||||
hiddenIds: Set<string>;
|
||||
onPinClick: (id: string) => void;
|
||||
onClusterClick: (providers: ProviderData[], position: google.maps.LatLngLiteral) => void;
|
||||
}> = ({ providers, hiddenIds, onPinClick, onClusterClick }) => {
|
||||
const map = useMap();
|
||||
const markerLibrary = useMapsLibrary('marker');
|
||||
|
||||
// Stash callbacks in a ref so the effect below doesn't re-run (and rebuild
|
||||
// every marker) when the parent passes fresh arrow-function references.
|
||||
const onPinClickRef = React.useRef(onPinClick);
|
||||
const onClusterClickRef = React.useRef(onClusterClick);
|
||||
React.useEffect(() => {
|
||||
onPinClickRef.current = onPinClick;
|
||||
onClusterClickRef.current = onClusterClick;
|
||||
}, [onPinClick, onClusterClick]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!map || !markerLibrary) return;
|
||||
|
||||
const roots: Root[] = [];
|
||||
const markerToProvider = new Map<google.maps.marker.AdvancedMarkerElement, ProviderData>();
|
||||
|
||||
const markers = providers
|
||||
.filter((p) => p.coords && !hiddenIds.has(p.id))
|
||||
.map((p) => {
|
||||
const el = document.createElement('div');
|
||||
const root = createRoot(el);
|
||||
// MapPin's own onClick stays for keyboard a11y (Enter/Space via its
|
||||
// onKeyDown). stopPropagation guards against the DOM click bubbling
|
||||
// to the Map's onClick and closing the popup the same frame it opens.
|
||||
root.render(
|
||||
<MapPin
|
||||
name={p.name}
|
||||
price={p.startingPrice}
|
||||
verified={p.verified}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPinClickRef.current(p.id);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
roots.push(root);
|
||||
|
||||
const marker = new markerLibrary.AdvancedMarkerElement({
|
||||
position: p.coords,
|
||||
content: el,
|
||||
gmpClickable: true,
|
||||
});
|
||||
// Also listen at the Google Maps level + stop the GMaps event so
|
||||
// Map's onClick can't fire when a pin is clicked via mouse. Safe to
|
||||
// fire twice with keyboard — handlePinClick is idempotent.
|
||||
marker.addListener('click', (event: google.maps.MapMouseEvent) => {
|
||||
event.stop();
|
||||
onPinClickRef.current(p.id);
|
||||
});
|
||||
markerToProvider.set(marker, p);
|
||||
return marker;
|
||||
});
|
||||
|
||||
const clusterer = new MarkerClusterer({
|
||||
map,
|
||||
markers,
|
||||
algorithm: new GridAlgorithm({
|
||||
maxZoom: CLUSTER_MAX_ZOOM,
|
||||
gridSize: CLUSTER_GRID_SIZE,
|
||||
}),
|
||||
// Override the library's default "zoom to fit cluster" on click —
|
||||
// we open the cluster popup instead. The event shape the library
|
||||
// passes varies: sometimes a google.maps.MapMouseEvent (has .stop),
|
||||
// sometimes a plain DOM MouseEvent. Stop whichever we got so the
|
||||
// click doesn't also fire Map.onClick and clear our state.
|
||||
onClusterClick: (event, cluster) => {
|
||||
const anyEvent = event as unknown as {
|
||||
stop?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
domEvent?: { stopPropagation?: () => void };
|
||||
};
|
||||
anyEvent.stop?.();
|
||||
anyEvent.stopPropagation?.();
|
||||
anyEvent.domEvent?.stopPropagation?.();
|
||||
|
||||
const providersInCluster = cluster.markers
|
||||
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
|
||||
.filter((p): p is ProviderData => !!p);
|
||||
const clusterPosition =
|
||||
cluster.position instanceof window.google.maps.LatLng
|
||||
? cluster.position.toJSON()
|
||||
: (cluster.position as google.maps.LatLngLiteral);
|
||||
onClusterClickRef.current(providersInCluster, clusterPosition);
|
||||
},
|
||||
renderer: {
|
||||
render: ({ count, position, markers: clusterMarkers }) => {
|
||||
const providersInCluster = clusterMarkers
|
||||
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
|
||||
.filter((p): p is ProviderData => !!p);
|
||||
const hasVerified = providersInCluster.some((p) => p.verified);
|
||||
|
||||
const el = document.createElement('div');
|
||||
const root = createRoot(el);
|
||||
// Visual only — click is handled at the MarkerClusterer level above.
|
||||
root.render(<ClusterMarker count={count} hasVerified={hasVerified} />);
|
||||
roots.push(root);
|
||||
|
||||
return new markerLibrary.AdvancedMarkerElement({
|
||||
position,
|
||||
content: el,
|
||||
gmpClickable: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
clusterer.clearMarkers();
|
||||
// Defer unmount so React doesn't warn about unmounting during render.
|
||||
setTimeout(() => {
|
||||
roots.forEach((r) => r.unmount());
|
||||
}, 0);
|
||||
};
|
||||
}, [map, markerLibrary, providers, hiddenIds]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Empty-state shown when no API key is configured or no providers have coords. */
|
||||
const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) => (
|
||||
<Box sx={{ m: 'auto', textAlign: 'center', px: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Map unavailable
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{reason === 'no-key'
|
||||
? 'Google Maps API key not configured.'
|
||||
: 'No provider locations to display.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Google Map showing provider pins with clustering + click-to-open popups.
|
||||
*
|
||||
* **Interaction model:**
|
||||
* - Clicking an individual pin **morphs** it into a `MapPopup` at the same
|
||||
* coord. Clicking the map background reverts.
|
||||
* - Pins within `CLUSTER_GRID_SIZE` (70px) of each other collapse into a
|
||||
* `ClusterMarker` — but only while zoomed out at level `CLUSTER_MAX_ZOOM`
|
||||
* (13) or below. Zoom in past that and every pin shows individually.
|
||||
* - Clicking a cluster opens a `ClusterPopup` listing its providers
|
||||
* (verified-first). Clicking a row **pans and zooms the map to that
|
||||
* provider's location** (zoom 15 = past the clustering ceiling, so the
|
||||
* other cluster members separate into their own pins around the selected
|
||||
* one) and opens that provider's `MapPopup`. The cluster state is cleared
|
||||
* — there's no back-to-list; the user's path forward is clear rather than
|
||||
* hierarchical.
|
||||
*
|
||||
* **Viewport:** auto-fits to include every provider with coords on load and
|
||||
* when the list changes. Single-provider maps centre with zoom 13.
|
||||
*
|
||||
* **Empty states:** if no API key is set or no providers have coords, a
|
||||
* subtle empty state renders in place (no throw).
|
||||
*
|
||||
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
|
||||
* (molecules). Clustering via `@googlemaps/markerclusterer`.
|
||||
*/
|
||||
export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
||||
(
|
||||
{
|
||||
providers,
|
||||
selectedProviderId,
|
||||
onSelectProvider,
|
||||
defaultCenter = FALLBACK_CENTER,
|
||||
defaultZoom = FALLBACK_ZOOM,
|
||||
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
|
||||
sx,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [activeProviderId, setActiveProviderId] = React.useState<string | null>(null);
|
||||
const [activeCluster, setActiveCluster] = React.useState<ActiveCluster | null>(null);
|
||||
const [exiting, setExiting] = React.useState(false);
|
||||
const mapRef = React.useRef<google.maps.Map | null>(null);
|
||||
const exitTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
// Helper: cancel any pending exit timer so rapid clicks don't clobber
|
||||
// newly-opened popups with a leftover clear from a previous close.
|
||||
const cancelExit = React.useCallback(() => {
|
||||
if (exitTimerRef.current) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
setExiting(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const withCoords = React.useMemo(() => providers.filter((p) => p.coords), [providers]);
|
||||
|
||||
// External selection (e.g. list hover) force-opens a popup. Internal click wins.
|
||||
const effectiveProviderId = activeProviderId ?? selectedProviderId ?? null;
|
||||
|
||||
const activeProvider = React.useMemo(
|
||||
() =>
|
||||
effectiveProviderId ? (withCoords.find((p) => p.id === effectiveProviderId) ?? null) : null,
|
||||
[withCoords, effectiveProviderId],
|
||||
);
|
||||
|
||||
// Pins hidden from the map (because their popup is showing instead).
|
||||
const hiddenIds = React.useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
if (effectiveProviderId) s.add(effectiveProviderId);
|
||||
if (activeCluster) {
|
||||
activeCluster.providers.forEach((p) => s.add(p.id));
|
||||
}
|
||||
return s;
|
||||
}, [effectiveProviderId, activeCluster]);
|
||||
|
||||
const handlePinClick = React.useCallback(
|
||||
(id: string) => {
|
||||
cancelExit();
|
||||
setActiveProviderId(id);
|
||||
setActiveCluster(null);
|
||||
},
|
||||
[cancelExit],
|
||||
);
|
||||
|
||||
const handleClusterClick = React.useCallback(
|
||||
(clusterProviders: ProviderData[], position: google.maps.LatLngLiteral) => {
|
||||
cancelExit();
|
||||
setActiveProviderId(null);
|
||||
setActiveCluster({ providers: clusterProviders, position });
|
||||
},
|
||||
[cancelExit],
|
||||
);
|
||||
|
||||
/** Shared close path — animate the popup out (exiting=true triggers the
|
||||
* CSS transition in MapPopup / ClusterPopup), then actually clear state
|
||||
* after the transition completes so the pin can fade back in. */
|
||||
const closeWithExit = React.useCallback(() => {
|
||||
if (!activeProviderId && !activeCluster) return;
|
||||
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||
setExiting(true);
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
setActiveProviderId(null);
|
||||
setActiveCluster(null);
|
||||
setExiting(false);
|
||||
exitTimerRef.current = null;
|
||||
}, POPUP_EXIT_MS);
|
||||
}, [activeProviderId, activeCluster]);
|
||||
|
||||
const handleMapClick = closeWithExit;
|
||||
const handleCloseCluster = closeWithExit;
|
||||
|
||||
/** Cluster list → single-provider drill-in.
|
||||
* Pans + zooms the map to the provider's coords (zoom 15 = past
|
||||
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
|
||||
* pins around the selected one), then clears the cluster state and
|
||||
* opens the single-provider popup. */
|
||||
const handleDrillIntoProvider = React.useCallback(
|
||||
(id: string) => {
|
||||
cancelExit();
|
||||
const provider = withCoords.find((p) => p.id === id);
|
||||
if (provider?.coords && mapRef.current) {
|
||||
mapRef.current.panTo(provider.coords);
|
||||
mapRef.current.setZoom(DRILL_IN_ZOOM);
|
||||
}
|
||||
setActiveProviderId(id);
|
||||
setActiveCluster(null);
|
||||
},
|
||||
[withCoords, cancelExit],
|
||||
);
|
||||
|
||||
const rootSx = [
|
||||
{
|
||||
position: 'relative' as const,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minHeight: 300,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'var(--fa-color-surface-cool)',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
];
|
||||
|
||||
// Empty states
|
||||
if (!apiKey) {
|
||||
return (
|
||||
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||
<MapEmptyState reason="no-key" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (withCoords.length === 0) {
|
||||
return (
|
||||
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||
<MapEmptyState reason="no-coords" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<GoogleMap
|
||||
defaultCenter={defaultCenter}
|
||||
defaultZoom={defaultZoom}
|
||||
mapId={MAP_ID}
|
||||
disableDefaultUI
|
||||
zoomControl
|
||||
gestureHandling="greedy"
|
||||
onClick={handleMapClick}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<FitBounds providers={withCoords} />
|
||||
<MapRefCapture mapRef={mapRef} />
|
||||
|
||||
<MarkerLayer
|
||||
providers={withCoords}
|
||||
hiddenIds={hiddenIds}
|
||||
onPinClick={handlePinClick}
|
||||
onClusterClick={handleClusterClick}
|
||||
/>
|
||||
|
||||
{/* Single-provider popup (pin click OR post-zoom cluster drill-in) */}
|
||||
{activeProvider && (
|
||||
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
|
||||
<MapPopup
|
||||
name={activeProvider.name}
|
||||
imageUrl={activeProvider.imageUrl}
|
||||
price={activeProvider.startingPrice}
|
||||
location={activeProvider.location}
|
||||
rating={activeProvider.rating}
|
||||
verified={activeProvider.verified}
|
||||
exiting={exiting}
|
||||
onClick={() => onSelectProvider(activeProvider.id)}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
|
||||
{/* Cluster list popup — shown while a cluster is active and no
|
||||
provider has been drilled into. Drilling clears activeCluster,
|
||||
which swaps this for the single-provider popup above. */}
|
||||
{activeCluster && !activeProviderId && (
|
||||
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
|
||||
<ClusterPopup
|
||||
providers={activeCluster.providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
location: p.location,
|
||||
verified: p.verified,
|
||||
rating: p.rating,
|
||||
startingPrice: p.startingPrice,
|
||||
}))}
|
||||
exiting={exiting}
|
||||
onSelectProvider={handleDrillIntoProvider}
|
||||
onClose={handleCloseCluster}
|
||||
/>
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProviderMap.displayName = 'ProviderMap';
|
||||
export default ProviderMap;
|
||||
Reference in New Issue
Block a user