release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
150
components/LeafletMap.tsx
Normal file
150
components/LeafletMap.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user