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>
162 lines
5.4 KiB
TypeScript
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;
|