/**
* Leaflet map component with shelter markers and user location.
*
* Tracks user interaction: if the user manually pans or zooms, auto-fitting
* is suppressed until they explicitly reset the view.
*/
import L from 'leaflet';
import type { Shelter, ShelterWithDistance, LatLon } from '../types';
import { t } from '../i18n/i18n';
// Fix Leaflet default icon paths (broken by bundlers)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
const DEFAULT_ZOOM = 14;
const DEFAULT_CENTER: L.LatLngExpression = [59.9, 10.7]; // Central Norway
const shelterIcon = L.divIcon({
className: 'shelter-marker',
html: ``,
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -28],
});
const selectedIcon = L.divIcon({
className: 'shelter-marker selected',
html: ``,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
});
let map: L.Map | null = null;
let userMarker: L.CircleMarker | null = null;
let shelterMarkers: L.Marker[] = [];
let selectedMarkerId: string | null = null;
// Track whether user has manually interacted with the map
let userHasInteracted = false;
// Callbacks
let onShelterSelect: ((shelter: Shelter) => void) | null = null;
/** Initialize the Leaflet map in the given container. */
export function initMap(
container: HTMLElement,
onSelect: (shelter: Shelter) => void,
): L.Map {
onShelterSelect = onSelect;
map = L.map(container, {
zoomControl: true,
attributionControl: true,
}).setView(DEFAULT_CENTER, DEFAULT_ZOOM);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'© OpenStreetMap',
maxZoom: 19,
}).addTo(map);
// Track user interaction (pan/zoom by hand)
map.on('dragstart', () => {
userHasInteracted = true;
});
map.on('zoomstart', (e: L.LeafletEvent) => {
// Only flag as user interaction if it's not programmatic
// Leaflet doesn't distinguish, so we use a flag set before programmatic calls
if (!programmaticMove) {
userHasInteracted = true;
}
});
return map;
}
// Flag to distinguish programmatic moves from user moves
let programmaticMove = false;
/** Update the user's location marker on the map. */
export function updateUserLocation(location: LatLon): void {
if (!map) return;
const latlng = L.latLng(location.latitude, location.longitude);
if (userMarker) {
userMarker.setLatLng(latlng);
} else {
userMarker = L.circleMarker(latlng, {
radius: 8,
fillColor: '#4285F4',
fillOpacity: 1,
color: '#fff',
weight: 3,
}).addTo(map);
}
}
/** Add markers for all shelters. */
export function updateShelterMarkers(shelters: Shelter[]): void {
if (!map) return;
// Remove old markers
for (const m of shelterMarkers) {
map.removeLayer(m);
}
shelterMarkers = [];
selectedMarkerId = null;
for (const shelter of shelters) {
const marker = L.marker([shelter.latitude, shelter.longitude], {
icon: shelterIcon,
})
.bindPopup(
`${shelter.adresse}
${t('shelter_capacity', shelter.plasser)} · ${t('shelter_room_nr', shelter.romnr)}`,
)
.on('click', () => {
onShelterSelect?.(shelter);
});
marker.addTo(map);
// Store shelter ID on the marker for highlighting
(marker as L.Marker & { _shelterLokalId: string })._shelterLokalId =
shelter.lokalId;
shelterMarkers.push(marker);
}
}
/** Highlight the selected shelter and optionally fit the view. */
export function selectShelter(
selected: ShelterWithDistance,
userLocation: LatLon | null,
): void {
if (!map) return;
// Update marker icons
for (const m of shelterMarkers) {
const mid = (m as L.Marker & { _shelterLokalId: string })._shelterLokalId;
if (mid === selected.shelter.lokalId) {
m.setIcon(selectedIcon);
selectedMarkerId = mid;
} else if (mid === selectedMarkerId || selectedMarkerId === null) {
m.setIcon(shelterIcon);
}
}
// Auto-fit view to show user + shelter, unless user has manually panned/zoomed
if (!userHasInteracted) {
fitToShelter(selected, userLocation);
}
}
/** Fit the map view to show both user location and selected shelter. */
export function fitToShelter(
selected: ShelterWithDistance,
userLocation: LatLon | null,
): void {
if (!map) return;
programmaticMove = true;
const shelterLatLng = L.latLng(
selected.shelter.latitude,
selected.shelter.longitude,
);
if (userLocation) {
const userLatLng = L.latLng(userLocation.latitude, userLocation.longitude);
const bounds = L.latLngBounds([userLatLng, shelterLatLng]);
map.fitBounds(bounds.pad(0.3), { animate: true });
} else {
map.setView(shelterLatLng, DEFAULT_ZOOM, { animate: true });
}
// Reset the flag after the animation completes
setTimeout(() => {
programmaticMove = false;
}, 500);
}
/**
* Reset the view: clear user interaction flag and re-fit to
* show user + selected shelter.
*/
export function resetView(
selected: ShelterWithDistance | null,
userLocation: LatLon | null,
): void {
userHasInteracted = false;
if (selected) {
fitToShelter(selected, userLocation);
} else if (userLocation) {
programmaticMove = true;
map?.setView(
L.latLng(userLocation.latitude, userLocation.longitude),
DEFAULT_ZOOM,
{ animate: true },
);
setTimeout(() => {
programmaticMove = false;
}, 500);
}
}
/** Get the Leaflet map instance (for cache manager). */
export function getMap(): L.Map | null {
return map;
}
/** Destroy the map. */
export function destroyMap(): void {
map?.remove();
map = null;
userMarker = null;
shelterMarkers = [];
}