96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
/**
|
|||
|
|
* LeafletHeroMapInner
|
||
|
|
* Minimal single-school map for the detail hero. Renders as a static preview
|
||
|
|
* (all interaction disabled so it never traps page scroll); when `interactive`
|
||
|
|
* flips true — i.e. the hero map has gone fullscreen — pan/zoom are enabled and
|
||
|
|
* a zoom control appears.
|
||
|
|
*/
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useRef } from 'react';
|
||
|
|
import L from 'leaflet';
|
||
|
|
import 'leaflet/dist/leaflet.css';
|
||
|
|
|
||
|
|
interface LeafletHeroMapInnerProps {
|
||
|
|
lat: number;
|
||
|
|
lng: number;
|
||
|
|
interactive: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Coral teardrop pin with a soft halo — inline styles only, so it renders
|
||
|
|
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||
|
|
const PIN_HTML = `
|
||
|
|
<div style="position:relative;width:26px;height:26px">
|
||
|
|
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
|
||
|
|
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
|
||
|
|
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
|
||
|
|
<circle cx="13" cy="13" r="5" fill="#fff"/>
|
||
|
|
</svg>
|
||
|
|
</div>`;
|
||
|
|
|
||
|
|
export default function LeafletHeroMapInner({ lat, lng, interactive }: LeafletHeroMapInnerProps) {
|
||
|
|
const elRef = useRef<HTMLDivElement>(null);
|
||
|
|
const mapRef = useRef<L.Map | null>(null);
|
||
|
|
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||
|
|
|
||
|
|
// Create the map once.
|
||
|
|
useEffect(() => {
|
||
|
|
if (!elRef.current || mapRef.current) return;
|
||
|
|
const map = L.map(elRef.current, {
|
||
|
|
zoomControl: false,
|
||
|
|
attributionControl: true,
|
||
|
|
dragging: false,
|
||
|
|
scrollWheelZoom: false,
|
||
|
|
doubleClickZoom: false,
|
||
|
|
boxZoom: false,
|
||
|
|
keyboard: false,
|
||
|
|
touchZoom: false,
|
||
|
|
}).setView([lat, lng], 15);
|
||
|
|
|
||
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
|
|
maxZoom: 19,
|
||
|
|
}).addTo(map);
|
||
|
|
|
||
|
|
L.marker([lat, lng], {
|
||
|
|
icon: L.divIcon({ className: '', iconSize: [26, 34], iconAnchor: [13, 30], html: PIN_HTML }),
|
||
|
|
keyboard: false,
|
||
|
|
}).addTo(map);
|
||
|
|
|
||
|
|
mapRef.current = map;
|
||
|
|
setTimeout(() => map.invalidateSize(), 60);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
map.remove();
|
||
|
|
mapRef.current = null;
|
||
|
|
zoomCtrlRef.current = null;
|
||
|
|
};
|
||
|
|
}, [lat, lng]);
|
||
|
|
|
||
|
|
// Toggle interaction (and the zoom control) when entering/leaving fullscreen,
|
||
|
|
// and re-measure since the container size changes.
|
||
|
|
useEffect(() => {
|
||
|
|
const map = mapRef.current;
|
||
|
|
if (!map) return;
|
||
|
|
|
||
|
|
const handlers = [
|
||
|
|
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||
|
|
];
|
||
|
|
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||
|
|
|
||
|
|
if (interactive && !zoomCtrlRef.current) {
|
||
|
|
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||
|
|
zoomCtrlRef.current.addTo(map);
|
||
|
|
} else if (!interactive && zoomCtrlRef.current) {
|
||
|
|
zoomCtrlRef.current.remove();
|
||
|
|
zoomCtrlRef.current = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!interactive) map.setView([lat, lng], 15);
|
||
|
|
setTimeout(() => map.invalidateSize(), 80);
|
||
|
|
}, [interactive, lat, lng]);
|
||
|
|
|
||
|
|
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||
|
|
}
|