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

View file

@ -0,0 +1,88 @@
/**
* Compass heading provider using DeviceOrientationEvent.
*
* Handles platform differences:
* - iOS Safari: requires requestPermission(), uses webkitCompassHeading
* - Android Chrome: uses deviceorientationabsolute, heading = (360 - alpha)
*
* Applies a low-pass filter for smooth rotation.
*/
export type HeadingCallback = (heading: number) => void;
const SMOOTHING = 0.3; // Low-pass filter coefficient (0..1, lower = smoother)
let currentHeading = 0;
let callback: HeadingCallback | null = null;
let listening = false;
function handleOrientation(event: DeviceOrientationEvent): void {
let heading: number | null = null;
// iOS Safari provides webkitCompassHeading directly
if ('webkitCompassHeading' in event) {
heading = (event as DeviceOrientationEvent & { webkitCompassHeading: number })
.webkitCompassHeading;
} else if (event.alpha != null) {
// Android: alpha is counterclockwise from north for absolute orientation
heading = (360 - event.alpha) % 360;
}
if (heading == null || callback == null) return;
// Low-pass filter for smooth rotation
// Handle wraparound at 0/360 boundary
let delta = heading - currentHeading;
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
currentHeading = (currentHeading + delta * SMOOTHING + 360) % 360;
callback(currentHeading);
}
/**
* Request compass permission (required on iOS 13+).
* Must be called from a user gesture handler.
* Returns true if permission was granted or not needed.
*/
export async function requestPermission(): Promise<boolean> {
const DOE = DeviceOrientationEvent as unknown as {
requestPermission?: () => Promise<string>;
};
if (typeof DOE.requestPermission === 'function') {
try {
const result = await DOE.requestPermission();
return result === 'granted';
} catch {
return false;
}
}
// Permission not required (Android, desktop)
return true;
}
/** Start listening for compass heading changes. */
export function startCompass(onHeading: HeadingCallback): void {
callback = onHeading;
if (listening) return;
listening = true;
// Prefer absolute orientation (Android), fall back to standard
const eventName = 'ondeviceorientationabsolute' in window
? 'deviceorientationabsolute'
: 'deviceorientation';
window.addEventListener(eventName as 'deviceorientation', handleOrientation);
}
/** Stop listening for compass updates. */
export function stopCompass(): void {
listening = false;
callback = null;
window.removeEventListener(
'deviceorientationabsolute' as unknown as 'deviceorientation',
handleOrientation,
);
window.removeEventListener('deviceorientation', handleOrientation);
}

View file

@ -0,0 +1,49 @@
/**
* Geolocation wrapper using navigator.geolocation.watchPosition.
* Provides a callback-based API for continuous location updates.
*/
import type { LatLon } from '../types';
export type LocationCallback = (location: LatLon) => void;
export type ErrorCallback = (error: GeolocationPositionError) => void;
let watchId: number | null = null;
/** Start watching the user's location. */
export function startWatching(
onLocation: LocationCallback,
onError?: ErrorCallback,
): void {
if (watchId !== null) stopWatching();
watchId = navigator.geolocation.watchPosition(
(pos) => {
onLocation({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
},
(err) => {
onError?.(err);
},
{
enableHighAccuracy: true,
maximumAge: 10_000,
timeout: 30_000,
},
);
}
/** Stop watching location. */
export function stopWatching(): void {
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
watchId = null;
}
}
/** Check if geolocation is available. */
export function isGeolocationAvailable(): boolean {
return 'geolocation' in navigator;
}

View file

@ -0,0 +1,37 @@
/**
* Finds the N nearest shelters to a given location.
* Ported from ShelterFinder.kt in the Android app.
*/
import type { Shelter, ShelterWithDistance } from '../types';
import { distanceMeters, bearingDegrees } from '../util/distance-utils';
/**
* Find the N nearest shelters to the given location.
* Returns results sorted by distance (nearest first).
*/
export function findNearest(
shelters: Shelter[],
latitude: number,
longitude: number,
count = 3,
): ShelterWithDistance[] {
return shelters
.map((shelter) => ({
shelter,
distanceMeters: distanceMeters(
latitude,
longitude,
shelter.latitude,
shelter.longitude,
),
bearingDegrees: bearingDegrees(
latitude,
longitude,
shelter.latitude,
shelter.longitude,
),
}))
.sort((a, b) => a.distanceMeters - b.distanceMeters)
.slice(0, count);
}