← 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='© <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>
);
}