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_takeawayMeal 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 queryusePlacesQuery() (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 feedbackMapLoadingToast (see Map Loading Toast) gives the user explicit confirmation that places are loading and have loaded.