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);
}