Files
Parsons/src/components/atoms/ClusterMarker/ClusterMarker.tsx
Richie e78d88b2f3 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>
2026-04-22 09:29:37 +10:00

162 lines
5.4 KiB
TypeScript

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;