From a1a05eb430da9a3870542ed82dc8f63b15584241 Mon Sep 17 00:00:00 2001 From: Mikhail Chandran Date: Tue, 27 Oct 2026 12:43:19 +0530 Subject: [PATCH] perf(map): cache marker icons and look pins up by id instead of scanning --- src/components/map/map-view.test.tsx | 12 ++++++++- src/components/map/map-view.tsx | 37 ++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/components/map/map-view.test.tsx b/src/components/map/map-view.test.tsx index dbd8269..a48b4d3 100644 --- a/src/components/map/map-view.test.tsx +++ b/src/components/map/map-view.test.tsx @@ -1,7 +1,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import MapView from "@/components/map/map-view"; +import MapView, { pinIcon } from "@/components/map/map-view"; const { flyTo } = vi.hoisted(() => ({ flyTo: vi.fn() })); @@ -118,3 +118,13 @@ describe("MapView configuration", () => { ); }); }); + +describe("pinIcon", () => { + it("returns the same icon instance for the same place type", () => { + expect(pinIcon("library")).toBe(pinIcon("library")); + }); + + it("returns a different icon for a different place type", () => { + expect(pinIcon("library")).not.toBe(pinIcon("airport")); + }); +}); diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index c0ae184..0a83bc7 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -30,8 +30,17 @@ function emptyTypeCounts(): ClusterAggProps { return Object.fromEntries(PLACE_TYPES.map((type) => [type, 0])) as ClusterAggProps; } -/** Builds a pie-style divIcon: a conic-gradient ring colored by place type, count in the middle. */ +const clusterIconCache = new Map(); + +/** + * Builds a pie-style divIcon: a conic-gradient ring colored by place type, count in the middle. + * Cached by the per-type counts so react-leaflet sees the same icon object across renders + * and skips `marker.setIcon` for clusters that did not change. + */ function clusterIcon(counts: ClusterAggProps, total: number, label: string): L.DivIcon { + const key = PLACE_TYPES.map((type) => counts[type]).join(","); + const cached = clusterIconCache.get(key); + if (cached) return cached; let acc = 0; const stops: string[] = []; for (const type of PLACE_TYPES) { @@ -43,12 +52,14 @@ function clusterIcon(counts: ClusterAggProps, total: number, label: string): L.D stops.push(`${PLACE_TYPE_COLORS[type]} ${from}deg ${to}deg`); } const size = total < 10 ? 36 : total < 50 ? 44 : 54; - return L.divIcon({ + const icon = L.divIcon({ html: `
${total}
`, className: "cluster-pie-icon", iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); + clusterIconCache.set(key, icon); + return icon; } /** Summarizes a cluster's contents for assistive tech, e.g. "Cluster of 11 places, mostly SAT centre". */ @@ -67,14 +78,21 @@ function clusterLabel(counts: ClusterAggProps, total: number): string { return `Cluster of ${total} ${total === 1 ? "place" : "places"}, mostly ${PLACE_TYPE_LABELS[dominant].toLowerCase()}`; } -function pinIcon(color: string): L.DivIcon { - return L.divIcon({ - html: `
`, +const pinIconCache = new Map(); + +/** One shared icon per place type, so unchanged pins keep the same icon object across renders. */ +export function pinIcon(type: PlaceType): L.DivIcon { + const cached = pinIconCache.get(type); + if (cached) return cached; + const icon = L.divIcon({ + html: `
`, className: "pin-marker-icon", iconSize: [32, 40], iconAnchor: [16, 40], popupAnchor: [0, -40], }); + pinIconCache.set(type, icon); + return icon; } /** True when the visitor has asked for reduced motion. Guarded for SSR and jsdom. */ @@ -139,6 +157,11 @@ function ClusteredMarkers({ places }: { places: Place[] }) { return sc; }, [places]); + const placesById = useMemo( + () => new Map(places.map((place) => [place.id, place])), + [places], + ); + const clusters = useMemo(() => index.getClusters(bbox, zoom), [index, bbox, zoom]); return ( @@ -183,14 +206,14 @@ function ClusteredMarkers({ places }: { places: Place[] }) { ); } - const place = places.find((p) => p.id === properties.placeId); + const place = placesById.get(properties.placeId); if (!place) return null; return ( { L.DomEvent.stopPropagation(e);