Map Markers
Each accessible place on the map is rendered as one of three visual states: a pin with label, a dot (when the pin is collision-hidden, or when the place is a "next page" preview), or a scaled-up selected pin. All three are powered by @vis.gl/react-google-maps's <AdvancedMarker> and animated with framer-motion.
Components
| Component | File | Purpose |
|---|---|---|
UnifiedMarker | src/components/map/unified-marker.tsx | Full pin + label for places on the current results page |
DotMarker | src/components/map/dot-marker.tsx | Small colored dot for "next page" places (out-of-view results preview) |
MapLoadingToast | src/components/map/map-loading-toast.tsx | Top-of-map status pill while the bounds query is in flight |
The map mounts both marker layers from src/components/map/google-map.tsx:
<AnimatePresence>
{nextPagePlaces.map((place) => <DotMarker ... />)}
</AnimatePresence>
<AnimatePresence>
{placesToRender.map((place) => <UnifiedMarker ... />)}
</AnimatePresence>
AnimatePresence lets markers animate out cleanly when they leave the viewport or get filtered out.
Pin Asset Resolution
File: src/lib/place-icons.ts
Each pin is an SVG keyed by a PinSlug derived from the place's Google primaryType:
| Slug | Color | Examples (primaryType) |
|---|---|---|
restaurant | #D64D35 | restaurant, bakery, meal_takeaway, anything matching /_restaurant$/ pattern |
cafe | #C88C38 | cafe, coffee_shop |
nature | #4EC373 | park, national_park, dog_park |
pharmacy | #6E64C8 | pharmacy, drugstore |
attraction | #63A1E2 | museum, art_gallery, tourist_attraction, zoo |
venue | #D46686 | bar, night_club, concert_hall, stadium |
misc | #6B6B6B | Fallback when no rule matches |
Resolution rules in resolvePinSlug():
- Exact match against
PRIMARY_TYPE_TO_PIN. - Regex pattern match against
PATTERN_RULES(e.g. any*_restaurantresolves torestaurant). - Fallback to
'misc'.
Two consumers:
getPinSrc(primaryType)→/assets/pins/{slug}.svgfor<Image>rendering.getPinColor(primaryType)→ hex string for the fallback dot'sbackgroundColor.
SVG assets live under public/assets/pins/.
UnifiedMarker — Pin, Dot, and Label
Every place on the current page is rendered by UnifiedMarker, which actually emits two sibling <AdvancedMarker> instances anchored at the same lat/lng:
1. The Pin (always emitted)
- Anchor:
-17px / -39px(centers a 35×39 pin asset on the lat/lng pixel). - Collision behavior:
REQUIRED_AND_HIDES_OPTIONALwhen selected — the selected pin is never collision-hidden and pushes others out.OPTIONAL_AND_HIDES_LOWER_PRIORITYotherwise — Google's collision avoidance can drop it to keep the map readable.
zIndex:10selected,5otherwise.- Inside: an
ImagefromgetPinSrc(primaryType)plus a label (see below). Both are wrapped inmotion.divs with spring animations on enter/scale.
2. The Fallback Dot (non-selected only)
- Anchor:
-6px / -6px(centers a 12×12 dot on the same lat/lng pixel). collisionBehavior="OPTIONAL_AND_HIDES_LOWER_PRIORITY"andzIndex: 0.- The dot's small bounding box (12×12) often fits where the larger pin (35×39) collides with neighbors. With
zIndex: 0, the pin always wins where both fit; the dot is only visible where the pin was collision-hidden.
This is the "fallback dot" pattern: every place is guaranteed to have at least a dot on the map, even at zoom levels where pins would collide too aggressively. Selecting a place upgrades it to REQUIRED_AND_HIDES_OPTIONAL, so its pin is always shown — at which point the dot is suppressed (!isSelected gate).
Pin Lifecycle Animations
The pin uses framer-motion's motion.div with spring transitions:
| Event | Animation |
|---|---|
| Enter | opacity: 0 → 1 (180ms) and scale: 0 → 1 (spring 380/22) |
| Selected → unsel | scale: 1.3 → 1 (spring) |
| Unsel → selected | scale: 1 → 1.3 (spring) |
| Exit | opacity: 1 → 0 and scale: 1 → 0 |
The fallback dot uses the same easing pattern — opacity + scale spring on enter/exit.
transformOrigin: '17.5px 30.5px' keeps the pin's tip anchored at the lat/lng pixel during the scale animation, so the pin appears to grow out of its tip rather than its center.
Reduced Motion
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
When the user has prefers-reduced-motion: reduce set, scale animations are skipped (instant scale change) and only the opacity fade is preserved. This avoids motion sickness without removing the visual cue entirely.
Marker Labels
Each pin is followed by a label showing the place's name and (when available) type · rating:
[Pin Image] Yum Cha Cafe
Restaurant · 4.6 ★
Two CSS utility classes in src/app/globals.css give the labels readability over a busy map:
.map-label-bg— soft blurred backdrop behind the label text (a fading, semi-transparent bg fill that pairs with the halo)..map-label-halo— a white text-shadow halo applied to the text itself, ensuring contrast on dark map tiles.
Type formatting goes through formatType() from src/lib/utils.ts (e.g. meal_takeaway → Meal Takeaway).
DotMarker — Next-Page Preview
File: src/components/map/dot-marker.tsx
When the bounds query returns more results than the current page, the surplus places are rendered as colored dots — same color resolution via getPinColor(), smaller footprint, no label by default. This signals "there are more places just out of view; pan or change filters to surface them" without overwhelming the map with full pins. Clicking a dot triggers the same handleMarkerClick flow.
Adjacent Operations
- Selection state — owned by
MapPlacesProvider(src/components/providers/map-places-provider.tsx).selectedPlaceis the Google Map's currently-selected place; setting it updatesUnifiedMarker.isSelectedfor the matching id. - Bounds query —
usePlacesQuery()(src/components/providers/places-query-provider.tsx) issuesapi.place.searchPlacesByBoundswhenever the map idles after a pan/zoom. The query result feedsplacesToRenderandnextPagePlaces. reloadPlacesflag — search-driven flows (NameSearch.handlePlaceSelect) callmapPlacesCtx.setReloadPlaces(true)to force a refetch after recentring, ensuring the new viewport's places appear.- Click handling — clicking a marker calls
handleMarkerClick(place); clicking the same selected marker again or clicking the empty map callshandleMarkerClick(null), deselecting. - Loading feedback —
MapLoadingToast(see Map Loading Toast) gives the user explicit confirmation that places are loading and have loaded.