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:
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the FA ClusterMarker atom */
|
||||
export interface ClusterMarkerProps {
|
||||
/** Number of providers in this cluster */
|
||||
count: number;
|
||||
/** True if any provider in the cluster is verified — drives the promoted palette */
|
||||
hasVerified?: boolean;
|
||||
/** Click handler — opens the cluster popup */
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||
const BADGE_SIZE = 36;
|
||||
|
||||
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
|
||||
|
||||
const colours = {
|
||||
verified: {
|
||||
bg: 'var(--fa-color-brand-700)',
|
||||
text: 'var(--fa-color-white)',
|
||||
border: 'var(--fa-color-brand-700)',
|
||||
nub: 'var(--fa-color-brand-700)',
|
||||
},
|
||||
unverified: {
|
||||
bg: 'var(--fa-color-neutral-100)',
|
||||
text: 'var(--fa-color-neutral-800)',
|
||||
border: 'var(--fa-color-neutral-300)',
|
||||
nub: 'var(--fa-color-neutral-100)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cluster map marker for the FA design system.
|
||||
*
|
||||
* Circular pill with a count, representing N provider pins grouped at the
|
||||
* same screen location. Sibling to `MapPin` — same palette language (verified
|
||||
* promoted, unverified neutral), same nub treatment, same shadow.
|
||||
*
|
||||
* `hasVerified` drives the palette: if *any* provider in the cluster is
|
||||
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
|
||||
* clusters use the neutral palette.
|
||||
*
|
||||
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
|
||||
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <ClusterMarker count={5} hasVerified onClick={...} />
|
||||
* <ClusterMarker count={12} />
|
||||
* ```
|
||||
*/
|
||||
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
|
||||
({ count, hasVerified = false, onClick, sx }, ref) => {
|
||||
const palette = hasVerified ? colours.verified : colours.unverified;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e as unknown as React.MouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const label = `${count} providers in this area`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
sx={[
|
||||
{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 150ms ease-in-out',
|
||||
// Fade in on mount — matches MapPin and popups for a consistent
|
||||
// entry timing across the map.
|
||||
'@keyframes clusterMarkerIn': {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
animation: 'clusterMarkerIn 180ms ease-out',
|
||||
'&:hover': { transform: 'scale(1.08)' },
|
||||
'&:focus-visible': {
|
||||
outline: 'none',
|
||||
'& > .ClusterMarker-badge': {
|
||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Circular badge */}
|
||||
<Box
|
||||
className="ClusterMarker-badge"
|
||||
sx={{
|
||||
width: BADGE_SIZE,
|
||||
height: BADGE_SIZE,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: palette.bg,
|
||||
border: '1px solid',
|
||||
borderColor: palette.border,
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
color: palette.text,
|
||||
fontFamily: 'var(--fa-font-family-body)',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</Box>
|
||||
|
||||
{/* Nub — same SVG pattern as MapPin for visual continuity */}
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 16 8"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: `calc(2 * ${NUB_SIZE})`,
|
||||
height: NUB_SIZE,
|
||||
marginTop: '-1px',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||
<path
|
||||
d="M 0 0 L 8 8 L 16 0"
|
||||
fill="none"
|
||||
stroke={palette.border}
|
||||
strokeWidth={1}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ClusterMarker.displayName = 'ClusterMarker';
|
||||
export default ClusterMarker;
|
||||
Reference in New Issue
Block a user