Skip to content

Commit 06362e8

Browse files
committed
fix(map): 8 UX fixes — tiles, popups, near-me, repair shop category
- Tile render speed: keepBuffer=2 + updateWhenZooming=false on TileLayer; loading fallback is now a pulsing skeleton div instead of plain text - Popup stability: closePopupOnClick=false on MapContainer + autoPan=false on Popup eliminates the close-on-open cycle for nearby markers - No zoom on single marker click: removed map.flyTo() from individual marker click handler; sidebar FlyToPlace still works for search results - Auto-close on new marker click: removed autoClose=false from Popup, reverting to Leaflet default (opening B closes A) - Near me interaction: removed setSheetOpen(true) from onLocated so map stays fully interactive after geolocation; peek bar already signals mode - Near me closes popup: closePopupTrigger counter incremented in onLocated, ClosePopupOnTrigger component calls map.closePopup() on increment - Hero stat space: explicit {" "} between count and "categories" - Desktop zoom hint: dismissible pill overlay on map page (lg only), persisted to localStorage - New category: repair_shop (#DC2626) added to types, colors, CSS tokens, and hero legend
1 parent 7c0a576 commit 06362e8

6 files changed

Lines changed: 55 additions & 9 deletions

File tree

src/app/globals.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
--marker-internet-cafe: #0369A1;
5858
--marker-airport: #BE185D;
5959
--marker-train-station: #4D7C0F;
60+
--marker-repair-shop: #DC2626;
6061
}
6162

6263
/* ----------------------------------------------------------------
@@ -109,6 +110,7 @@
109110
--marker-internet-cafe: #0369A1;
110111
--marker-airport: #BE185D;
111112
--marker-train-station: #4D7C0F;
113+
--marker-repair-shop: #DC2626;
112114
}
113115

114116
/* ================================================================
@@ -157,6 +159,7 @@
157159
--color-marker-internet-cafe: var(--marker-internet-cafe);
158160
--color-marker-airport: var(--marker-airport);
159161
--color-marker-train-station: var(--marker-train-station);
162+
--color-marker-repair-shop: var(--marker-repair-shop);
160163

161164
/* next/font injects --font-inter, --font-space-grotesk, --font-jetbrains-mono
162165
onto <html> via className. See src/lib/fonts.ts */

src/components/home/hero.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const MARKER_DOT: Record<PlaceType, string> = {
1818
internet_cafe: "bg-marker-internet-cafe",
1919
airport: "bg-marker-airport",
2020
train_station: "bg-marker-train-station",
21+
repair_shop: "bg-marker-repair-shop",
2122
};
2223

