perf(map): batch + de-dupe marker rendering so pins appear faster

The spot/tour/event/crew marker effect rebuilt every Leaflet marker
whenever the parent handed it new arrays, and added each marker with a
per-marker clusterGroup.addLayer() call — each of which re-clusters the
whole set. On a map with a few hundred pins that meant several full
teardown/rebuild passes per load, all synchronously on the main thread.

- Skip the rebuild when the marker signature (ids + weather colours +
  layer toggles) is unchanged. Addressable events get a new id on every
  edit, so ids reliably detect real changes; unrelated array churn
  (trust scores, comment/zap totals, re-sorts) no longer rebuilds the map.
- Collect markers and add them with the plugin's bulk addLayers() in
  requestAnimationFrame batches of 150, cancelling on effect re-run. One
  cluster refresh per batch, pins paint progressively, UI stays responsive.
This commit is contained in:
SkateSpots 2026-09-29 12:45:59 +07:00
parent eb209452fc
commit 563d91504d

View file

@ -794,6 +794,9 @@ export function SpotMap({
// MarkerClusterGroup for spot/shop/gear/tour markers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clusterGroupRef = useRef<any>(null);
// Signature of the marker set currently on the map — lets us skip a full
// rebuild when the parent hands us new arrays with identical content.
const markerSigRef = useRef<string>('');
const onMapMoveRef = useRef(onMapMove);
const navigate = useNavigate();
@ -1052,6 +1055,38 @@ export function SpotMap({
// Wait for map to be fully loaded before adding markers
if (!map.getContainer()) return;
// Skip the whole rebuild when nothing that affects the rendered markers has
// actually changed. Every spot/listing/tour/event/crew is an addressable
// event, so a new event id means an edit — ids + weather colours are enough
// to detect real changes. Without this, the unrelated identity churn the
// parent hands us (trust scores, comment/zap totals and re-sorts all produce
// fresh arrays) tore down and rebuilt every Leaflet marker several times per
// load, which is what made the pins feel slow.
const markerSig = [
`${showSpots}|${showShops}|${showGear}|${showTours}|${showEvents}|${showCrews}|${weatherEnabled}`,
locationGroups
.map((group) => {
const first = group.spots[0];
const weather =
weatherEnabled && weatherData && first?.geohash
? weatherData.get(first.geohash)
: undefined;
return [
group.geohash,
group.spots.map((s) => s.event.id).join('.'),
group.marketplaceItems.map((i) => i.event.id).join('.'),
weather?.skateability.color ?? '',
weather?.weather.weatherIcon ?? '',
].join(':');
})
.join('|'),
tours.map((t) => t.id).join('.'),
events.map((e) => e.id).join('.'),
crews.map((c) => c.id).join('.'),
].join('~~');
if (markerSig === markerSigRef.current) return;
markerSigRef.current = markerSig;
// Clear existing markers from cluster group and array
if (clusterGroupRef.current) {
clusterGroupRef.current.clearLayers();
@ -1059,6 +1094,9 @@ export function SpotMap({
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
// Markers built this pass; added to the cluster group in batches below.
const pending: L.Marker[] = [];
// Ensure we have location data before adding markers
if (locationGroups.length === 0) return;
@ -1180,12 +1218,8 @@ export function SpotMap({
});
}
// Add to cluster group (enables auto-clustering at high densities)
if (clusterGroupRef.current) {
clusterGroupRef.current.addLayer(marker);
} else {
marker.addTo(map);
}
// Queue for a batched add to the cluster group (see flush below).
pending.push(marker);
markersRef.current.push(marker);
});
@ -1284,12 +1318,8 @@ export function SpotMap({
className: 'custom-popup tour-popup',
});
// Add tour markers to cluster group as well
if (clusterGroupRef.current) {
clusterGroupRef.current.addLayer(tourMarker);
} else {
tourMarker.addTo(map);
}
// Queue for a batched add to the cluster group (see flush below).
pending.push(tourMarker);
markersRef.current.push(tourMarker);
});
}
@ -1357,11 +1387,8 @@ export function SpotMap({
className: 'custom-popup event-popup',
});
if (clusterGroupRef.current) {
clusterGroupRef.current.addLayer(eventMarker);
} else {
eventMarker.addTo(map);
}
// Queue for a batched add to the cluster group (see flush below).
pending.push(eventMarker);
markersRef.current.push(eventMarker);
});
}
@ -1460,15 +1487,42 @@ export function SpotMap({
className: 'custom-popup crew-popup',
});
if (clusterGroupRef.current) {
clusterGroupRef.current.addLayer(crewMarker);
} else {
crewMarker.addTo(map);
}
// Queue for a batched add to the cluster group (see flush below).
pending.push(crewMarker);
markersRef.current.push(crewMarker);
});
}
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
// Add the queued markers to the cluster group in batches across animation
// frames. `addLayers` is the plugin's bulk API — it clusters the batch and
// refreshes once, instead of the old one-marker-at-a-time `addLayer`, which
// re-clustered the whole set for every single pin. Batching keeps the main
// thread responsive (no freeze on a few hundred pins) and lets nearby pins
// paint first; cancel it if the effect re-runs before it finishes.
const group = clusterGroupRef.current;
if (!group) {
pending.forEach((marker) => marker.addTo(map));
} else if (pending.length > 0) {
const CHUNK_SIZE = 150;
let offset = 0;
let cancelled = false;
let raf = 0;
const flush = () => {
if (cancelled || !clusterGroupRef.current) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(clusterGroupRef.current as any).addLayers(pending.slice(offset, offset + CHUNK_SIZE));
offset += CHUNK_SIZE;
if (offset < pending.length) {
raf = requestAnimationFrame(flush);
}
};
flush();
return () => {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
};
}
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, showSpots, showShops, showGear, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
// Update BTC Map markers
useEffect(() => {