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:
Ole-Morten Duesund 2026-03-08 17:41:38 +01:00
commit e8428de775
12051 changed files with 1799735 additions and 0 deletions

228
pwa/src/ui/map-view.ts Normal file
View 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:
'&copy; <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)} &middot; ${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 = [];
}