# Leaflet Map — Implementation Guide ## Dependencies Add to the project: ```bash npm install leaflet react-leaflet npm install -D @types/leaflet ``` Import the Leaflet CSS in the component file (not in global CSS — keeps it co-located): ```tsx import "leaflet/dist/leaflet.css"; ``` --- ## TypeScript declaration (if @types/leaflet is unavailable) If the project cannot install `@types/leaflet`, create a declaration file: ```ts // types/leaflet.d.ts declare module "leaflet" { const L: unknown; export default L; } declare module "leaflet/dist/leaflet.css"; ``` --- ## Reusable map component Create a generic map component at `components/MapView.tsx` (or similar): ```tsx import { useMemo, useState, useEffect } from "react"; import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; import L from "leaflet"; import "leaflet/dist/leaflet.css"; const Leaflet = L as { divIcon: (opts: { className?: string; html?: string; iconSize?: [number, number]; iconAnchor?: [number, number]; popupAnchor?: [number, number]; }) => unknown; }; const pinIcon = Leaflet.divIcon({ className: "map-pin", html: ``, iconSize: [28, 40], iconAnchor: [14, 40], popupAnchor: [0, -40], }); export interface MapMarker { lat: number; lng: number; label?: string; } interface MapViewProps { center: [number, number]; zoom?: number; markers?: MapMarker[]; className?: string; } function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) { const map = useMap() as { setView: (center: [number, number], zoom: number) => void }; useEffect(() => { map.setView(center, zoom); }, [map, center[0], center[1], zoom]); return null; } export default function MapView({ center, zoom = 13, markers = [], className = "h-[400px] w-full rounded-xl overflow-hidden", }: MapViewProps) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const effectiveCenter = useMemo((): [number, number] => { if (markers.length > 0) { const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]); return [sum[0] / markers.length, sum[1] / markers.length]; } return center; }, [center, markers]); if (!mounted || typeof window === "undefined") { return (
Loading map…
); } return (
{markers.map((m, i) => ( {m.label ?? "Location"} ))}
); } ``` --- ## Tile provider: OpenStreetMap The default tile URL is free and requires no API key: ``` https://tile.openstreetmap.org/{z}/{x}/{y}.png ``` | Property | Value | |----------|-------| | Cost | Free | | API key | None | | Max zoom | 19 | | Attribution | Required — `© OpenStreetMap` | CSP `img-src` must include `https://tile.openstreetmap.org` if CSP is enforced. --- ## Custom pin icon via CSS The `divIcon` approach avoids external marker image dependencies. Add these styles to your global CSS or a co-located CSS file: ```css .map-pin { background: transparent !important; border: none !important; } .map-pin-shape { display: block; width: 28px; height: 40px; background: hsl(var(--primary)); border-radius: 50% 50% 50% 0; transform: rotate(-45deg); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); } .map-pin-shape::after { content: ""; display: block; width: 12px; height: 12px; background: white; border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } ``` --- ## SSR safety Leaflet requires `window` and `document`. The component guards against SSR with: 1. A `mounted` state that only becomes `true` in `useEffect` (client-side). 2. A `typeof window === "undefined"` check. 3. A loading placeholder rendered during SSR / before mount. This prevents "window is not defined" errors in SSR or static builds. --- ## MapCenterUpdater pattern `react-leaflet`'s `MapContainer` does not re-center when the `center` prop changes after initial render. The `MapCenterUpdater` child component uses `useMap()` to call `map.setView()` reactively: ```tsx function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoom?: number }) { const map = useMap(); useEffect(() => { map.setView(center, zoom); }, [map, center[0], center[1], zoom]); return null; } ``` Place it as a direct child of ``. --- ## Multi-marker center calculation When rendering multiple markers, auto-center the map on their centroid: ```tsx const effectiveCenter = useMemo((): [number, number] => { if (markers.length > 0) { const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]); return [sum[0] / markers.length, sum[1] / markers.length]; } return fallbackCenter; }, [markers, fallbackCenter]); ``` --- ## Split-panel layout (map + list) For a search page with map on the left and scrollable results on the right: ```tsx
{/* Search bar */}
{/* search input */}
{/* Map + List */}
``` Key points: - `h-[calc(100vh-4rem)]` fills viewport minus header. - `lg:flex-row` stacks vertically on mobile, side-by-side on desktop. - List panel uses `overflow-y-auto` for independent scrolling. --- ## Accessibility - Wrap the map `
` with `aria-label="Map"` or a descriptive label. - Pin icon inner HTML uses `aria-hidden="true"`. - Popups contain text labels for each marker. - Provide a text-based alternative (address list) for screen readers.