← back to Homesonspec

packages/shared-ui/src/MapView.tsx

97 lines

"use client";

import { useEffect } from "react";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";

/**
 * Props-only map contract — Leaflet + OSM today; a commercial provider can
 * swap in behind this interface without touching callers. Import this
 * component with next/dynamic ssr:false (Leaflet needs window).
 */

export interface MapMarker {
  id: string;
  lat: number;
  lon: number;
  label: string;
  sublabel?: string;
  href?: string;
}

export interface MapViewProps {
  markers: MapMarker[];
  center?: [number, number];
  zoom?: number;
  onMoveEnd?: (bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => void;
  className?: string;
}

// Default marker sprites don't resolve under bundlers; use a divIcon dot.
const dotIcon = L.divIcon({
  className: "",
  html: '<div style="width:14px;height:14px;border-radius:50%;background:#0f766e;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>',
  iconSize: [14, 14],
  iconAnchor: [7, 7],
});

function MoveListener({ onMoveEnd }: { onMoveEnd?: MapViewProps["onMoveEnd"] }) {
  const map = useMap();
  useEffect(() => {
    if (!onMoveEnd) return;
    const handler = () => {
      const b = map.getBounds();
      onMoveEnd({
        minLat: b.getSouth(),
        maxLat: b.getNorth(),
        minLon: b.getWest(),
        maxLon: b.getEast(),
      });
    };
    map.on("moveend", handler);
    return () => {
      map.off("moveend", handler);
    };
  }, [map, onMoveEnd]);
  return null;
}

function FitMarkers({ markers }: { markers: MapMarker[] }) {
  const map = useMap();
  useEffect(() => {
    if (markers.length === 0) return;
    const bounds = L.latLngBounds(markers.map((m) => [m.lat, m.lon] as [number, number]));
    map.fitBounds(bounds.pad(0.15), { maxZoom: 13 });
    // Only refit when the marker SET changes, not on every pan.
  }, [map, markers.map((m) => m.id).join(",")]);
  return null;
}

export function MapView({ markers, center = [31.5, -95.0], zoom = 5, onMoveEnd, className }: MapViewProps) {
  return (
    <MapContainer center={center} zoom={zoom} className={className} style={{ width: "100%", height: "100%" }}>
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      <MoveListener onMoveEnd={onMoveEnd} />
      <FitMarkers markers={markers} />
      {markers.map((m) => (
        <Marker key={m.id} position={[m.lat, m.lon]} icon={dotIcon}>
          <Popup>
            {m.href ? (
              <a href={m.href} style={{ fontWeight: 600 }}>
                {m.label}
              </a>
            ) : (
              <strong>{m.label}</strong>
            )}
            {m.sublabel ? <div>{m.sublabel}</div> : null}
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  );
}