Skip to content

Commit 4639d65

Browse files
dnywhcursoragent
andcommitted
add client-side clustering for mirrored open-data map pins
Collapse mirrored listings with supercluster at city zoom so dense open-data cities stay usable, while organic pins keep the normal map UX. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 60b0aa6 commit 4639d65

14 files changed

Lines changed: 674 additions & 16 deletions

src/app/actions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { createSupportError } from "@/lib/supportError";
3232
import type {
3333
DeleteListingResult,
3434
ListingDraftInput,
35+
ListingMarker,
3536
ListingSubmitFailureData,
3637
ListingSubmitResult,
3738
ListingType,
@@ -1187,7 +1188,10 @@ export async function fetchListingsInView(
11871188
return [];
11881189
}
11891190

1190-
return data || [];
1191+
return ((data || []) as ListingMarker[]).map((listing) => ({
1192+
...listing,
1193+
is_open_data_mirrored: listing.is_open_data_mirrored ?? false,
1194+
}));
11911195
} catch (error) {
11921196
console.error("Fatal error in fetchListingsInView:", {
11931197
error,

src/components/MapPin/MapPin.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ const CompactPinHitTarget = styled.div`
151151

152152
const CompactPinInner = styled.div<{ $type?: string }>`
153153
box-shadow:
154-
0 0 0 2.5px ${theme.colors.marker.border},
154+
0 0 0 var(--map-pin-border-width, 2.5px) ${theme.colors.marker.border},
155155
0 3px 14px rgba(0, 0, 0, 0.22),
156156
0 0 4px rgba(0, 0, 0, 0.22);
157157
width: 24px;
@@ -164,6 +164,7 @@ const CompactPinInner = styled.div<{ $type?: string }>`
164164
pointer-events: none;
165165
transform: scale(var(--map-pin-compact-scale, 1));
166166
transform-origin: center;
167+
transition: box-shadow 180ms ease;
167168
168169
${({ $type }) => $type === "residential" && residentialPinStyles}
169170
${({ $type }) => $type === "community" && communityPinStyles}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use client";
2+
3+
import { createContext, useContext, useEffect, useRef, useState } from "react";
4+
import { css, keyframes, styled } from "next-yak";
5+
6+
const PIN_ENTER_SCALE_MS = 380;
7+
const PIN_ENTER_OPACITY_MS = 160;
8+
const PIN_ENTER_SPRING = "cubic-bezier(0.22, 1.12, 0.36, 1)";
9+
const PIN_ENTER_START_SCALE = 0.58;
10+
const PIN_ENTER_OVERSHOOT_SCALE = 1.05;
11+
12+
const pinEnterScale = keyframes`
13+
0% {
14+
transform: scale(${PIN_ENTER_START_SCALE});
15+
}
16+
17+
72% {
18+
transform: scale(${PIN_ENTER_OVERSHOOT_SCALE});
19+
}
20+
21+
100% {
22+
transform: scale(1);
23+
}
24+
`;
25+
26+
const pinEnterOpacity = keyframes`
27+
from {
28+
opacity: 0;
29+
}
30+
31+
to {
32+
opacity: 1;
33+
}
34+
`;
35+
36+
const ClusterPinEnterShell = styled.div<{ $entering?: boolean }>`
37+
transform-origin: center center;
38+
39+
@media (prefers-reduced-motion: no-preference) {
40+
${({ $entering }) =>
41+
$entering
42+
? css`
43+
animation:
44+
${pinEnterScale} ${PIN_ENTER_SCALE_MS}ms ${PIN_ENTER_SPRING} both,
45+
${pinEnterOpacity} ${PIN_ENTER_OPACITY_MS}ms ease-out both;
46+
`
47+
: undefined}
48+
}
49+
`;
50+
51+
const SuppressEnterAnimationContext = createContext({ current: true });
52+
53+
export function ClusterPinEnterProvider({
54+
children,
55+
}: {
56+
children: React.ReactNode;
57+
}) {
58+
const suppressEnterAnimationRef = useRef(true);
59+
60+
useEffect(() => {
61+
suppressEnterAnimationRef.current = false;
62+
}, []);
63+
64+
return (
65+
<SuppressEnterAnimationContext.Provider value={suppressEnterAnimationRef}>
66+
{children}
67+
</SuppressEnterAnimationContext.Provider>
68+
);
69+
}
70+
71+
export default function ClusterPinEnter({
72+
children,
73+
}: {
74+
children: React.ReactNode;
75+
}) {
76+
const suppressEnterAnimationRef = useContext(SuppressEnterAnimationContext);
77+
const [isEntering] = useState(() => !suppressEnterAnimationRef.current);
78+
79+
return (
80+
<ClusterPinEnterShell $entering={isEntering}>
81+
{children}
82+
</ClusterPinEnterShell>
83+
);
84+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import type { MouseEvent as ReactMouseEvent } from "react";
5+
import Supercluster from "supercluster";
6+
import { Marker, type MarkerEvent } from "react-map-gl/maplibre";
7+
import type { FeatureCollection, Point } from "geojson";
8+
9+
import MapPin from "@/components/MapPin";
10+
import type { ListingCoordinates, ListingMarker } from "@/types/listing";
11+
12+
import ClusterPinEnter, { ClusterPinEnterProvider } from "./ClusterPinEnter";
13+
import { ListingMapPinMarker } from "./MapPinLayer";
14+
15+
type MapMirroredClusterLayerProps = {
16+
geoJson: FeatureCollection<Point>;
17+
listingsById: Map<number, ListingMarker>;
18+
bounds: [number, number, number, number] | null;
19+
zoom: number;
20+
selectedListingId: number | null;
21+
excludedListingId: number | null;
22+
markerLabel: string;
23+
onClusterClick: (
24+
longitude: number,
25+
latitude: number,
26+
expansionZoom: number
27+
) => void;
28+
onMarkerClick: (listing: ListingMarker) => void;
29+
clusterMaxZoom?: number;
30+
clusterRadius?: number;
31+
};
32+
33+
type VisibleMirroredPin = {
34+
key: string;
35+
longitude: number;
36+
latitude: number;
37+
isCluster: boolean;
38+
clusterId?: number;
39+
listingId?: number;
40+
};
41+
42+
function resolveVisibleMirroredPins(
43+
features: ReturnType<Supercluster["getClusters"]>,
44+
excludedListingId: number | null
45+
): VisibleMirroredPin[] {
46+
const visiblePins: VisibleMirroredPin[] = [];
47+
48+
for (const feature of features) {
49+
const [longitude, latitude] = feature.geometry.coordinates as [
50+
number,
51+
number,
52+
];
53+
const isCluster = Boolean(feature.properties?.cluster);
54+
55+
if (isCluster) {
56+
const clusterId = feature.properties.cluster_id as number;
57+
visiblePins.push({
58+
key: `mirrored-cluster-${clusterId}`,
59+
longitude,
60+
latitude,
61+
isCluster: true,
62+
clusterId,
63+
});
64+
continue;
65+
}
66+
67+
const listingId = feature.properties?.id as number;
68+
if (listingId === excludedListingId) {
69+
continue;
70+
}
71+
72+
visiblePins.push({
73+
key: `mirrored-${listingId}`,
74+
longitude,
75+
latitude,
76+
isCluster: false,
77+
listingId,
78+
});
79+
}
80+
81+
return visiblePins;
82+
}
83+
84+
export default function MapMirroredClusterLayer({
85+
geoJson,
86+
listingsById,
87+
bounds,
88+
zoom,
89+
selectedListingId,
90+
excludedListingId,
91+
markerLabel,
92+
onClusterClick,
93+
onMarkerClick,
94+
clusterMaxZoom = 15,
95+
clusterRadius = 80,
96+
}: MapMirroredClusterLayerProps) {
97+
const clusterIndex = useMemo(() => {
98+
const index = new Supercluster({
99+
radius: clusterRadius,
100+
maxZoom: clusterMaxZoom,
101+
});
102+
103+
index.load(
104+
geoJson.features.map((feature) => ({
105+
type: "Feature" as const,
106+
properties: { ...feature.properties },
107+
geometry: feature.geometry,
108+
}))
109+
);
110+
111+
return index;
112+
}, [clusterMaxZoom, clusterRadius, geoJson]);
113+
114+
const visibleClusters = useMemo(() => {
115+
if (!bounds) return [];
116+
return clusterIndex.getClusters(bounds, Math.floor(zoom));
117+
}, [bounds, clusterIndex, zoom]);
118+
119+
const visiblePins = useMemo(
120+
() => resolveVisibleMirroredPins(visibleClusters, excludedListingId),
121+
[excludedListingId, visibleClusters]
122+
);
123+
124+
return (
125+
<ClusterPinEnterProvider>
126+
{visiblePins.map((pin) => {
127+
if (pin.isCluster && pin.clusterId !== undefined) {
128+
const activateCluster = () => {
129+
onClusterClick(
130+
pin.longitude,
131+
pin.latitude,
132+
clusterIndex.getClusterExpansionZoom(pin.clusterId!)
133+
);
134+
};
135+
136+
const handlePinClick = (event: ReactMouseEvent<HTMLDivElement>) => {
137+
event.preventDefault();
138+
event.stopPropagation();
139+
event.nativeEvent.stopPropagation();
140+
activateCluster();
141+
};
142+
143+
const handleMarkerClick = (
144+
event: MarkerEvent<globalThis.MouseEvent>
145+
) => {
146+
event.originalEvent.stopPropagation();
147+
activateCluster();
148+
};
149+
150+
return (
151+
<Marker
152+
key={pin.key}
153+
longitude={pin.longitude}
154+
latitude={pin.latitude}
155+
anchor="center"
156+
onClick={handleMarkerClick}
157+
>
158+
<ClusterPinEnter>
159+
<MapPin
160+
markerId={pin.key}
161+
type="community"
162+
onClick={handlePinClick}
163+
/>
164+
</ClusterPinEnter>
165+
</Marker>
166+
);
167+
}
168+
169+
const listing =
170+
pin.listingId !== undefined
171+
? listingsById.get(pin.listingId)
172+
: undefined;
173+
if (!listing) return null;
174+
175+
const coords = listing.coordinates as ListingCoordinates;
176+
const isSelected = selectedListingId === listing.id;
177+
178+
return (
179+
<ListingMapPinMarker
180+
key={pin.key}
181+
listing={listing}
182+
coords={coords}
183+
isSelected={isSelected}
184+
markerLabel={markerLabel}
185+
onMarkerClick={onMarkerClick}
186+
withClusterEnterAnimation
187+
/>
188+
);
189+
})}
190+
</ClusterPinEnterProvider>
191+
);
192+
}

src/features/map/components/MapPageClient.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import MapListingDrawerPanel from "./MapListingDrawerPanel";
1414
import MapSidebar from "./MapSidebar";
1515
import { useMapListingUrl } from "../hooks/useMapListingUrl";
1616
import { useIpInitialLocation } from "../hooks/useIpInitialLocation";
17-
import type { InitialMapCoordinates } from "../lib/mapInitialView";
1817
import {
1918
MAP_DRAWER_SNAP_POINTS,
2019
useMapDrawerState,
2120
} from "../hooks/useMapDrawerState";
21+
import type { InitialMapCoordinates } from "../lib/mapInitialView";
2222

2323
type MapPageClientProps = {
2424
user: User | null;

src/features/map/components/MapPinLayer.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import MapPin from "@/components/MapPin";
1212
import type { ListingCoordinates, ListingMarker } from "@/types/listing";
1313

14+
import ClusterPinEnter from "./ClusterPinEnter";
1415
import { hasValidCoordinates } from "../lib/mapUtils";
1516

1617
type MapPinLayerProps = {
@@ -26,6 +27,7 @@ type ListingMapPinMarkerProps = {
2627
isSelected: boolean;
2728
markerLabel: string;
2829
onMarkerClick: MapPinLayerProps["onMarkerClick"];
30+
withClusterEnterAnimation?: boolean;
2931
};
3032

3133
const KEYBOARD_ACTIVATION_KEYS = new Set(["Enter", " "]);
@@ -34,12 +36,13 @@ const KEYBOARD_ACTIVATION_KEYS = new Set(["Enter", " "]);
3436
// duplicate events without blocking deliberate follow-up activations.
3537
const DUPLICATE_MARKER_CLICK_SUPPRESSION_MS = 100;
3638

37-
function ListingMapPinMarker({
39+
export function ListingMapPinMarker({
3840
listing,
3941
coords,
4042
isSelected,
4143
markerLabel,
4244
onMarkerClick,
45+
withClusterEnterAnimation = false,
4346
}: ListingMapPinMarkerProps) {
4447
const markerRef = useRef<MarkerInstance | null>(null);
4548
const lastPinPointerClickRef = useRef<{
@@ -142,12 +145,23 @@ function ListingMapPinMarker({
142145
onClick={handleMarkerClick}
143146
style={{ zIndex: isSelected ? 1 : 0 }}
144147
>
145-
<MapPin
146-
markerId={listing.id}
147-
onClick={handlePinClick}
148-
selected={isSelected}
149-
type={listing.type ?? undefined}
150-
/>
148+
{withClusterEnterAnimation ? (
149+
<ClusterPinEnter>
150+
<MapPin
151+
markerId={listing.id}
152+
onClick={handlePinClick}
153+
selected={isSelected}
154+
type={listing.type ?? undefined}
155+
/>
156+
</ClusterPinEnter>
157+
) : (
158+
<MapPin
159+
markerId={listing.id}
160+
onClick={handlePinClick}
161+
selected={isSelected}
162+
type={listing.type ?? undefined}
163+
/>
164+
)}
151165
</Marker>
152166
);
153167
}

0 commit comments

Comments
 (0)