Skip to content

Commit 7d20b21

Browse files
committed
fix(map): keep URL/UI in sync on tap failure; handle antimeridian in padBounds
- selectListingById no longer sets the error sentinel when the fetch fails. Tap-driven fetches happen before the URL is pushed, so the drawer would either stay closed (swallowing the error) or desync UI and URL while pointing at the previous listing. The optimistic pin id is reverted and `selectedListing` is left untouched, so UI and URL stay consistent. - padBounds now wraps longitudes into [-180, 180] and splits the envelope into two when the padded viewport crosses the antimeridian. Callers iterate all returned boxes. - useListingsInView fetches all returned boxes in parallel and merges responses, deduping by id. Made-with: Cursor
1 parent ff2194a commit 7d20b21

3 files changed

Lines changed: 60 additions & 23 deletions

File tree

src/features/map/hooks/useListingsInView.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,35 @@ export function useListingsInView(): UseListingsInViewResult {
6565
const inFlightCountRef = useRef(0);
6666

6767
const runFetch = useCallback(async (bounds: LngLatBounds) => {
68-
const padded = padBounds(bounds, VIEWPORT_PAD_FACTOR);
68+
// `padBounds` returns 1 box normally, or 2 when the viewport crosses the
69+
// antimeridian. We fetch each and merge, deduping by id.
70+
const paddedBoxes = padBounds(bounds, VIEWPORT_PAD_FACTOR);
6971

7072
const requestId = ++requestIdRef.current;
7173
inFlightCountRef.current += 1;
7274
setIsFetching(true);
7375

7476
try {
75-
const data = await fetchListingsInView(
76-
padded.south,
77-
padded.west,
78-
padded.north,
79-
padded.east
77+
const responses = await Promise.all(
78+
paddedBoxes.map((box) =>
79+
fetchListingsInView(box.south, box.west, box.north, box.east)
80+
)
8081
);
8182

8283
// Ignore stale responses — a newer request has already superseded this one.
8384
if (requestId !== requestIdRef.current) return;
8485

85-
setListings((data ?? []) as ListingMarker[]);
86+
const seen = new Set<number>();
87+
const merged: ListingMarker[] = [];
88+
for (const response of responses) {
89+
for (const marker of (response ?? []) as ListingMarker[]) {
90+
if (seen.has(marker.id)) continue;
91+
seen.add(marker.id);
92+
merged.push(marker);
93+
}
94+
}
95+
96+
setListings(merged);
8697
} catch (error) {
8798
if (requestId !== requestIdRef.current) return;
8899
console.error("Error fetching listings in view:", error);

src/features/map/hooks/useMapListingUrl.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,12 @@ export function useMapListingUrl({
187187
if (token !== requestTokenRef.current) return;
188188

189189
if (error || !data) {
190-
setSelectedListing({
191-
error: true,
192-
message: t("Listings.edit.notFound"),
193-
});
190+
// Tap-driven fetches happen before the URL is pushed, so surfacing
191+
// an error sentinel here would either be invisible (no listing in
192+
// URL → drawer stays closed) or desync the UI from the URL (still
193+
// pointing at the previous listing). Revert the optimistic pin and
194+
// leave `selectedListing` alone instead.
195+
console.warn("Failed to select listing by id:", error);
194196
setOptimisticListingId(null);
195197
return;
196198
}
@@ -208,14 +210,10 @@ export function useMapListingUrl({
208210
} catch (err) {
209211
if (token !== requestTokenRef.current) return;
210212
console.warn("Failed to select listing by id:", err);
211-
setSelectedListing({
212-
error: true,
213-
message: t("Listings.edit.notFound"),
214-
});
215213
setOptimisticListingId(null);
216214
}
217215
},
218-
[router, supabase, t, tableName]
216+
[router, supabase, tableName]
219217
);
220218

221219
const closeListing = useCallback(() => {

src/features/map/lib/mapUtils.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,23 @@ export function hasValidCoordinates(
7979
);
8080
}
8181

82+
// Wrap a longitude into the canonical [-180, 180] range. MapLibre reports
83+
// bounds as "unwrapped" coordinates (values outside the canonical range when
84+
// the user has panned across the antimeridian), but the `listings_in_view`
85+
// RPC feeds them into PostGIS' `st_makeenvelope`, which expects canonical
86+
// longitudes.
87+
function wrapLongitude(lng: number): number {
88+
return ((((lng + 180) % 360) + 360) % 360) - 180;
89+
}
90+
8291
// Expand a viewport bbox by a fraction (e.g. 0.3 => 30% larger in each
8392
// direction). This lets us fetch a slightly padded area so that small pans
8493
// reuse already-loaded pins without hitting the network again.
85-
export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox {
94+
//
95+
// Returns 1 or 2 boxes. Two are returned when the padded viewport crosses
96+
// the antimeridian (e.g. Fiji, NZ → Alaska), so the caller can fetch both
97+
// halves and merge the results.
98+
export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox[] {
8699
const sw = bounds.getSouthWest();
87100
const ne = bounds.getNorthEast();
88101

@@ -92,12 +105,27 @@ export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox {
92105
const latPad = latSpan * factor;
93106
const lngPad = lngSpan * factor;
94107

95-
return {
96-
south: Math.max(-90, sw.lat - latPad),
97-
north: Math.min(90, ne.lat + latPad),
98-
west: sw.lng - lngPad,
99-
east: ne.lng + lngPad,
100-
};
108+
const south = Math.max(-90, sw.lat - latPad);
109+
const north = Math.min(90, ne.lat + latPad);
110+
111+
// If the padded viewport already covers the whole globe, just request the
112+
// whole world (avoids degenerate envelopes in PostGIS).
113+
if (lngSpan + 2 * lngPad >= 360) {
114+
return [{ south, north, west: -180, east: 180 }];
115+
}
116+
117+
const west = wrapLongitude(sw.lng - lngPad);
118+
const east = wrapLongitude(ne.lng + lngPad);
119+
120+
if (west <= east) {
121+
return [{ south, north, west, east }];
122+
}
123+
124+
// Crosses the antimeridian — split into two valid envelopes.
125+
return [
126+
{ south, north, west, east: 180 },
127+
{ south, north, west: -180, east },
128+
];
101129
}
102130

103131
export function isCoordinateInBounds(

0 commit comments

Comments
 (0)