afv-library/samples/webapp-template-app-react-sample-b2e-experimental/.a4drules/skills/building-interactive-map/implementation/leaflet-map.md

6.8 KiB

Leaflet Map — Implementation Guide

Dependencies

Add to the project:

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):

import "leaflet/dist/leaflet.css";

TypeScript declaration (if @types/leaflet is unavailable)

If the project cannot install @types/leaflet, create a declaration file:

// 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):

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: `<span class="map-pin-shape" aria-hidden="true"></span>`,
  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 (
      <div
        className={className + " bg-muted flex items-center justify-center text-muted-foreground text-sm"}
        aria-hidden
      >
        Loading map
      </div>
    );
  }

  return (
    <div className={className} aria-label="Map">
      <MapContainer
        center={effectiveCenter}
        zoom={zoom}
        scrollWheelZoom
        className="h-full w-full"
        style={{ minHeight: 200 }}
      >
        <MapCenterUpdater center={effectiveCenter} zoom={zoom} />
        <TileLayer
          attribution='Data by &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
          url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
          maxZoom={19}
        />
        {markers.map((m, i) => (
          <Marker key={`${m.lat}-${m.lng}-${i}`} position={[m.lat, m.lng]} icon={pinIcon}>
            <Popup>{m.label ?? "Location"}</Popup>
          </Marker>
        ))}
      </MapContainer>
    </div>
  );
}

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:

.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:

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 <MapContainer>.


Multi-marker center calculation

When rendering multiple markers, auto-center the map on their centroid:

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:

<div className="flex h-[calc(100vh-4rem)] min-h-[500px] flex-col">
  {/* Search bar */}
  <div className="shrink-0 border-b bg-background px-4 py-3">
    {/* search input */}
  </div>
  {/* Map + List */}
  <div className="flex min-h-0 flex-1 flex-col lg:flex-row">
    <div className="h-64 shrink-0 lg:h-full lg:min-h-0 lg:w-2/3" aria-label="Map">
      <MapView center={center} markers={markers} className="h-full w-full" />
    </div>
    <aside className="flex w-full flex-col border-t lg:w-1/3 lg:border-l lg:border-t-0">
      <div className="flex-1 overflow-y-auto p-4">
        {/* list items */}
      </div>
    </aside>
  </div>
</div>

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 <div> 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.