2324
export function Hero() {
@@ -53,7 +54,7 @@ export function Hero() {
5354
</div>
5455

5556
<p className="mt-6 font-mono text-xs text-muted-foreground">
56-
{total} places &middot; {PLACE_TYPES.length} categories &middot; 3 cities &middot; 100% open data
57+
{total} places &middot; {PLACE_TYPES.length}{" "}categories &middot; 3 cities &middot; 100% open data
5758
</p>
5859

5960
<ul className="mt-8 flex flex-wrap gap-x-4 gap-y-2.5">

src/components/map/map-view.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,10 @@ function ClusteredMarkers({
159159
eventHandlers={{
160160
click: (e) => {
161161
L.DomEvent.stopPropagation(e);
162-
if (focusId === place.id) return;
163-
map.flyTo([place.lat, place.lng], 15, { duration: 0.5 });
164162
},
165163
}}
166164
>
167-
<Popup autoClose={false} closeOnClick={false}>
165+
<Popup autoPan={false} closeOnClick={false}>
168166
<PinPopup place={place} />
169167
</Popup>
170168
</Marker>
@@ -174,6 +172,19 @@ function ClusteredMarkers({
174172
);
175173
}
176174

175+
/** Closes all open popups whenever the trigger counter increments. */
176+
function ClosePopupOnTrigger({ trigger }: { trigger: number }) {
177+
const map = useMap();
178+
const prev = useRef(trigger);
179+
useEffect(() => {
180+
if (trigger !== prev.current) {
181+
prev.current = trigger;
182+
map.closePopup();
183+
}
184+
}, [map, trigger]);
185+
return null;
186+
}
187+
177188
interface MapViewProps {
178189
places: Place[];
179190
userLocation?: LatLng | null;
@@ -184,6 +195,8 @@ interface MapViewProps {
184195
interactive?: boolean;
185196
/** Initial zoom level; falls back to the MMR default. */
186197
zoom?: number;
198+
/** Increment this to imperatively close all open popups. */
199+
closePopupTrigger?: number;
187200
}
188201

189202
/** Eases the map to the user's location when it is first set. */
@@ -318,6 +331,7 @@ export default function MapView({
318331
focusBounds,
319332
interactive = true,
320333
zoom = DEFAULT_ZOOM,
334+
closePopupTrigger = 0,
321335
}: MapViewProps) {
322336
const focusPlace = focusId
323337
? places.find((place) => place.id === focusId)
@@ -349,17 +363,21 @@ export default function MapView({
349363
keyboard={interactive}
350364
zoomControl={interactive}
351365
attributionControl={interactive}
366+
closePopupOnClick={false}
352367
className="size-full"
353368
>
354369
<TileLayer
355370
key={tileVariant}
356371
attribution='&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
357372
url={`https://api.maptiler.com/maps/${tileVariant}/256/{z}/{x}/{y}{r}.png?key=${process.env.NEXT_PUBLIC_MAPTILER_KEY}`}
358373
maxZoom={20}
374+
keepBuffer={2}
375+
updateWhenZooming={false}
359376
detectRetina
360377
/>
361378

362379
<MapResizeHandler />
380+
<ClosePopupOnTrigger trigger={closePopupTrigger} />
363381
{interactive && <ScrollZoomGuard />}
364382
{interactive && !userLocation && !focusPlace && !focusBounds && (
365383
<FitAllOnMount places={places} />

src/components/map/places-map.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ import type { PlaceFilters } from "@/components/map/filters";
2222
const MapView = dynamic(() => import("@/components/map/map-view"), {
2323
ssr: false,
2424
loading: () => (
25-
<div className="flex size-full items-center justify-center bg-muted text-sm text-muted-foreground">
26-
Loading map...
27-
</div>
25+
<div className="size-full animate-pulse bg-muted" aria-hidden />
2826
),
2927
});
3028

@@ -44,11 +42,16 @@ export function PlacesMap({ places }: PlacesMapProps) {
4442
return parseMapState(window.location.search).placeId ?? null;
4543
});
4644
const [userLocation, setUserLocation] = React.useState<LatLng | null>(null);
45+
const [closePopupTrigger, setClosePopupTrigger] = React.useState(0);
4746
const [sortByDistance, setSortByDistance] = React.useState(false);
4847
const [snap, setSnap] = React.useState<number | string | null>(
4948
SHEET_SNAP_POINTS[0],
5049
);
5150
const [sheetOpen, setSheetOpen] = React.useState(false);
51+
const [zoomHintVisible, setZoomHintVisible] = React.useState(() => {
52+
if (typeof window === "undefined") return false;
53+
return localStorage.getItem("studymap:zoom-hint-dismissed") !== "true";
54+
});
5255
const hydrated = React.useRef(false);
5356

5457
const cities = React.useMemo(() => getCities(places), [places]);
@@ -145,8 +148,7 @@ export function PlacesMap({ places }: PlacesMapProps) {
145148

146149
function onLocated(loc: LatLng) {
147150
setUserLocation(loc);
148-
setSnap(SHEET_SNAP_POINTS[0]);
149-
setSheetOpen(true); // surface the nearest list once we have a location
151+
setClosePopupTrigger((t) => t + 1);
150152
}
151153

152154
function openSheet() {
@@ -189,9 +191,28 @@ export function PlacesMap({ places }: PlacesMapProps) {
189191
userLocation={userLocation}
190192
focusId={focusId}
191193
focusBounds={focusBounds}
194+
closePopupTrigger={closePopupTrigger}
192195
/>
193196
</MapErrorBoundary>
194197

198+
{/* Desktop zoom hint — dismissible, laptop only */}
199+
{zoomHintVisible && (
200+
<div className="pointer-events-auto absolute left-1/2 top-3 z-[999] hidden -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-sm lg:flex">
201+
<span>Best viewed at 75% browser zoom</span>
202+
<button
203+
type="button"
204+
aria-label="Dismiss hint"
205+
className="ml-1 text-muted-foreground hover:text-foreground"
206+
onClick={() => {
207+
setZoomHintVisible(false);
208+
localStorage.setItem("studymap:zoom-hint-dismissed", "true");
209+
}}
210+
>
211+
×
212+
</button>
213+
</div>
214+
)}
215+
195216
{/* Mobile top bar: persistent search + filters trigger */}
196217
<div className="pointer-events-none absolute inset-x-3 top-3 z-[1000] flex gap-2 lg:hidden">
197218
<div className="pointer-events-auto relative flex-1">

src/lib/map.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const PLACE_TYPE_COLORS: Record<PlaceType, string> = {
1414
internet_cafe: "#0369A1",
1515
airport: "#BE185D",
1616
train_station: "#4D7C0F",
17+
repair_shop: "#DC2626",
1718
};
1819

1920
/** Build a Google Maps directions deep-link to a coordinate. */

src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const PLACE_TYPES = [
77
"internet_cafe",
88
"airport",
99
"train_station",
10+
"repair_shop",
1011
] as const;
1112

1213
export type PlaceType = (typeof PLACE_TYPES)[number];
@@ -28,6 +29,7 @@ export const PLACE_TYPE_LABELS: Record<PlaceType, string> = {
2829
internet_cafe: "Internet cafe",
2930
airport: "Airport",
3031
train_station: "Train station",
32+
repair_shop: "Repair shop",
3133
};
3234

3335
/** Turns a city slug into a display label, e.g. "navi_mumbai" -> "Navi Mumbai". */

0 commit comments

Comments
 (0)