From fcf391ce6294606992c0fc405ac2b7d5c13a6815 Mon Sep 17 00:00:00 2001 From: Mikhail Chandran Date: Mon, 2 Nov 2026 18:33:51 +0530 Subject: [PATCH] Stop bundling the places and competitions data into the /map client scripts --- src/app/embed/page.tsx | 7 +- src/app/map/page.tsx | 10 ++- src/components/home/city-stats-card.tsx | 2 +- src/components/map/embed-map.tsx | 13 ++-- src/components/map/map-panel.tsx | 6 +- src/components/map/map-view.test.tsx | 7 +- src/components/map/map-view.tsx | 15 ++-- src/components/map/places-map.test.tsx | 74 +++++++++++++++++-- src/components/map/places-map.tsx | 30 +++++--- .../map/suggest-place-dialog.test.tsx | 6 +- src/components/map/suggest-place-dialog.tsx | 12 ++- src/components/map/user-place-dialog.tsx | 6 +- src/lib/places-utils.ts | 69 +++++++++++++++++ src/lib/places.ts | 64 ++-------------- 14 files changed, 216 insertions(+), 105 deletions(-) create mode 100644 src/lib/places-utils.ts diff --git a/src/app/embed/page.tsx b/src/app/embed/page.tsx index 094d822..bc2d897 100644 --- a/src/app/embed/page.tsx +++ b/src/app/embed/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { getPlaces } from "@/lib/places"; import { EmbedMap } from "@/components/map/embed-map"; +import studyMapConfig from "../../../studymap.config"; export const metadata: Metadata = { title: "Embedded Map", @@ -13,7 +14,11 @@ export default function EmbedPage() { return (
- +
); } diff --git a/src/app/map/page.tsx b/src/app/map/page.tsx index b939d7a..00335d5 100644 --- a/src/app/map/page.tsx +++ b/src/app/map/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; -import { getPlaces } from "@/lib/places"; +import { getCities, getPlaces } from "@/lib/places"; import { PlacesMap } from "@/components/map/places-map"; +import studyMapConfig from "../../../studymap.config"; export const metadata: Metadata = { title: "Map", @@ -14,7 +15,12 @@ export default function MapPage() { return (
- +
); } diff --git a/src/components/home/city-stats-card.tsx b/src/components/home/city-stats-card.tsx index b9825e0..ec6c608 100644 --- a/src/components/home/city-stats-card.tsx +++ b/src/components/home/city-stats-card.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import type { Place, PlaceType } from "@/lib/types"; import { PLACE_TYPES, PLACE_TYPE_LABELS, humanizeCity } from "@/lib/types"; -import { filterPlaces } from "@/lib/places"; +import { filterPlaces } from "@/lib/places-utils"; import { mapStateToSearch } from "@/lib/share"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; diff --git a/src/components/map/embed-map.tsx b/src/components/map/embed-map.tsx index 39e9fd9..d42cd78 100644 --- a/src/components/map/embed-map.tsx +++ b/src/components/map/embed-map.tsx @@ -5,10 +5,9 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import type { Place } from "@/lib/types"; -import { cityBounds, filterPlaces } from "@/lib/places"; +import { cityBounds, filterPlaces } from "@/lib/places-utils"; import { parseMapState } from "@/lib/share"; import { site } from "@/lib/site"; -import studyMapConfig from "../../../studymap.config"; const MapView = dynamic(() => import("@/components/map/map-view"), { ssr: false, @@ -17,6 +16,8 @@ const MapView = dynamic(() => import("@/components/map/map-view"), { interface EmbedMapProps { places: Place[]; + defaultCenter: [number, number]; + defaultZoom: number; } /** @@ -26,7 +27,7 @@ interface EmbedMapProps { * the full map's "Share" button already works as an embed URL. Public * places only - private/saved places never enter this component. */ -export function EmbedMap({ places }: EmbedMapProps) { +export function EmbedMap({ places, defaultCenter, defaultZoom }: EmbedMapProps) { const state = React.useMemo( () => parseMapState(typeof window === "undefined" ? "" : window.location.search), [], @@ -55,9 +56,11 @@ export function EmbedMap({ places }: EmbedMapProps) { focusBounds={focusBounds} closePopupTrigger={0} center={ - state.lat !== null && state.lng !== null ? [state.lat, state.lng] : undefined + state.lat !== null && state.lng !== null + ? [state.lat, state.lng] + : defaultCenter } - zoom={state.zoom ?? studyMapConfig.defaultZoom} + zoom={state.zoom ?? defaultZoom} /> - + ); diff --git a/src/components/map/map-view.test.tsx b/src/components/map/map-view.test.tsx index a48b4d3..2347f6f 100644 --- a/src/components/map/map-view.test.tsx +++ b/src/components/map/map-view.test.tsx @@ -41,12 +41,14 @@ afterEach(() => { vi.restoreAllMocks(); }); +const VIEW = { center: [19.08, 72.95] as [number, number], zoom: 11 }; + describe("MapView configuration", () => { it("shows actionable guidance when the MapTiler key is blank", () => { vi.stubEnv("NEXT_PUBLIC_MAPTILER_KEY", " "); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - render(); + render(); expect(screen.getByRole("status", { name: "Basemap unavailable" })).toBeTruthy(); expect(screen.getByText("NEXT_PUBLIC_MAPTILER_KEY")).toBeTruthy(); @@ -72,6 +74,7 @@ describe("MapView configuration", () => { ); render( { })); flyTo.mockClear(); - render(); + render(); expect(flyTo).toHaveBeenCalledWith( [19.2, 72.9], diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 0a83bc7..465f1ef 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -18,8 +18,7 @@ import { import type { Place, PlaceType } from "@/lib/types"; import { PLACE_TYPES, PLACE_TYPE_LABELS } from "@/lib/types"; import type { LatLng } from "@/lib/geo"; -import studyMapConfig from "../../../studymap.config"; -import type { Bounds } from "@/lib/places"; +import type { Bounds } from "@/lib/places-utils"; import { PLACE_TYPE_COLORS } from "@/lib/map"; import { PinPopup } from "@/components/pins/pin-popup"; @@ -244,10 +243,10 @@ interface MapViewProps { focusBounds?: Bounds | null; /** When false, the map is a static preview: no pan, zoom, or controls. */ interactive?: boolean; - /** Initial zoom level; falls back to the MMR default. */ - zoom?: number; - /** Initial center, as [lat, lng]; falls back to the configured region center. */ - center?: [number, number]; + /** Initial zoom level. Callers resolve it from `studymap.config.ts`, so this bundle stays free of the dataset. */ + zoom: number; + /** Initial center, as [lat, lng]. */ + 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. */ @@ -474,8 +473,8 @@ export default function MapView({ focusId, focusBounds, interactive = true, - zoom = studyMapConfig.defaultZoom, - center = studyMapConfig.center, + zoom, + center, initialViewport = null, onViewportChange, closePopupTrigger = 0, diff --git a/src/components/map/places-map.test.tsx b/src/components/map/places-map.test.tsx index ef24519..294f8a6 100644 --- a/src/components/map/places-map.test.tsx +++ b/src/components/map/places-map.test.tsx @@ -34,6 +34,8 @@ function place(overrides: Partial & Pick): Place { }; } +const CITIES = ["mumbai", "thane"]; + const PLACES: Place[] = [ place({ id: "city-library", name: "City Library" }), place({ id: "reading-room", name: "Riverside Reading Room" }), @@ -106,7 +108,14 @@ afterEach(() => { describe("PlacesMap search URLs", () => { it("keeps an initial query and the other shared state after mounting", () => { window.history.replaceState(null, "", FILTERED_URL); - render(); + render( + , + ); expect(searchInputs().map((input) => input.value)).toEqual(["Library", "Library"]); expect(screen.getByText("City Library")).toBeTruthy(); @@ -129,7 +138,14 @@ describe("PlacesMap search URLs", () => { }); it("updates results and the URL only after the latest input has settled", () => { - render(); + render( + , + ); const replace = vi.spyOn(window.history, "replaceState"); const historyLength = window.history.length; const [desktop, mobile] = searchInputs(); @@ -160,7 +176,14 @@ describe("PlacesMap search URLs", () => { it("shares the applied query and restores the same results from the copied link", async () => { window.history.replaceState(null, "", FILTERED_URL); - const view = render(); + const view = render( + , + ); fireEvent.change(searchInputs()[0], { target: { value: "Reading" } }); await act(async () => { @@ -182,7 +205,14 @@ describe("PlacesMap search URLs", () => { expect(copied.pathname).toBe("/map"); view.unmount(); window.history.replaceState(null, "", copied.href); - render(); + render( + , + ); expect(searchInputs().map((input) => input.value)).toEqual(["Reading", "Reading"]); expect(screen.getByText("Riverside Reading Room")).toBeTruthy(); @@ -193,7 +223,14 @@ describe("PlacesMap search URLs", () => { it("clears the query without waiting 250 ms or losing the other shared state", () => { window.history.replaceState(null, "", FILTERED_URL); - render(); + render( + , + ); fireEvent.change(searchInputs()[0], { target: { value: "" } }); advance(0); @@ -209,7 +246,14 @@ describe("PlacesMap search URLs", () => { it("resets the search and filters to an unfiltered URL", () => { window.history.replaceState(null, "", "/map?q=Library&types=library&city=mumbai"); - render(); + render( + , + ); fireEvent.click(screen.getByRole("button", { name: "Reset" })); advance(0); @@ -224,7 +268,14 @@ describe("PlacesMap search URLs", () => { describe("PlacesMap panel mounting", () => { it("mounts the sidebar panel at desktop width", () => { - render(); + render( + , + ); // Sidebar search + mobile top bar search. expect(searchInputs()).toHaveLength(2); @@ -233,7 +284,14 @@ describe("PlacesMap panel mounting", () => { it("does not build the sidebar panel on a phone", () => { stubViewport(false); - render(); + render( + , + ); // Only the mobile top bar search; the sheet is closed at peek height. expect(searchInputs()).toHaveLength(1); diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index 96e0ee2..dc6dc11 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -6,13 +6,12 @@ import type { User } from "@supabase/supabase-js"; import { ChevronUp, Search, SlidersHorizontal } from "lucide-react"; import { toast } from "sonner"; -import type { Place, PlaceType } from "@/lib/types"; +import type { City, Place, PlaceType } from "@/lib/types"; import { humanizeCity, PLACE_TYPES, PLACE_TYPE_LABELS } from "@/lib/types"; -import { cityBounds, filterPlaces, getCities } from "@/lib/places"; +import { cityBounds, filterPlaces, getCities } from "@/lib/places-utils"; 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 { @@ -51,9 +50,18 @@ const MapView = dynamic(() => import("@/components/map/map-view"), { interface PlacesMapProps { places: Place[]; + /** Distinct city slugs in `places`, in display order (from `getCities`). */ + cities: City[]; + defaultCenter: [number, number]; + defaultZoom: number; } -export function PlacesMap({ places }: PlacesMapProps) { +export function PlacesMap({ + places, + cities, + defaultCenter, + defaultZoom, +}: PlacesMapProps) { const [filters, setFilters] = React.useState(() => { if (typeof window === "undefined") return { types: [], city: null, query: "" }; const state = parseMapState(window.location.search); @@ -77,7 +85,7 @@ export function PlacesMap({ places }: PlacesMapProps) { return { lat: state.lat, lng: state.lng, - zoom: state.zoom ?? studyMapConfig.defaultZoom, + zoom: state.zoom ?? defaultZoom, }; }); const handleViewportChange = React.useCallback((next: MapViewport) => { @@ -106,8 +114,6 @@ export function PlacesMap({ places }: PlacesMapProps) { const [myPlacesError, setMyPlacesError] = React.useState(null); const [lastUserId, setLastUserId] = React.useState(null); - const cities = React.useMemo(() => getCities(places), [places]); - const activeFilters = React.useMemo(() => { const labels: string[] = []; if (filters.types.length > 0) { @@ -229,7 +235,10 @@ export function PlacesMap({ places }: PlacesMapProps) { [visible, privatePlaces], ); - const myCities = React.useMemo(() => getCities(privatePlaces), [privatePlaces]); + const myCities = React.useMemo( + () => getCities(privatePlaces, cities), + [privatePlaces, cities], + ); const byDistance = React.useMemo(() => { if (!userLocation) return []; @@ -397,8 +406,8 @@ export function PlacesMap({ places }: PlacesMapProps) { focusId={focusId} focusBounds={focusBounds} closePopupTrigger={closePopupTrigger} - center={viewport ? [viewport.lat, viewport.lng] : undefined} - zoom={viewport?.zoom} + center={viewport ? [viewport.lat, viewport.lng] : defaultCenter} + zoom={viewport?.zoom ?? defaultZoom} initialViewport={viewport} onViewportChange={handleViewportChange} /> @@ -479,6 +488,7 @@ export function PlacesMap({ places }: PlacesMapProps) { open={placeDialogOpen} onOpenChange={setPlaceDialogOpen} place={editingPlace} + datasetCities={cities} onSaved={handlePlaceSaved} onDeleted={handlePlaceDeleted} /> diff --git a/src/components/map/suggest-place-dialog.test.tsx b/src/components/map/suggest-place-dialog.test.tsx index 4c16290..59f6694 100644 --- a/src/components/map/suggest-place-dialog.test.tsx +++ b/src/components/map/suggest-place-dialog.test.tsx @@ -52,7 +52,7 @@ describe("isGoogleMapsUrl", () => { describe("SuggestPlaceDialog submission", () => { it("rejects an invalid Google Maps URL inline", () => { - render(); + render(); fillRequiredFields("google maps com/place/x"); expect(screen.getByRole("alert").textContent).toContain( @@ -68,7 +68,7 @@ describe("SuggestPlaceDialog submission", () => { const onOpenChange = vi.fn(); vi.spyOn(window, "open").mockReturnValue(null); - render(); + render(); fillRequiredFields("https://maps.app.goo.gl/abc123"); fireEvent.click(screen.getByRole("button", { name: "Open GitHub issue" })); @@ -101,7 +101,7 @@ describe("SuggestPlaceDialog submission", () => { } as unknown as Window; vi.spyOn(window, "open").mockReturnValue(opened); - render(); + render(); fillRequiredFields("https://maps.google.com/?q=19.0176,72.8562"); fireEvent.click(screen.getByRole("button", { name: "Open GitHub issue" })); diff --git a/src/components/map/suggest-place-dialog.tsx b/src/components/map/suggest-place-dialog.tsx index 6152e82..d31443f 100644 --- a/src/components/map/suggest-place-dialog.tsx +++ b/src/components/map/suggest-place-dialog.tsx @@ -26,13 +26,13 @@ import { PLACE_TYPE_LABELS, PLACE_TYPES, type PlaceType, + type City, } from "@/lib/types"; -import { getCities, getPlaces } from "@/lib/places"; import { site } from "@/lib/site"; -const datasetCities = getCities(getPlaces()); - interface SuggestPlaceDialogProps { + /** Cities already in the public dataset, offered as suggestions. */ + datasetCities: City[]; open: boolean; onOpenChange: (open: boolean) => void; } @@ -88,7 +88,11 @@ export function isGoogleMapsUrl(value: string): boolean { * pre-filled "New issue" page. Nothing is written directly to the repo - * a maintainer still reviews and merges it through the existing PR flow. */ -export function SuggestPlaceDialog({ open, onOpenChange }: SuggestPlaceDialogProps) { +export function SuggestPlaceDialog({ + datasetCities, + open, + onOpenChange, +}: SuggestPlaceDialogProps) { const [name, setName] = React.useState(""); const [type, setType] = React.useState("library"); const [city, setCity] = React.useState(""); diff --git a/src/components/map/user-place-dialog.tsx b/src/components/map/user-place-dialog.tsx index 6804b0d..43f337e 100644 --- a/src/components/map/user-place-dialog.tsx +++ b/src/components/map/user-place-dialog.tsx @@ -25,10 +25,9 @@ import { PLACE_TYPE_LABELS, PLACE_TYPES, type PlaceType, + type City, } from "@/lib/types"; -import { getCities, getPlaces } from "@/lib/places"; -const datasetCities = getCities(getPlaces()); import { isMissingTableError } from "@/lib/utils"; import { createUserPlace, @@ -42,6 +41,8 @@ function normalizeCity(input: string): string { } interface UserPlaceDialogProps { + /** Cities already in the public dataset, offered as suggestions. */ + datasetCities: City[]; open: boolean; onOpenChange: (open: boolean) => void; place: UserPlaceRow | null; @@ -50,6 +51,7 @@ interface UserPlaceDialogProps { } export function UserPlaceDialog({ + datasetCities, open, onOpenChange, place, diff --git a/src/lib/places-utils.ts b/src/lib/places-utils.ts new file mode 100644 index 0000000..c8cfe1d --- /dev/null +++ b/src/lib/places-utils.ts @@ -0,0 +1,69 @@ +import type { City, Place, PlaceType } from "@/lib/types"; +import { foldText } from "@/lib/utils"; + +/** + * Pure helpers over a places array. Kept free of `studymap.config` so client + * components can import them without bundling the whole dataset. + */ +export function filterPlaces( + places: Place[], + opts: { types?: PlaceType[]; city?: City | null; query?: string }, +): Place[] { + const q = foldText(opts.query?.trim() ?? ""); + return places.filter((place) => { + if (opts.types && opts.types.length > 0 && !opts.types.includes(place.type)) { + return false; + } + if (opts.city && place.city !== opts.city) { + return false; + } + if (q) { + const cityNorm = place.city.replace(/_/g, " "); + if (!foldText(place.name).includes(q) && !cityNorm.includes(q)) { + return false; + } + } + return true; + }); +} + +/** + * Every distinct city slug present in `places`, ordered by `preferredOrder` + * with any city not in that list appended alphabetically after. + */ +export function getCities(places: Place[], preferredOrder: readonly City[] = []): City[] { + const present = new Set(places.map((place) => place.city)); + const ordered = preferredOrder.filter((city) => present.has(city)); + const rest = Array.from(present) + .filter((city) => !preferredOrder.includes(city)) + .sort(); + return [...ordered, ...rest]; +} + +export interface Bounds { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +} + +/** Bounding box of every place in `city`, for flying the map to that city. */ +export function cityBounds(places: Place[], city: City): Bounds | null { + const inCity = places.filter((place) => place.city === city); + if (inCity.length === 0) return null; + + return inCity.reduce( + (bounds, place) => ({ + minLat: Math.min(bounds.minLat, place.lat), + maxLat: Math.max(bounds.maxLat, place.lat), + minLng: Math.min(bounds.minLng, place.lng), + maxLng: Math.max(bounds.maxLng, place.lng), + }), + { + minLat: inCity[0].lat, + maxLat: inCity[0].lat, + minLng: inCity[0].lng, + maxLng: inCity[0].lng, + }, + ); +} diff --git a/src/lib/places.ts b/src/lib/places.ts index 5a2d8b2..1deaf97 100644 --- a/src/lib/places.ts +++ b/src/lib/places.ts @@ -1,34 +1,15 @@ -import type { City, Place, PlaceType } from "@/lib/types"; -import { foldText } from "@/lib/utils"; +import type { City, Place } from "@/lib/types"; import studyMapConfig from "../../studymap.config"; +import { getCities as getCitiesFrom } from "@/lib/places-utils"; + +export { cityBounds, filterPlaces } from "@/lib/places-utils"; +export type { Bounds } from "@/lib/places-utils"; /** All public places, sourced from `studymap.config.ts`. */ export function getPlaces(): Place[] { return studyMapConfig.places; } -export function filterPlaces( - places: Place[], - opts: { types?: PlaceType[]; city?: City | null; query?: string }, -): Place[] { - const q = foldText(opts.query?.trim() ?? ""); - return places.filter((place) => { - if (opts.types && opts.types.length > 0 && !opts.types.includes(place.type)) { - return false; - } - if (opts.city && place.city !== opts.city) { - return false; - } - if (q) { - const cityNorm = place.city.replace(/_/g, " "); - if (!foldText(place.name).includes(q) && !cityNorm.includes(q)) { - return false; - } - } - return true; - }); -} - /** * Every distinct city slug present in `places`, ordered by `preferredOrder` * (defaulting to `studymap.config.ts`'s `cities` registry) with any city not @@ -38,38 +19,5 @@ export function getCities( places: Place[], preferredOrder: readonly City[] = studyMapConfig.cities, ): City[] { - const present = new Set(places.map((place) => place.city)); - const ordered = preferredOrder.filter((city) => present.has(city)); - const rest = Array.from(present) - .filter((city) => !preferredOrder.includes(city)) - .sort(); - return [...ordered, ...rest]; -} - -export interface Bounds { - minLat: number; - maxLat: number; - minLng: number; - maxLng: number; -} - -/** Bounding box of every place in `city`, for flying the map to that city. */ -export function cityBounds(places: Place[], city: City): Bounds | null { - const inCity = places.filter((place) => place.city === city); - if (inCity.length === 0) return null; - - return inCity.reduce( - (bounds, place) => ({ - minLat: Math.min(bounds.minLat, place.lat), - maxLat: Math.max(bounds.maxLat, place.lat), - minLng: Math.min(bounds.minLng, place.lng), - maxLng: Math.max(bounds.maxLng, place.lng), - }), - { - minLat: inCity[0].lat, - maxLat: inCity[0].lat, - minLng: inCity[0].lng, - maxLng: inCity[0].lng, - }, - ); + return getCitiesFrom(places, preferredOrder); }