/** * 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: ` T `, iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -28], }); const selectedIcon = L.divIcon({ className: 'shelter-marker selected', html: ` T `, 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 = []; }