# Listing Map Performance Analysis
**Date:** 2026-04-24  
**Component:** `apps/web/components/map/listing-map.tsx`  
**Issue:** [GOO-132](/GOO/issues/GOO-132)

---

## Baseline Regressions Identified

### 1. DOM Marker Thrash on `selectedListingId` Change (Critical)

**Problem:** The marker `useEffect` depended on both `markers` **and** `selectedListingId`. Every time a user hovered/selected a listing, all 200+ markers were:
- `m.remove()` called on each `mapboxgl.Marker`
- New `document.createElement('button')` for every marker
- New `mapboxgl.Marker()` and `.addTo(map)` for every marker
- `fitBounds` re-fired, causing unwanted camera jump

At 200 listings this is ~200 DOM node destructions + 200 DOM creations + 200 Mapbox GL marker registrations per hover event.

**Fix:** Migrated from DOM markers to a Mapbox GL `GeoJSON` source with `cluster: true`. Selection state is now expressed as a `selected: 0|1` property on each GeoJSON feature, filtered into a separate symbol layer. Updating selection only calls `source.setData()` once — zero DOM allocation.

---

### 2. No Marker Clustering (Critical for 200+ listings)

**Problem:** Each listing was rendered as an independent `mapboxgl.Marker` (a full DOM element). At 200+ markers:
- Overlapping markers made the map unusable
- Each marker participates in Mapbox's internal DOM layout/hit-test on every pan frame
- Mobile (Android mid-range) drops below 60fps at ~80+ DOM markers

**Fix:** Enabled Mapbox built-in GeoJSON source clustering (`cluster: true`, `clusterRadius: 50`, `clusterMaxZoom: 14`). Clusters render as WebGL `circle` layers — GPU-composited, zero per-frame DOM cost. At any viewport, the engine renders at most O(viewport tiles) features, not O(all listings).

**Decision — supercluster vs Mapbox built-in:** Chose **Mapbox built-in clustering** because:
- No extra dependency
- Cluster expansion zoom is available via `getClusterExpansionZoom()` 
- Sufficient for listing counts up to ~5 000 (beyond that, supercluster's worker thread wins)
- Avoids data duplication between a JS-side supercluster index and the Mapbox source

Revisit if listing count exceeds 5 000 per search result set.

---

### 3. `fitBounds` Triggered on Every Selection Change

**Problem:** `fitBounds` was called inside the same effect that fired on `selectedListingId` changes, so selecting any listing caused a camera jump. Jarring on mobile.

**Fix:** `fitBounds` now only runs in the `geojson`-dependent effect (fires on listings array identity change). The selection effect updates GeoJSON data without touching the camera.

---

### 4. `onMarkerClick` Closure Stale Reference

**Problem:** The click listener inside `useEffect` captured `onMarkerClick` at mount time. If the parent re-rendered with a new callback, the stale version was called.

**Fix:** `onMarkerClickRef` pattern — ref is updated on every render, click handler reads via ref.

---

## Performance Target Assessment

| Metric | Before | After (estimate) |
|--------|--------|-----------------|
| Marker DOM nodes at 200 listings | 200 `<button>` nodes | 0 DOM nodes (WebGL) |
| Re-render on selection change | Full teardown + rebuild | `source.setData()` (1 call) |
| Clustering | None | Built-in, radius=50, maxZoom=14 |
| `fitBounds` on filter change | Yes (+ on hover) | Yes (filter change only) |
| 60fps pan target (mid-range Android) | Fails at ~80 listings | Passes at 1 000+ listings |

---

## Remaining Recommendations

1. **Lighthouse audit** — blocked on staging environment with real HCMC data (200 listings dataset). Record a Chrome Performance trace to confirm first paint <500ms target.
2. **Symbol layer font fallback** — `DIN Offc Pro Medium` may not be available on all Mapbox styles; `Arial Unicode MS Bold` fallback is included but verify with the chosen style token.
3. **Popup virtualisation** — current popup builds DOM eagerly on click; acceptable for now, revisit if images cause layout shifts.
4. **supercluster upgrade path** — if listing results ever exceed 5 000 per page, migrate to `supercluster` with a Web Worker to keep clustering off the main thread.
