Skip to main content

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​

ComponentFilePurpose
UnifiedMarkersrc/components/map/unified-marker.tsxFull pin + label for places on the current results page
DotMarkersrc/components/map/dot-marker.tsxSmall colored dot for "next page" places (out-of-view results preview)
MapLoadingToastsrc/components/map/map-loading-toast.tsxTop-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:

SlugColorExamples (primaryType)
restaurant#D64D35restaurant, bakery, meal_takeaway, anything matching /_restaurant$/ pattern
cafe#C88C38cafe, coffee_shop
nature#4EC373park, national_park, dog_park
pharmacy#6E64C8pharmacy, drugstore
attraction#63A1E2museum, art_gallery, tourist_attraction, zoo
venue#D46686bar, night_club, concert_hall, stadium
misc#6B6B6BFallback when no rule matches

Resolution rules in resolvePinSlug():

  1. Exact match against PRIMARY_TYPE_TO_PIN.
  2. Regex pattern match against PATTERN_RULES (e.g. any *_restaurant resolves to restaurant).
  3. Fallback to 'misc'.

Two consumers:

  • getPinSrc(primaryType) → /assets/pins/{slug}.svg for <Image> rendering.
  • getPinColor(primaryType) → hex string for the fallback dot's backgroundColor.

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_OPTIONAL when selected — the selected pin is never collision-hidden and pushes others out.
    • OPTIONAL_AND_HIDES_LOWER_PRIORITY otherwise — Google's collision avoidance can drop it to keep the map readable.
  • zIndex: 10 selected, 5 otherwise.
  • Inside: an Image from getPinSrc(primaryType) plus a label (see below). Both are wrapped in motion.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" and zIndex: 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:

EventAnimation
Enteropacity: 0 → 1 (180ms) and scale: 0 → 1 (spring 380/22)
Selected → unselscale: 1.3 → 1 (spring)
Unsel → selectedscale: 1 → 1.3 (spring)
Exitopacity: 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). selectedPlace is the Google Map's currently-selected place; setting it updates UnifiedMarker.isSelected for the matching id.
  • Bounds query — usePlacesQuery() (src/components/providers/places-query-provider.tsx) issues api.place.searchPlacesByBounds whenever the map idles after a pan/zoom. The query result feeds placesToRender and nextPagePlaces.
  • reloadPlaces flag — search-driven flows (NameSearch.handlePlaceSelect) call mapPlacesCtx.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 calls handleMarkerClick(null), deselecting.
  • Loading feedback — MapLoadingToast (see Map Loading Toast) gives the user explicit confirmation that places are loading and have loaded.