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 ────────────────────────────────────────────────────────────────── /** Shape of the currently-active provider or cluster selection, emitted to * callers that opt into external popup rendering (see `externalisePopups`). */ export interface ProviderMapActiveState { /** Active single provider, if a pin was tapped (or a cluster row drilled into) */ provider: ProviderData | null; /** Active cluster, if a cluster marker was tapped and no row has been drilled into */ cluster: { providers: ProviderData[]; position: { lat: number; lng: number } } | null; /** True while the exit animation is running — callers may want to mirror it */ exiting: boolean; } /** Imperative handle exposed via ref. Used when rendering popups externally. */ export interface ProviderMapHandle { /** Close the currently-active popup (animated). No-op if nothing is open. */ clearActive: () => void; /** Pan + zoom the map to a provider's coords and set them as the active * single-provider selection. Equivalent to a cluster-row tap. */ drillIntoProvider: (id: string) => void; } /** 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; /** When true, suppress the organism's own MapPopup + ClusterPopup rendering. * The active state is still tracked internally (pins still hide when active) * and emitted via `onActiveChange` so callers can render a drawer, sheet, * or other external container. Used by the mobile map-first layout. */ externalisePopups?: boolean; /** Fires whenever the active provider/cluster state changes. Paired with * `externalisePopups` — the caller uses this to drive external UI. */ onActiveChange?: (state: ProviderMapActiveState) => void; /** MUI sx prop for the root element */ sx?: SxProps; } 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; }> = ({ 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; 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(); 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( { 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(); 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 }) => ( Map unavailable {reason === 'no-key' ? 'Google Maps API key not configured.' : 'No provider locations to display.'} ); // ─── 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( ( { providers, selectedProviderId, onSelectProvider, defaultCenter = FALLBACK_CENTER, defaultZoom = FALLBACK_ZOOM, apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY, externalisePopups = false, onActiveChange, sx, }, ref, ) => { const [activeProviderId, setActiveProviderId] = React.useState(null); const [activeCluster, setActiveCluster] = React.useState(null); const [exiting, setExiting] = React.useState(false); const mapRef = React.useRef(null); const exitTimerRef = React.useRef(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(); 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; // Emit active-state changes when the caller is rendering popups externally. const onActiveChangeRef = React.useRef(onActiveChange); React.useEffect(() => { onActiveChangeRef.current = onActiveChange; }, [onActiveChange]); React.useEffect(() => { onActiveChangeRef.current?.({ provider: activeProvider, cluster: activeCluster ? { providers: activeCluster.providers, position: { lat: activeCluster.position.lat, lng: activeCluster.position.lng, }, } : null, exiting, }); }, [activeProvider, activeCluster, exiting]); /** 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], ); // Imperative handle for external callers (drawer close, cluster-row tap). React.useImperativeHandle( ref, () => ({ clearActive: closeWithExit, drillIntoProvider: handleDrillIntoProvider, }), [closeWithExit, handleDrillIntoProvider], ); 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 ( ); } if (withCoords.length === 0) { return ( ); } return ( {/* Internal popups — skipped when caller externalises them (e.g. mobile drawer). Active state still flows via onActiveChange. */} {!externalisePopups && activeProvider && ( onSelectProvider(activeProvider.id)} /> )} {/* 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. */} {!externalisePopups && activeCluster && !activeProviderId && ( ({ id: p.id, name: p.name, location: p.location, verified: p.verified, rating: p.rating, startingPrice: p.startingPrice, }))} exiting={exiting} onSelectProvider={handleDrillIntoProvider} onClose={handleCloseCluster} /> )} ); }, ); ProviderMap.displayName = 'ProviderMap'; export default ProviderMap;