Add progressive web app companion for cross-platform access
Vite + TypeScript PWA that mirrors the Android app's core features: - Pre-processed shelter data (build-time UTM33N→WGS84 conversion) - Leaflet map with shelter markers, user location, and offline tiles - Canvas compass arrow (ported from DirectionArrowView.kt) - IndexedDB shelter cache with 7-day staleness check - Service worker with CacheFirst tiles and precached app shell - i18n for en, nb, nn (ported from Android strings.xml) - iOS/Android compass handling with low-pass filter - Respects user map interaction (no auto-snap on pan/zoom) - Build revision cache-breaker for reliable SW updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46365b713b
commit
e8428de775
12051 changed files with 1799735 additions and 0 deletions
228
pwa/src/ui/map-view.ts
Normal file
228
pwa/src/ui/map-view.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* 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 = [];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue