From ffdc4bfe3e46e63e1cb63cd6ba1575eaf0426ce6 Mon Sep 17 00:00:00 2001 From: aryansk Date: Fri, 14 Aug 2026 20:03:02 +0530 Subject: [PATCH] feat: keep the map viewport in the URL so a place can be shared by link Extends the existing share state (place/city/types, already selected and centred via ?place=) with lat/lng/zoom viewport params: parseMapState restores them on load (skipping the fit-all view), a ViewportReporter mirrors pan/zoom into the URL with replaceState (no history entries), and copy-link now reproduces the exact viewport in a fresh tab. Invalid viewport params degrade to the default view instead of crashing. Closes #118 --- src/components/map/map-view.tsx | 54 +++++++++++++++++++++- src/components/map/places-map.tsx | 33 +++++++++++++- src/components/pins/pin-popup.tsx | 9 +++- src/lib/share.test.ts | 75 ++++++++++++++++++++++++++++--- src/lib/share.ts | 25 ++++++++++- 5 files changed, 185 insertions(+), 11 deletions(-) diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 4811fe0..1370a17 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -191,6 +191,12 @@ function ClusteredMarkers({ places }: { places: Place[] }) { ); } +export interface MapViewport { + lat: number; + lng: number; + zoom: number; +} + interface MapViewProps { places: Place[]; userLocation?: LatLng | null; @@ -203,6 +209,10 @@ interface MapViewProps { zoom?: number; /** Initial center, as [lat, lng]; falls back to the configured region center. */ center?: [number, number]; + /** When set, the map starts at this viewport instead of fitting all places. */ + initialViewport?: MapViewport | null; + /** Fired on pan/zoom end with the current center and zoom. */ + onViewportChange?: (viewport: MapViewport) => void; /** Increment to imperatively close all open popups. */ closePopupTrigger?: number; } @@ -326,6 +336,43 @@ function ScrollZoomGuard() { ); } +/** + * Reports the map center and zoom to the parent whenever the user pans or + * zooms, so the URL can stay in sync (see #118). Dedupes identical values + * so an unchanged viewport never rewrites the URL. + */ +function ViewportReporter({ + onViewportChange, +}: { + onViewportChange: (viewport: MapViewport) => void; +}) { + const map = useMap(); + const last = useRef(""); + + useEffect(() => { + function report() { + const center = map.getCenter(); + const viewport = { + lat: Math.round(center.lat * 1e5) / 1e5, + lng: Math.round(center.lng * 1e5) / 1e5, + zoom: Math.round(map.getZoom() * 10) / 10, + }; + const key = `${viewport.lat},${viewport.lng},${viewport.zoom}`; + if (key === last.current) return; + last.current = key; + onViewportChange(viewport); + } + map.on("moveend", report); + map.on("zoomend", report); + return () => { + map.off("moveend", report); + map.off("zoomend", report); + }; + }, [map, onViewportChange]); + + return null; +} + /** * Keeps Leaflet's tile grid in sync with the container's real size. * Without this, anything that resizes the map div (mobile browser chrome @@ -358,6 +405,8 @@ export default function MapView({ interactive = true, zoom = studyMapConfig.defaultZoom, center = studyMapConfig.center, + initialViewport = null, + onViewportChange, closePopupTrigger = 0, }: MapViewProps) { const focusPlace = focusId @@ -406,7 +455,10 @@ export default function MapView({ {interactive && } - {interactive && !userLocation && !focusPlace && !focusBounds && ( + {interactive && onViewportChange && ( + + )} + {interactive && !userLocation && !focusPlace && !focusBounds && !initialViewport && ( )} diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index 1c9e984..f337cfa 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -11,6 +11,8 @@ import { humanizeCity, PLACE_TYPES, PLACE_TYPE_LABELS } from "@/lib/types"; import { cityBounds, filterPlaces, getCities } from "@/lib/places"; import { placesByDistance, type LatLng } from "@/lib/geo"; import { buildShareUrl, mapStateToSearch, parseMapState } from "@/lib/share"; +import type { MapViewport } from "@/components/map/map-view"; +import studyMapConfig from "../../../studymap.config"; import { createClient } from "@/lib/supabase/client"; import { isMissingTableError } from "@/lib/utils"; import { @@ -61,6 +63,21 @@ export function PlacesMap({ places }: PlacesMapProps) { if (typeof window === "undefined") return null; return parseMapState(window.location.search).placeId ?? null; }); + // A viewport in the URL restores that exact center/zoom on load instead of + // fitting all places; it is then kept in sync as the user pans (see #118). + const [viewport, setViewport] = React.useState(() => { + if (typeof window === "undefined") return null; + const state = parseMapState(window.location.search); + if (state.lat === null || state.lng === null) return null; + return { + lat: state.lat, + lng: state.lng, + zoom: state.zoom ?? studyMapConfig.defaultZoom, + }; + }); + const handleViewportChange = React.useCallback((next: MapViewport) => { + setViewport(next); + }, []); const [userLocation, setUserLocation] = React.useState(null); const [closePopupTrigger, setClosePopupTrigger] = React.useState(0); const [sortByDistance, setSortByDistance] = React.useState(false); @@ -145,16 +162,21 @@ export function PlacesMap({ places }: PlacesMapProps) { return () => clearTimeout(timer); }, [filters.query]); - // Mirror filter and focus state back into the URL so it stays shareable. + // Mirror filter, focus, and viewport state back into the URL so it stays + // shareable. replaceState keeps panning out of browser history, so the back + // button still leaves the map in one press (see #118). React.useEffect(() => { if (!hydrated.current) return; const search = mapStateToSearch({ types: filters.types, city: filters.city, placeId: focusId, + lat: viewport?.lat ?? null, + lng: viewport?.lng ?? null, + zoom: viewport?.zoom ?? null, }); window.history.replaceState(null, "", `${window.location.pathname}${search}`); - }, [filters, focusId]); + }, [filters, focusId, viewport]); const visible = React.useMemo( () => @@ -227,6 +249,9 @@ export function PlacesMap({ places }: PlacesMapProps) { types: filters.types, city: filters.city, placeId: focusId, + lat: viewport?.lat ?? null, + lng: viewport?.lng ?? null, + zoom: viewport?.zoom ?? null, }); navigator.clipboard .writeText(url) @@ -331,6 +356,10 @@ export function PlacesMap({ places }: PlacesMapProps) { focusId={focusId} focusBounds={focusBounds} closePopupTrigger={closePopupTrigger} + center={viewport ? [viewport.lat, viewport.lng] : undefined} + zoom={viewport?.zoom} + initialViewport={viewport} + onViewportChange={handleViewportChange} /> diff --git a/src/components/pins/pin-popup.tsx b/src/components/pins/pin-popup.tsx index b093e36..9ede58a 100644 --- a/src/components/pins/pin-popup.tsx +++ b/src/components/pins/pin-popup.tsx @@ -23,7 +23,14 @@ function formatValidTill(iso: string): string { export function PinPopup({ place }: PinPopupProps) { function copyLink() { - const url = buildShareUrl({ types: [], city: null, placeId: place.id }); + const url = buildShareUrl({ + types: [], + city: null, + placeId: place.id, + lat: place.lat, + lng: place.lng, + zoom: 15, + }); navigator.clipboard .writeText(url) .then(() => toast.success("Link copied")) diff --git a/src/lib/share.test.ts b/src/lib/share.test.ts index 6b5baf9..3937dd3 100644 --- a/src/lib/share.test.ts +++ b/src/lib/share.test.ts @@ -2,21 +2,32 @@ import { describe, expect, it } from "vitest"; import { buildShareUrl, mapStateToSearch, parseMapState, type MapShareState } from "@/lib/share"; +const emptyState: MapShareState = { + types: [], + city: null, + placeId: null, + lat: null, + lng: null, + zoom: null, +}; + describe("mapStateToSearch / parseMapState round-trip", () => { it("round-trips a fully populated state", () => { const state: MapShareState = { types: ["library", "sat_centre"], city: "mumbai", placeId: "mum-library-01", + lat: 19.076, + lng: 72.8777, + zoom: 14, }; const search = mapStateToSearch(state); expect(parseMapState(search)).toEqual(state); }); it("round-trips an empty state as an empty query string", () => { - const state: MapShareState = { types: [], city: null, placeId: null }; - expect(mapStateToSearch(state)).toBe(""); - expect(parseMapState("")).toEqual(state); + expect(mapStateToSearch(emptyState)).toBe(""); + expect(parseMapState("")).toEqual(emptyState); }); it("drops unknown place types when parsing", () => { @@ -29,20 +40,72 @@ describe("mapStateToSearch / parseMapState round-trip", () => { }); it("reads a placeId with no other state set", () => { - const search = mapStateToSearch({ types: [], city: null, placeId: "mum-library-01" }); + const search = mapStateToSearch({ + ...emptyState, + placeId: "mum-library-01", + }); expect(search).toBe("?place=mum-library-01"); expect(parseMapState(search).placeId).toBe("mum-library-01"); }); + + it("round-trips a viewport without filters", () => { + const search = mapStateToSearch({ + ...emptyState, + lat: 19.076, + lng: 72.8777, + zoom: 12, + }); + expect(search).toBe("?lat=19.076&lng=72.8777&zoom=12"); + expect(parseMapState(search)).toEqual({ + ...emptyState, + lat: 19.076, + lng: 72.8777, + zoom: 12, + }); + }); + + it("ignores lat/lng unless both are present and finite", () => { + const parsed = parseMapState("?lat=19.076&zoom=12"); + expect(parsed.lat).toBeNull(); + expect(parsed.lng).toBeNull(); + expect(parsed.zoom).toBeNull(); + + const bogus = parseMapState("?lat=abc&lng=72.8777"); + expect(bogus.lat).toBeNull(); + expect(bogus.lng).toBeNull(); + }); + + it("omits the zoom when it is null", () => { + const search = mapStateToSearch({ ...emptyState, lat: 19.076, lng: 72.8777 }); + expect(search).toBe("?lat=19.076&lng=72.8777"); + }); }); describe("buildShareUrl", () => { it("builds an absolute URL from the current origin and pathname", () => { - const url = buildShareUrl({ types: ["library"], city: "mumbai", placeId: null }); + const url = buildShareUrl({ + ...emptyState, + types: ["library"], + city: "mumbai", + }); expect(url).toBe(`${window.location.origin}${window.location.pathname}?types=library&city=mumbai`); }); + it("includes the viewport when present", () => { + const url = buildShareUrl({ + ...emptyState, + placeId: "mum-library-01", + lat: 19.076, + lng: 72.8777, + zoom: 15, + }); + expect(url).toBe( + `${window.location.origin}${window.location.pathname}?place=mum-library-01&lat=19.076&lng=72.8777&zoom=15`, + ); + }); + it("returns just the origin and pathname when no state is set", () => { - const url = buildShareUrl({ types: [], city: null, placeId: null }); + const url = buildShareUrl(emptyState); expect(url).toBe(`${window.location.origin}${window.location.pathname}`); }); }); diff --git a/src/lib/share.ts b/src/lib/share.ts index 8b71405..98ac1d3 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -5,6 +5,10 @@ export interface MapShareState { types: PlaceType[]; city: City | null; placeId: string | null; + /** Optional viewport: map center coordinates and zoom level. */ + lat: number | null; + lng: number | null; + zoom: number | null; } const TYPE_SET = new Set(PLACE_TYPES); @@ -17,14 +21,28 @@ function parseList(raw: string | null, allowed: Set): string[] { .filter((value) => allowed.has(value)); } -/** Read filter and focused-pin state out of a URL query string. */ +function parseNumber(raw: string | null): number | null { + if (raw === null || raw.trim() === "") return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +/** Read filter, focused-pin, and viewport state out of a URL query string. */ export function parseMapState(search: string): MapShareState { const params = new URLSearchParams(search); const city = params.get("city")?.trim() || null; + const lat = parseNumber(params.get("lat")); + const lng = parseNumber(params.get("lng")); + const zoom = parseNumber(params.get("zoom")); + // A viewport only makes sense with both coordinates; otherwise ignore all three. + const hasViewport = lat !== null && lng !== null; return { types: parseList(params.get("types"), TYPE_SET) as PlaceType[], city, placeId: params.get("place"), + lat: hasViewport ? lat : null, + lng: hasViewport ? lng : null, + zoom: hasViewport ? zoom : null, }; } @@ -34,6 +52,11 @@ export function mapStateToSearch(state: MapShareState): string { if (state.types.length) params.set("types", state.types.join(",")); if (state.city) params.set("city", state.city); if (state.placeId) params.set("place", state.placeId); + if (state.lat !== null && state.lng !== null) { + params.set("lat", String(state.lat)); + params.set("lng", String(state.lng)); + if (state.zoom !== null) params.set("zoom", String(state.zoom)); + } const query = params.toString(); return query ? `?${query}` : ""; }