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
88
pwa/src/location/compass-provider.ts
Normal file
88
pwa/src/location/compass-provider.ts
Normal 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);
|
||||
}
|
||||
49
pwa/src/location/location-provider.ts
Normal file
49
pwa/src/location/location-provider.ts
Normal 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;
|
||||
}
|
||||
37
pwa/src/location/shelter-finder.ts
Normal file
37
pwa/src/location/shelter-finder.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue