228 lines
6.4 KiB
TypeScript
228 lines
6.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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: `<svg viewBox="0 0 24 24" width="28" height="28">
|
||
|
|
<path d="M12 2L2 12h3v8h14v-8h3L12 2z" fill="#FF6B35" stroke="#fff" stroke-width="1.5"/>
|
||
|
|
<text x="12" y="17" text-anchor="middle" fill="#fff" font-size="8" font-weight="bold">T</text>
|
||
|
|
</svg>`,
|
||
|
|
iconSize: [28, 28],
|
||
|
|
iconAnchor: [14, 28],
|
||
|
|
popupAnchor: [0, -28],
|
||
|
|
});
|
||
|
|
|
||
|
|
const selectedIcon = L.divIcon({
|
||
|
|
className: 'shelter-marker selected',
|
||
|
|
html: `<svg viewBox="0 0 24 24" width="36" height="36">
|
||
|
|
<path d="M12 2L2 12h3v8h14v-8h3L12 2z" fill="#FFC107" stroke="#fff" stroke-width="1.5"/>
|
||
|
|
<text x="12" y="17" text-anchor="middle" fill="#1A1A2E" font-size="8" font-weight="bold">T</text>
|
||
|
|
</svg>`,
|
||
|
|
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:
|
||
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||
|
|
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(
|
||
|
|
`<strong>${shelter.adresse}</strong><br>${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 = [];
|
||
|
|
}
|