151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* LeafletMap — the Leaflet core shared by the /map page and journal map
|
|
* blocks: tiles, markers from props, an optional line through them, fit to
|
|
* bounds. It knows nothing about /api/map; MapView fetches and hands over.
|
|
*
|
|
* The Leaflet stylesheet is emitted here rather than by each page, so a map
|
|
* anywhere brings its own CSS. Marker and popup styling lives in
|
|
* app/leaflet.css, loaded globally.
|
|
*/
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
export interface LeafletMarker {
|
|
lat: number;
|
|
lng: number;
|
|
/** Inner HTML of the marker icon — already escaped by the caller. */
|
|
html: string;
|
|
popupHtml?: string;
|
|
}
|
|
|
|
interface LeafletMapProps {
|
|
markers: LeafletMarker[];
|
|
/** Draw a dashed line through the markers in the order given. */
|
|
line?: boolean;
|
|
className?: string;
|
|
/** Cap the zoom fitBounds may pick — a single pin otherwise zooms to the rooftop. */
|
|
fitMaxZoom?: number;
|
|
onReady?: () => void;
|
|
onError?: (err: unknown) => void;
|
|
}
|
|
|
|
/** Escapes special HTML characters for Leaflet's string-based popups and icons. */
|
|
export function escapeHtml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
const LEAFLET_CSS_HREF = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
const LEAFLET_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
|
|
|
export function LeafletMap({
|
|
markers,
|
|
line = false,
|
|
className = 'map-container',
|
|
fitMaxZoom,
|
|
onReady,
|
|
onError,
|
|
}: LeafletMapProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
|
|
// Callers rebuild the markers array on every render; the map is only
|
|
// rebuilt when its content changes.
|
|
const markersKey = JSON.stringify(markers);
|
|
const markersRef = useRef(markers);
|
|
markersRef.current = markers;
|
|
const onReadyRef = useRef(onReady);
|
|
onReadyRef.current = onReady;
|
|
const onErrorRef = useRef(onError);
|
|
onErrorRef.current = onError;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function init() {
|
|
const L = (await import('leaflet')).default;
|
|
if (cancelled || !containerRef.current || mapRef.current) return;
|
|
|
|
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
|
|
mapRef.current = map;
|
|
|
|
// Dark-themed tiles (CartoDB Dark Matter)
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
subdomains: 'abcd',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
const current = markersRef.current;
|
|
if (current.length === 0) {
|
|
// Nothing to place — world view
|
|
map.setView([20, 0], 2);
|
|
} else {
|
|
const group = L.featureGroup(
|
|
current.map((m) => {
|
|
const marker = L.marker([m.lat, m.lng], {
|
|
icon: L.divIcon({
|
|
className: '',
|
|
html: m.html,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
popupAnchor: [0, -20],
|
|
}),
|
|
});
|
|
if (m.popupHtml) marker.bindPopup(m.popupHtml, { maxWidth: 260, minWidth: 200 });
|
|
return marker;
|
|
}),
|
|
).addTo(map);
|
|
|
|
if (line && current.length > 1) {
|
|
const accent =
|
|
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
|
|
'#e60012';
|
|
L.polyline(
|
|
current.map((m) => [m.lat, m.lng] as [number, number]),
|
|
{ color: accent, weight: 2, opacity: 0.75, dashArray: '4 6' },
|
|
).addTo(map);
|
|
}
|
|
|
|
map.fitBounds(group.getBounds().pad(0.15), fitMaxZoom ? { maxZoom: fitMaxZoom } : {});
|
|
}
|
|
|
|
// Force Leaflet to recalculate the tile grid once laid out
|
|
setTimeout(() => map.invalidateSize(), 100);
|
|
onReadyRef.current?.();
|
|
}
|
|
|
|
init().catch((err) => {
|
|
console.error('[Map] Init error:', err);
|
|
if (!cancelled) onErrorRef.current?.(err);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (mapRef.current) {
|
|
mapRef.current.remove();
|
|
mapRef.current = null;
|
|
}
|
|
};
|
|
}, [markersKey, line, fitMaxZoom]);
|
|
|
|
return (
|
|
<>
|
|
<link
|
|
rel="stylesheet"
|
|
href={LEAFLET_CSS_HREF}
|
|
integrity={LEAFLET_CSS_INTEGRITY}
|
|
crossOrigin=""
|
|
/>
|
|
<div ref={containerRef} className={className} />
|
|
</>
|
|
);
|
|
}
|