Skip to content

Commit 7e6243f

Browse files
dnywhcursoragent
andauthored
feat(map): cluster mirrored open-data pins at city zoom (#119)
* 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> * fix map clustering on preview and address review feedback Enrich mirrored flags when the RPC has not migrated yet, move cluster viewport updates off high-frequency pan events, and restore keyboard access for cluster markers. Co-authored-by: Cursor <cursoragent@cursor.com> * remove mirrored-flag fallback and tighten cluster map behaviour Rely on the listings_in_view migration for mirrored tier data, use real map bounds for supercluster, and suppress duplicate cluster click events. * pass keyboard event timestamps through map marker activation Keep duplicate click suppression on the same DOM time base for pointer and keyboard activations. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 60b0aa6 commit 7e6243f

14 files changed

Lines changed: 784 additions & 32 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: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"use client";
2+
3+
import { useMemo, useRef } from "react";
4+
import type { MouseEvent as ReactMouseEvent } from "react";
5+
import Supercluster from "supercluster";
6+
import {
7+
Marker,
8+
type MarkerEvent,
9+
type MarkerInstance,
10+
} from "react-map-gl/maplibre";
11+
import type { FeatureCollection, Point } from "geojson";
12+
13+
import MapPin from "@/components/MapPin";
14+
import type { ListingCoordinates, ListingMarker } from "@/types/listing";
15+
16+
import ClusterPinEnter, { ClusterPinEnterProvider } from "./ClusterPinEnter";
17+
import {
18+
DUPLICATE_MARKER_CLICK_SUPPRESSION_MS,
19+
ListingMapPinMarker,
20+
useMapMarkerKeyboardActivation,
21+
} from "./MapPinLayer";
22+
23+
type MapMirroredClusterLayerProps = {
24+
geoJson: FeatureCollection<Point>;
25+
listingsById: Map<number, ListingMarker>;
26+
bounds: [number, number, number, number] | null;
27+
zoom: number;
28+
selectedListingId: number | null;
29+
excludedListingId: number | null;
30+
markerLabel: string;
31+
onClusterClick: (
32+
longitude: number,
33+
latitude: number,
34+
expansionZoom: number
35+
) => void;
36+
onMarkerClick: (listing: ListingMarker) => void;
37+
clusterMaxZoom?: number;
38+
clusterRadius?: number;
39+
};
40+
41+
type VisibleMirroredPin = {
42+
key: string;
43+
longitude: number;
44+
latitude: number;
45+
isCluster: boolean;
46+
clusterId?: number;
47+
listingId?: number;
48+
};
49+
50+
function resolveVisibleMirroredPins(
51+
features: ReturnType<Supercluster["getClusters"]>,
52+
excludedListingId: number | null
53+
): VisibleMirroredPin[] {
54+
const visiblePins: VisibleMirroredPin[] = [];
55+
56+
for (const feature of features) {
57+
const [longitude, latitude] = feature.geometry.coordinates as [
58+
number,
59+
number,
60+
];
61+
const isCluster = Boolean(feature.properties?.cluster);
62+
63+
if (isCluster) {
64+
const clusterId = feature.properties.cluster_id as number;
65+
visiblePins.push({
66+
key: `mirrored-cluster-${clusterId}`,
67+
longitude,
68+
latitude,
69+
isCluster: true,
70+
clusterId,
71+
});
72+
continue;
73+
}
74+
75+
const listingId = feature.properties?.id as number;
76+
if (listingId === excludedListingId) {
77+
continue;
78+
}
79+
80+
visiblePins.push({
81+
key: `mirrored-${listingId}`,
82+
longitude,
83+
latitude,
84+
isCluster: false,
85+
listingId,
86+
});
87+
}
88+
89+
return visiblePins;
90+
}
91+
92+
function MirroredClusterPinMarker({
93+
pinKey,
94+
longitude,
95+
latitude,
96+
markerLabel,
97+
onActivate,
98+
}: {
99+
pinKey: string;
100+
longitude: number;
101+
latitude: number;
102+
markerLabel: string;
103+
onActivate: () => void;
104+
}) {
105+
const markerRef = useRef<MarkerInstance | null>(null);
106+
const lastPinPointerClickRef = useRef<{
107+
pinKey: string;
108+
timeStamp: number;
109+
} | null>(null);
110+
const lastKeyboardActivationRef = useRef<number | null>(null);
111+
112+
useMapMarkerKeyboardActivation({
113+
markerRef,
114+
markerLabel,
115+
onActivate: (timeStamp) => {
116+
lastKeyboardActivationRef.current = timeStamp;
117+
onActivate();
118+
},
119+
});
120+
121+
const handlePinClick = (event: ReactMouseEvent<HTMLDivElement>) => {
122+
event.preventDefault();
123+
event.stopPropagation();
124+
event.nativeEvent.stopPropagation();
125+
lastPinPointerClickRef.current = {
126+
pinKey,
127+
timeStamp: event.timeStamp,
128+
};
129+
onActivate();
130+
};
131+
132+
const handleMarkerClick = (event: MarkerEvent<globalThis.MouseEvent>) => {
133+
event.originalEvent.stopPropagation();
134+
const lastPinPointerClick = lastPinPointerClickRef.current;
135+
if (
136+
lastPinPointerClick?.pinKey === pinKey &&
137+
Math.abs(lastPinPointerClick.timeStamp - event.originalEvent.timeStamp) <
138+
DUPLICATE_MARKER_CLICK_SUPPRESSION_MS
139+
) {
140+
return;
141+
}
142+
143+
const lastKeyboardActivation = lastKeyboardActivationRef.current;
144+
if (
145+
lastKeyboardActivation !== null &&
146+
Math.abs(lastKeyboardActivation - event.originalEvent.timeStamp) <
147+
DUPLICATE_MARKER_CLICK_SUPPRESSION_MS
148+
) {
149+
return;
150+
}
151+
152+
onActivate();
153+
};
154+
155+
return (
156+
<Marker
157+
ref={markerRef}
158+
longitude={longitude}
159+
latitude={latitude}
160+
anchor="center"
161+
onClick={handleMarkerClick}
162+
>
163+
<ClusterPinEnter>
164+
<MapPin markerId={pinKey} type="community" onClick={handlePinClick} />
165+
</ClusterPinEnter>
166+
</Marker>
167+
);
168+
}
169+
170+
export default function MapMirroredClusterLayer({
171+
geoJson,
172+
listingsById,
173+
bounds,
174+
zoom,
175+
selectedListingId,
176+
excludedListingId,
177+
markerLabel,
178+
onClusterClick,
179+
onMarkerClick,
180+
clusterMaxZoom = 15,
181+
clusterRadius = 80,
182+
}: MapMirroredClusterLayerProps) {
183+
const clusterIndex = useMemo(() => {
184+
const index = new Supercluster({
185+
radius: clusterRadius,
186+
maxZoom: clusterMaxZoom,
187+
});
188+
189+
index.load(
190+
geoJson.features.map((feature) => ({
191+
type: "Feature" as const,
192+
properties: { ...feature.properties },
193+
geometry: feature.geometry,
194+
}))
195+
);
196+
197+
return index;
198+
}, [clusterMaxZoom, clusterRadius, geoJson]);
199+
200+
const visibleClusters = useMemo(() => {
201+
if (!bounds) return [];
202+
return clusterIndex.getClusters(bounds, Math.floor(zoom));
203+
}, [bounds, clusterIndex, zoom]);
204+
205+
const visiblePins = useMemo(
206+
() => resolveVisibleMirroredPins(visibleClusters, excludedListingId),
207+
[excludedListingId, visibleClusters]
208+
);
209+
210+
return (
211+
<ClusterPinEnterProvider>
212+
{visiblePins.map((pin) => {
213+
if (pin.isCluster && pin.clusterId !== undefined) {
214+
return (
215+
<MirroredClusterPinMarker
216+
key={pin.key}
217+
pinKey={pin.key}
218+
longitude={pin.longitude}
219+
latitude={pin.latitude}
220+
markerLabel={markerLabel}
221+
onActivate={() => {
222+
onClusterClick(
223+
pin.longitude,
224+
pin.latitude,
225+
clusterIndex.getClusterExpansionZoom(pin.clusterId!)
226+
);
227+
}}
228+
/>
229+
);
230+
}
231+
232+
const listing =
233+
pin.listingId !== undefined
234+
? listingsById.get(pin.listingId)
235+
: undefined;
236+
if (!listing) return null;
237+
238+
const coords = listing.coordinates as ListingCoordinates;
239+
const isSelected = selectedListingId === listing.id;
240+
241+
return (
242+
<ListingMapPinMarker
243+
key={pin.key}
244+
listing={listing}
245+
coords={coords}
246+
isSelected={isSelected}
247+
markerLabel={markerLabel}
248+
onMarkerClick={onMarkerClick}
249+
withClusterEnterAnimation
250+
/>
251+
);
252+
})}
253+
</ClusterPinEnterProvider>
254+
);
255+
}

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;

0 commit comments

Comments
 (0)