Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion src/components/map/map-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<string>("");

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -406,7 +455,10 @@ export default function MapView({
<MapResizeHandler />
<ClosePopupOnTrigger trigger={closePopupTrigger} />
{interactive && <ScrollZoomGuard />}
{interactive && !userLocation && !focusPlace && !focusBounds && (
{interactive && onViewportChange && (
<ViewportReporter onViewportChange={onViewportChange} />
)}
{interactive && !userLocation && !focusPlace && !focusBounds && !initialViewport && (
<FitAllOnMount places={places} />
)}

Expand Down
33 changes: 31 additions & 2 deletions src/components/map/places-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<MapViewport | null>(() => {
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<LatLng | null>(null);
const [closePopupTrigger, setClosePopupTrigger] = React.useState(0);
const [sortByDistance, setSortByDistance] = React.useState(false);
Expand Down Expand Up @@ -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(
() =>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
/>
</MapErrorBoundary>

Expand Down
9 changes: 8 additions & 1 deletion src/components/pins/pin-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
75 changes: 69 additions & 6 deletions src/lib/share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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}`);
});
});
25 changes: 24 additions & 1 deletion src/lib/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(PLACE_TYPES);
Expand All @@ -17,14 +21,28 @@ function parseList(raw: string | null, allowed: Set<string>): 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,
};
}

Expand All @@ -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}` : "";
}
Expand Down
Loading