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

375
pwa/src/app.ts Normal file
View file

@ -0,0 +1,375 @@
/**
* Main app controller wires together all components.
* Ported from MainActivity.kt.
*
* Key UX decision: selecting a shelter (initial or alternate) auto-fits the
* map to show both user and shelter. Manual pan/zoom overrides this. A "reset
* view" button re-fits when the user wants to return.
*/
import type { Shelter, ShelterWithDistance, LatLon } from './types';
import { t } from './i18n/i18n';
import { formatDistance } from './util/distance-utils';
import { findNearest } from './location/shelter-finder';
import * as repo from './data/shelter-repository';
import * as locationProvider from './location/location-provider';
import * as compassProvider from './location/compass-provider';
import * as mapView from './ui/map-view';
import * as compassView from './ui/compass-view';
import * as shelterList from './ui/shelter-list';
import * as statusBar from './ui/status-bar';
import * as loading from './ui/loading-overlay';
import * as mapCache from './cache/map-cache-manager';
const NEAREST_COUNT = 3;
let allShelters: Shelter[] = [];
let nearestShelters: ShelterWithDistance[] = [];
let selectedShelterIndex = 0;
let currentLocation: LatLon | null = null;
let deviceHeading = 0;
let isCompassMode = false;
let firstLocationFix = true;
// Track whether user manually selected a shelter (prevents auto-reselection
// on location updates)
let userSelectedShelter = false;
export async function init(): Promise<void> {
setupMap();
setupCompass();
setupShelterList();
setupButtons();
await loadData();
}
function setupMap(): void {
const container = document.getElementById('map-container')!;
mapView.initMap(container, (shelter: Shelter) => {
// Marker click — select this shelter
const idx = nearestShelters.findIndex(
(s) => s.shelter.lokalId === shelter.lokalId,
);
if (idx >= 0) {
userSelectedShelter = true;
selectedShelterIndex = idx;
updateSelectedShelter(true);
}
});
}
function setupCompass(): void {
const container = document.getElementById('compass-container')!;
compassView.initCompass(container);
}
function setupShelterList(): void {
const container = document.getElementById('shelter-list')!;
shelterList.initShelterList(container, (index: number) => {
userSelectedShelter = true;
selectedShelterIndex = index;
updateSelectedShelter(true);
});
}
function setupButtons(): void {
// Toggle map/compass
const toggleFab = document.getElementById('toggle-fab')!;
toggleFab.addEventListener('click', async () => {
isCompassMode = !isCompassMode;
const mapContainer = document.getElementById('map-container')!;
const compassContainer = document.getElementById('compass-container')!;
if (isCompassMode) {
// Request compass permission on first toggle (iOS requirement)
const granted = await compassProvider.requestPermission();
if (!granted) {
isCompassMode = false;
return;
}
mapContainer.style.display = 'none';
compassContainer.classList.add('active');
toggleFab.textContent = '\uD83D\uDDFA\uFE0F'; // map emoji
compassProvider.startCompass(onHeadingUpdate);
} else {
compassContainer.classList.remove('active');
mapContainer.style.display = 'block';
toggleFab.textContent = '\uD83E\uDDED'; // compass emoji
// Invalidate map size after showing
const map = mapView.getMap();
if (map) setTimeout(() => map.invalidateSize(), 100);
compassProvider.stopCompass();
}
});
// Refresh button
statusBar.onRefreshClick(forceRefresh);
// Cache retry button
const cacheRetryBtn = document.getElementById('cache-retry-btn')!;
cacheRetryBtn.textContent = t('action_cache_now');
cacheRetryBtn.addEventListener('click', () => {
if (currentLocation && navigator.onLine) {
startCaching(currentLocation.latitude, currentLocation.longitude);
}
});
// Reset view button
const resetBtn = document.getElementById('reset-view-btn')!;
resetBtn.addEventListener('click', () => {
const selected = nearestShelters[selectedShelterIndex] ?? null;
mapView.resetView(selected, currentLocation);
resetBtn.classList.remove('visible');
});
// Show reset button when user pans/zooms
const mapContainer = document.getElementById('map-container')!;
const map = mapView.getMap();
if (map) {
map.on('dragstart', showResetButton);
map.on('zoomstart', showResetButton);
}
// No-cache banner text
const noCacheText = document.getElementById('no-cache-text')!;
noCacheText.textContent = t('warning_no_map_cache');
}
function showResetButton(): void {
const btn = document.getElementById('reset-view-btn');
if (btn) btn.classList.add('visible');
}
async function loadData(): Promise<void> {
const hasData = await repo.hasCachedData();
if (!hasData) {
if (!navigator.onLine) {
statusBar.setStatus(t('error_no_data_offline'));
return;
}
loading.showLoading(t('loading_shelters'));
const success = await repo.refreshData();
loading.hideLoading();
if (!success) {
statusBar.setStatus(t('error_download_failed'));
return;
}
}
allShelters = await repo.getAllShelters();
statusBar.setStatus(t('status_shelters_loaded', allShelters.length));
mapView.updateShelterMarkers(allShelters);
// Start location
startLocationUpdates();
// Background refresh if stale
if (hasData && (await repo.isDataStale()) && navigator.onLine) {
const success = await repo.refreshData();
if (success) {
allShelters = await repo.getAllShelters();
statusBar.setStatus(t('update_success'));
mapView.updateShelterMarkers(allShelters);
if (currentLocation) updateNearestShelters(currentLocation);
}
}
}
function startLocationUpdates(): void {
if (!locationProvider.isGeolocationAvailable()) {
statusBar.setStatus(t('permission_denied'));
return;
}
statusBar.setStatus(t('status_no_location'));
locationProvider.startWatching(
(location: LatLon) => {
currentLocation = location;
mapView.updateUserLocation(location);
updateNearestShelters(location);
// Cache map on first fix
if (firstLocationFix) {
firstLocationFix = false;
if (
!mapCache.hasCacheForLocation(location.latitude, location.longitude) &&
navigator.onLine
) {
promptMapCache(location.latitude, location.longitude);
}
}
},
() => {
statusBar.setStatus(t('permission_denied'));
},
);
}
function updateNearestShelters(location: LatLon): void {
if (allShelters.length === 0) return;
nearestShelters = findNearest(
allShelters,
location.latitude,
location.longitude,
NEAREST_COUNT,
);
// Only auto-select the nearest shelter if the user hasn't manually selected one
if (!userSelectedShelter) {
selectedShelterIndex = 0;
}
shelterList.updateList(nearestShelters, selectedShelterIndex);
updateSelectedShelter(false);
statusBar.setStatus(t('status_shelters_loaded', allShelters.length));
}
/**
* Update all UI to reflect the currently selected shelter.
* @param isUserAction Whether this was triggered by user shelter selection
* (if true, we auto-fit the map; if false, only auto-fit when not panned)
*/
function updateSelectedShelter(isUserAction: boolean): void {
if (nearestShelters.length === 0) return;
const selected = nearestShelters[selectedShelterIndex];
if (!selected) return;
const dist = formatDistance(selected.distanceMeters);
// Update bottom sheet
const addrEl = document.getElementById('selected-shelter-address')!;
const detailsEl = document.getElementById('selected-shelter-details')!;
addrEl.textContent = selected.shelter.adresse;
detailsEl.textContent = [
dist,
t('shelter_capacity', selected.shelter.plasser),
t('shelter_room_nr', selected.shelter.romnr),
].join(' \u00B7 ');
// Update mini arrow
updateMiniArrow(selected.bearingDegrees - deviceHeading);
// Update compass view
document.getElementById('compass-distance')!.textContent = dist;
document.getElementById('compass-address')!.textContent =
selected.shelter.adresse;
compassView.setDirection(selected.bearingDegrees - deviceHeading);
// Update shelter list selection
shelterList.updateList(nearestShelters, selectedShelterIndex);
// Update map: highlight selected and optionally fit view
if (isUserAction) {
// User explicitly selected a shelter — fit view to show it
mapView.resetView(selected, currentLocation);
// Hide the reset button since we just fit the view
document.getElementById('reset-view-btn')?.classList.remove('visible');
}
mapView.selectShelter(selected, currentLocation);
}
function onHeadingUpdate(heading: number): void {
deviceHeading = heading;
if (nearestShelters.length === 0) return;
const selected = nearestShelters[selectedShelterIndex];
const angle = selected.bearingDegrees - heading;
compassView.setDirection(angle);
updateMiniArrow(angle);
}
/** Draw the mini direction arrow in the bottom sheet. */
function updateMiniArrow(angleDeg: number): void {
const canvas = document.getElementById('mini-arrow') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const size = Math.min(w, h) * 0.4;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.beginPath();
ctx.moveTo(0, -size);
ctx.lineTo(size * 0.5, size * 0.3);
ctx.lineTo(size * 0.15, size * 0.1);
ctx.lineTo(size * 0.15, size * 0.7);
ctx.lineTo(-size * 0.15, size * 0.7);
ctx.lineTo(-size * 0.15, size * 0.1);
ctx.lineTo(-size * 0.5, size * 0.3);
ctx.closePath();
ctx.fillStyle = '#FF6B35';
ctx.fill();
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
function promptMapCache(lat: number, lon: number): void {
loading.showCachePrompt(
t('loading_map_explanation'),
() => startCaching(lat, lon),
() => {
// User skipped — show warning banner
document.getElementById('no-cache-banner')?.classList.add('visible');
},
);
}
async function startCaching(lat: number, lon: number): Promise<void> {
document.getElementById('no-cache-banner')?.classList.remove('visible');
loading.showLoading(t('loading_map'));
const map = mapView.getMap();
if (!map) {
loading.hideLoading();
return;
}
await mapCache.cacheMapArea(map, lat, lon, (progress) => {
loading.updateLoadingText(
`${t('loading_map')} (${Math.round(progress * 100)}%)`,
);
});
loading.hideLoading();
statusBar.setStatus(t('status_shelters_loaded', allShelters.length));
}
async function forceRefresh(): Promise<void> {
if (!navigator.onLine) {
statusBar.setStatus(t('error_download_failed'));
return;
}
statusBar.setStatus(t('status_updating'));
const success = await repo.refreshData();
if (success) {
allShelters = await repo.getAllShelters();
mapView.updateShelterMarkers(allShelters);
if (currentLocation) updateNearestShelters(currentLocation);
statusBar.setStatus(t('update_success'));
} else {
statusBar.setStatus(t('update_failed'));
}
}

112
pwa/src/cache/map-cache-manager.ts vendored Normal file
View file

@ -0,0 +1,112 @@
/**
* Map tile cache manager.
* Seeds tiles by programmatically panning a Leaflet map across a grid.
* Tiles are cached by the service worker (CacheFirst strategy on OSM URLs).
*
* Ported from MapCacheManager.kt same 3×3 grid × 4 zoom levels approach.
*/
import type L from 'leaflet';
const CACHE_RADIUS_DEGREES = 0.15; // ~15km
const CACHE_ZOOM_LEVELS = [10, 12, 14, 16];
const GRID_SIZE = 3;
const PAN_DELAY_MS = 400; // Slightly longer than Android (300ms) for web
const STORAGE_KEY = 'mapCache';
interface CacheMeta {
lat: number;
lon: number;
radius: number;
complete: boolean;
}
function getCacheMeta(): CacheMeta | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function saveCacheMeta(meta: CacheMeta): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(meta));
}
/** Check if we have a tile cache covering the given location. */
export function hasCacheForLocation(lat: number, lon: number): boolean {
const meta = getCacheMeta();
if (!meta?.complete) return false;
const margin = meta.radius * 0.3;
return (
Math.abs(lat - meta.lat) < meta.radius - margin &&
Math.abs(lon - meta.lon) < meta.radius - margin
);
}
/**
* Seed the tile cache by panning the map across the surrounding area.
* The service worker's CacheFirst strategy on OSM tile URLs handles storage.
*
* Reports progress via callback (0 to 1).
*/
export async function cacheMapArea(
map: L.Map,
lat: number,
lon: number,
onProgress?: (progress: number) => void,
): Promise<boolean> {
try {
const totalSteps = CACHE_ZOOM_LEVELS.length * GRID_SIZE * GRID_SIZE;
let step = 0;
const originalZoom = map.getZoom();
const originalCenter = map.getCenter();
for (const zoom of CACHE_ZOOM_LEVELS) {
map.setZoom(zoom);
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const panLat =
lat -
CACHE_RADIUS_DEGREES +
(2 * CACHE_RADIUS_DEGREES * row) / (GRID_SIZE - 1);
const panLon =
lon -
CACHE_RADIUS_DEGREES +
(2 * CACHE_RADIUS_DEGREES * col) / (GRID_SIZE - 1);
map.setView([panLat, panLon], zoom, { animate: false });
map.invalidateSize();
step++;
onProgress?.(step / totalSteps);
await sleep(PAN_DELAY_MS);
}
}
}
// Restore original view
map.setView(originalCenter, originalZoom, { animate: false });
saveCacheMeta({
lat,
lon,
radius: CACHE_RADIUS_DEGREES,
complete: true,
});
return true;
} catch {
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -0,0 +1,73 @@
/**
* IndexedDB wrapper for shelter data using the idb library.
* Ported from Room database in the Android app.
*/
import { openDB, type IDBPDatabase } from 'idb';
import type { Shelter } from '../types';
const DB_NAME = 'tilfluktsrom';
const DB_VERSION = 1;
const SHELTER_STORE = 'shelters';
const META_STORE = 'metadata';
const META_KEY_LAST_UPDATE = 'lastUpdate';
type TilfluktsromDB = IDBPDatabase;
let dbPromise: Promise<TilfluktsromDB> | null = null;
function getDb(): Promise<TilfluktsromDB> {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(SHELTER_STORE)) {
db.createObjectStore(SHELTER_STORE, { keyPath: 'lokalId' });
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE);
}
},
});
}
return dbPromise;
}
/** Get all cached shelters. */
export async function getAllShelters(): Promise<Shelter[]> {
const db = await getDb();
return db.getAll(SHELTER_STORE);
}
/** Check if any shelter data is cached. */
export async function hasCachedData(): Promise<boolean> {
const db = await getDb();
const count = await db.count(SHELTER_STORE);
return count > 0;
}
/** Replace all shelter data (clear + insert). */
export async function replaceShelters(shelters: Shelter[]): Promise<void> {
const db = await getDb();
const tx = db.transaction(SHELTER_STORE, 'readwrite');
await tx.store.clear();
for (const shelter of shelters) {
await tx.store.put(shelter);
}
await tx.done;
// Update last-update timestamp
const metaTx = db.transaction(META_STORE, 'readwrite');
await metaTx.store.put(Date.now(), META_KEY_LAST_UPDATE);
await metaTx.done;
}
/** Check if cached data is older than the given max age (default 7 days). */
export async function isDataStale(
maxAgeMs = 7 * 24 * 60 * 60 * 1000,
): Promise<boolean> {
const db = await getDb();
const lastUpdate = await db.get(META_STORE, META_KEY_LAST_UPDATE);
if (lastUpdate == null) return true;
return Date.now() - (lastUpdate as number) > maxAgeMs;
}

View file

@ -0,0 +1,33 @@
/**
* Repository that manages shelter data: fetches pre-processed JSON,
* caches in IndexedDB, and handles staleness checks.
*
* Unlike the Android app, no ZIP handling or coordinate conversion
* is needed at runtime the data is pre-processed at build time.
*/
import type { Shelter } from '../types';
import {
getAllShelters,
hasCachedData,
replaceShelters,
isDataStale,
} from './shelter-db';
const SHELTERS_JSON_PATH = './data/shelters.json';
/** Fetch shelters.json and cache in IndexedDB. Returns success. */
export async function refreshData(): Promise<boolean> {
try {
const response = await fetch(SHELTERS_JSON_PATH);
if (!response.ok) return false;
const shelters: Shelter[] = await response.json();
await replaceShelters(shelters);
return true;
} catch {
return false;
}
}
export { getAllShelters, hasCachedData, isDataStale };

49
pwa/src/i18n/en.ts Normal file
View file

@ -0,0 +1,49 @@
/** English strings — default locale. Ported from res/values/strings.xml. */
export const en: Record<string, string> = {
app_name: 'Tilfluktsrom',
// Status
status_ready: 'Ready',
status_loading: 'Loading shelter data\u2026',
status_updating: 'Updating\u2026',
status_offline: 'Offline mode',
status_shelters_loaded: '%d shelters loaded',
status_no_location: 'Waiting for GPS\u2026',
status_caching_map: 'Caching map for offline use\u2026',
// Loading
loading_shelters: 'Downloading shelter data\u2026',
loading_map: 'Caching map tiles\u2026',
loading_map_explanation:
'Preparing offline map.\nThe map will scroll briefly to cache your surroundings.',
loading_first_time: 'Setting up for first use\u2026',
// Shelter info
shelter_capacity: '%d places',
shelter_room_nr: 'Room %d',
nearest_shelter: 'Nearest shelter',
no_shelters: 'No shelter data available',
// Actions
action_refresh: 'Refresh data',
action_toggle_view: 'Toggle map/compass view',
action_skip: 'Skip',
action_cache_ok: 'Cache map',
action_cache_now: 'Cache now',
warning_no_map_cache: 'No offline map cached. Map requires internet.',
// Permissions
permission_location_title: 'Location permission required',
permission_location_message:
'This app needs your location to find the nearest shelter. Please grant location access.',
permission_denied:
'Location permission denied. The app cannot find nearby shelters without it.',
// Errors
error_download_failed:
'Could not download shelter data. Check your internet connection.',
error_no_data_offline:
'No cached data available. Connect to the internet to download shelter data.',
update_success: 'Shelter data updated',
update_failed: 'Update failed \u2014 using cached data',
};

49
pwa/src/i18n/i18n.ts Normal file
View file

@ -0,0 +1,49 @@
/**
* Minimal i18n system: detects locale from navigator.languages,
* provides t(key, ...args) for string lookup with %d/%s substitution.
*/
import { en } from './en';
import { nb } from './nb';
import { nn } from './nn';
const locales: Record<string, Record<string, string>> = { en, nb, nn };
let currentLocale = 'en';
/** Detect and set locale from browser preferences. */
export function initLocale(): void {
const langs = navigator.languages ?? [navigator.language];
for (const lang of langs) {
const code = lang.toLowerCase().split('-')[0];
if (code in locales) {
currentLocale = code;
return;
}
// nb and nn both start with "n" — also match "no" as Bokmål
if (code === 'no') {
currentLocale = 'nb';
return;
}
}
}
/** Get current locale code. */
export function getLocale(): string {
return currentLocale;
}
/**
* Translate a string key, substituting %d and %s with provided arguments.
* Falls back to English, then to the raw key.
*/
export function t(key: string, ...args: (string | number)[]): string {
let str = locales[currentLocale]?.[key] ?? locales.en[key] ?? key;
// Replace %d and %s placeholders sequentially
for (const arg of args) {
str = str.replace(/%[ds]/, String(arg));
}
return str;
}

44
pwa/src/i18n/nb.ts Normal file
View file

@ -0,0 +1,44 @@
/** Norwegian Bokm\u00e5l strings. Ported from res/values-nb/strings.xml. */
export const nb: Record<string, string> = {
app_name: 'Tilfluktsrom',
status_ready: 'Klar',
status_loading: 'Laster tilfluktsromdata\u2026',
status_updating: 'Oppdaterer\u2026',
status_offline: 'Frakoblet modus',
status_shelters_loaded: '%d tilfluktsrom lastet',
status_no_location: 'Venter p\u00e5 GPS\u2026',
status_caching_map: 'Lagrer kart for frakoblet bruk\u2026',
loading_shelters: 'Laster ned tilfluktsromdata\u2026',
loading_map: 'Lagrer kartfliser\u2026',
loading_map_explanation:
'Forbereder frakoblet kart.\nKartet vil rulle kort for \u00e5 lagre omgivelsene dine.',
loading_first_time: 'Gj\u00f8r klar for f\u00f8rste gangs bruk\u2026',
shelter_capacity: '%d plasser',
shelter_room_nr: 'Rom %d',
nearest_shelter: 'N\u00e6rmeste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengelig',
action_refresh: 'Oppdater data',
action_toggle_view: 'Bytt mellom kart og kompassvisning',
action_skip: 'Hopp over',
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre n\u00e5',
warning_no_map_cache:
'Ingen frakoblet kart lagret. Kartet krever internett.',
permission_location_title: 'Posisjonstillatelse kreves',
permission_location_message:
'Denne appen trenger din posisjon for \u00e5 finne n\u00e6rmeste tilfluktsrom. Vennligst gi tilgang til posisjon.',
permission_denied:
'Posisjonstillatelse avsl\u00e5tt. Appen kan ikke finne tilfluktsrom i n\u00e6rheten uten den.',
error_download_failed:
'Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.',
error_no_data_offline:
'Ingen lagrede data tilgjengelig. Koble til internett for \u00e5 laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislyktes \u2014 bruker lagrede data',
};

44
pwa/src/i18n/nn.ts Normal file
View file

@ -0,0 +1,44 @@
/** Norwegian Nynorsk strings. Ported from res/values-nn/strings.xml. */
export const nn: Record<string, string> = {
app_name: 'Tilfluktsrom',
status_ready: 'Klar',
status_loading: 'Lastar tilfluktsromdata\u2026',
status_updating: 'Oppdaterer\u2026',
status_offline: 'Fr\u00e5kopla modus',
status_shelters_loaded: '%d tilfluktsrom lasta',
status_no_location: 'Ventar p\u00e5 GPS\u2026',
status_caching_map: 'Lagrar kart for fr\u00e5kopla bruk\u2026',
loading_shelters: 'Lastar ned tilfluktsromdata\u2026',
loading_map: 'Lagrar kartfliser\u2026',
loading_map_explanation:
'F\u00f8rebur fr\u00e5kopla kart.\nKartet vil rulle kort for \u00e5 lagre omgjevnadene dine.',
loading_first_time: 'Gjer klar for fyrste gongs bruk\u2026',
shelter_capacity: '%d plassar',
shelter_room_nr: 'Rom %d',
nearest_shelter: 'N\u00e6raste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengeleg',
action_refresh: 'Oppdater data',
action_toggle_view: 'Byt mellom kart og kompassvising',
action_skip: 'Hopp over',
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre no',
warning_no_map_cache:
'Ingen fr\u00e5kopla kart lagra. Kartet krev internett.',
permission_location_title: 'Posisjonsløyve krevst',
permission_location_message:
'Denne appen treng posisjonen din for \u00e5 finne n\u00e6raste tilfluktsrom. Ver venleg og gje tilgang til posisjon.',
permission_denied:
'Posisjonsløyve avsl\u00e5tt. Appen kan ikkje finne tilfluktsrom i n\u00e6rleiken utan det.',
error_download_failed:
'Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.',
error_no_data_offline:
'Ingen lagra data tilgjengeleg. Kopla til internett for \u00e5 laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislukkast \u2014 brukar lagra data',
};

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);
}

36
pwa/src/main.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* Entry point: wait for DOM, initialize locale, boot the app.
*
* The __BUILD_REVISION__ constant is injected by Vite at build time and
* changes on every build. Combined with vite-plugin-pwa's autoUpdate
* registration, this ensures the service worker detects any new deployment
* and swaps in the fresh precache immediately.
*/
import './styles/main.css';
import 'leaflet/dist/leaflet.css';
import { initLocale } from './i18n/i18n';
import { init } from './app';
import { setStatus } from './ui/status-bar';
import { t } from './i18n/i18n';
console.info(`[tilfluktsrom] build ${__BUILD_REVISION__}`);
document.addEventListener('DOMContentLoaded', async () => {
initLocale();
// Request persistent storage (helps prevent iOS eviction)
if (navigator.storage?.persist) {
await navigator.storage.persist();
}
// Listen for service worker updates — flash a status message when a new
// version activates so the user knows they have fresh code/data.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
setStatus(t('update_success'));
});
}
await init();
});

374
pwa/src/styles/main.css Normal file
View file

@ -0,0 +1,374 @@
/**
* Dark emergency theme same colors as the Android app (colors.xml).
* Mobile-first responsive design.
*/
/* --- Reset & base --- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1A1A2E;
color: #ECEFF1;
-webkit-text-size-adjust: 100%;
}
/* --- App shell layout --- */
#app {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
/* --- Status bar --- */
#status-bar {
display: flex;
align-items: center;
background: #16213E;
padding: 6px 12px;
min-height: 36px;
flex-shrink: 0;
}
#status-text {
flex: 1;
color: #B0BEC5;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#refresh-btn {
background: none;
border: none;
color: #B0BEC5;
cursor: pointer;
padding: 4px;
font-size: 18px;
line-height: 1;
flex-shrink: 0;
}
#refresh-btn:hover {
color: #ECEFF1;
}
/* --- Main content area (map or compass) --- */
#main-content {
flex: 1;
position: relative;
min-height: 0;
}
#map-container {
width: 100%;
height: 100%;
}
#compass-container {
display: none;
width: 100%;
height: 100%;
background: #0F0F23;
position: relative;
}
#compass-container.active {
display: block;
}
#compass-distance {
position: absolute;
bottom: 32px;
left: 0;
right: 0;
text-align: center;
color: #FFFFFF;
font-size: 48px;
font-weight: bold;
pointer-events: none;
}
#compass-address {
position: absolute;
top: 24px;
left: 0;
right: 0;
text-align: center;
color: #FFFFFF;
font-size: 18px;
pointer-events: none;
}
/* --- Toggle FAB --- */
#toggle-fab {
position: absolute;
right: 16px;
bottom: 16px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #FF6B35;
border: none;
color: #FFFFFF;
font-size: 24px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
#toggle-fab:hover {
background: #E55A2B;
}
/* --- Reset view button --- */
#reset-view-btn {
position: absolute;
left: 16px;
bottom: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #16213E;
border: 2px solid #B0BEC5;
color: #B0BEC5;
font-size: 18px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
}
#reset-view-btn.visible {
display: flex;
}
#reset-view-btn:hover {
background: #1A1A2E;
color: #ECEFF1;
border-color: #ECEFF1;
}
/* --- No-cache warning banner --- */
#no-cache-banner {
display: none;
align-items: center;
background: #E65100;
padding: 6px 12px;
flex-shrink: 0;
}
#no-cache-banner.visible {
display: flex;
}
#no-cache-banner span {
flex: 1;
color: #FFFFFF;
font-size: 12px;
}
#cache-retry-btn {
background: none;
border: 1px solid #FFFFFF;
color: #FFFFFF;
font-size: 12px;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
}
/* --- Bottom sheet --- */
#bottom-sheet {
background: #1A1A2E;
padding: 12px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
/* Selected shelter summary */
#selected-shelter {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
#mini-arrow {
width: 48px;
height: 48px;
flex-shrink: 0;
}
#selected-shelter-info {
flex: 1;
min-width: 0;
}
#selected-shelter-address {
color: #ECEFF1;
font-size: 16px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#selected-shelter-details {
color: #90A4AE;
font-size: 13px;
margin-top: 2px;
}
/* Shelter list items */
#shelter-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.shelter-item {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.05);
border: 1px solid transparent;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
text-align: left;
width: 100%;
color: inherit;
font: inherit;
}
.shelter-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.shelter-item.selected {
border-color: #FF6B35;
background: rgba(255, 107, 53, 0.1);
}
.shelter-item-address {
color: #ECEFF1;
font-size: 14px;
font-weight: 500;
}
.shelter-item-details {
color: #90A4AE;
font-size: 12px;
margin-top: 2px;
}
/* --- Loading overlay --- */
#loading-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
}
#loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: #FF6B35;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#loading-text {
color: #FFFFFF;
font-size: 16px;
text-align: center;
padding: 0 32px;
white-space: pre-line;
}
#loading-button-row {
display: none;
gap: 12px;
margin-top: 4px;
}
#loading-button-row button {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
#loading-skip-btn {
background: transparent;
border: 1px solid #FFFFFF;
color: #FFFFFF;
}
#loading-ok-btn {
background: #FF6B35;
border: none;
color: #FFFFFF;
}
/* --- Shelter marker icons --- */
.shelter-marker {
background: none !important;
border: none !important;
}
/* --- Leaflet overrides for dark theme --- */
.leaflet-control-zoom a {
background: #16213E !important;
color: #ECEFF1 !important;
border-color: #2a2a4e !important;
}
.leaflet-control-attribution {
background: rgba(26, 26, 46, 0.8) !important;
color: #90A4AE !important;
font-size: 10px !important;
}
.leaflet-control-attribution a {
color: #B0BEC5 !important;
}
.leaflet-popup-content-wrapper {
background: #16213E !important;
color: #ECEFF1 !important;
border-radius: 8px !important;
}
.leaflet-popup-tip {
background: #16213E !important;
}
.leaflet-popup-close-button {
color: #B0BEC5 !important;
}

22
pwa/src/types.ts Normal file
View file

@ -0,0 +1,22 @@
/** A public shelter (tilfluktsrom) with WGS84 coordinates. */
export interface Shelter {
lokalId: string;
romnr: number;
plasser: number;
adresse: string;
latitude: number;
longitude: number;
}
/** A shelter annotated with distance and bearing from a reference point. */
export interface ShelterWithDistance {
shelter: Shelter;
distanceMeters: number;
bearingDegrees: number;
}
/** WGS84 latitude/longitude pair. */
export interface LatLon {
latitude: number;
longitude: number;
}

View file

@ -0,0 +1,89 @@
/**
* Canvas-based direction arrow pointing toward the selected shelter.
* Ported from DirectionArrowView.kt same 7-point arrow polygon.
*
* Arrow rotation = shelterBearing - deviceHeading
*/
const ARROW_COLOR = '#FF6B35';
const OUTLINE_COLOR = '#FFFFFF';
const OUTLINE_WIDTH = 4;
let canvas: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
let currentAngle = 0;
let animFrameId = 0;
/** Initialize the compass canvas inside the given container element. */
export function initCompass(container: HTMLElement): void {
canvas = document.createElement('canvas');
canvas.id = 'compass-canvas';
canvas.style.width = '100%';
canvas.style.height = '100%';
container.prepend(canvas);
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas(): void {
if (!canvas) return;
const rect = canvas.parentElement!.getBoundingClientRect();
canvas.width = rect.width * devicePixelRatio;
canvas.height = rect.height * devicePixelRatio;
draw();
}
/**
* Set the arrow direction in degrees.
* 0 = pointing up, positive = clockwise.
*/
export function setDirection(degrees: number): void {
currentAngle = degrees;
if (animFrameId) cancelAnimationFrame(animFrameId);
animFrameId = requestAnimationFrame(draw);
}
function draw(): void {
if (!canvas) return;
ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const size = Math.min(w, h) * 0.4;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((currentAngle * Math.PI) / 180);
// 7-point arrow polygon (same geometry as DirectionArrowView.kt)
ctx.beginPath();
ctx.moveTo(0, -size); // tip
ctx.lineTo(size * 0.5, size * 0.3); // right wing
ctx.lineTo(size * 0.15, size * 0.1); // right notch
ctx.lineTo(size * 0.15, size * 0.7); // right tail
ctx.lineTo(-size * 0.15, size * 0.7); // left tail
ctx.lineTo(-size * 0.15, size * 0.1); // left notch
ctx.lineTo(-size * 0.5, size * 0.3); // left wing
ctx.closePath();
ctx.fillStyle = ARROW_COLOR;
ctx.fill();
ctx.strokeStyle = OUTLINE_COLOR;
ctx.lineWidth = OUTLINE_WIDTH;
ctx.stroke();
ctx.restore();
}
/** Clean up compass resources. */
export function destroyCompass(): void {
window.removeEventListener('resize', resizeCanvas);
if (animFrameId) cancelAnimationFrame(animFrameId);
canvas?.remove();
canvas = null;
ctx = null;
}

View file

@ -0,0 +1,57 @@
/**
* Loading overlay: spinner + message + OK/Skip buttons.
* Same flow as Android: prompt before map caching, user can skip.
*/
/** Show the loading overlay with a message and optional spinner. */
export function showLoading(message: string, showSpinner = true): void {
const overlay = document.getElementById('loading-overlay')!;
const text = document.getElementById('loading-text')!;
const spinner = document.getElementById('loading-spinner')!;
const buttonRow = document.getElementById('loading-button-row')!;
text.textContent = message;
spinner.style.display = showSpinner ? 'block' : 'none';
buttonRow.style.display = 'none';
overlay.style.display = 'flex';
}
/** Show the cache prompt (OK / Skip buttons, no spinner). */
export function showCachePrompt(
message: string,
onOk: () => void,
onSkip: () => void,
): void {
const overlay = document.getElementById('loading-overlay')!;
const text = document.getElementById('loading-text')!;
const spinner = document.getElementById('loading-spinner')!;
const buttonRow = document.getElementById('loading-button-row')!;
const okBtn = document.getElementById('loading-ok-btn')!;
const skipBtn = document.getElementById('loading-skip-btn')!;
text.textContent = message;
spinner.style.display = 'none';
buttonRow.style.display = 'flex';
overlay.style.display = 'flex';
okBtn.onclick = () => {
hideLoading();
onOk();
};
skipBtn.onclick = () => {
hideLoading();
onSkip();
};
}
/** Update loading text (e.g. progress). */
export function updateLoadingText(message: string): void {
const text = document.getElementById('loading-text');
if (text) text.textContent = message;
}
/** Hide the loading overlay. */
export function hideLoading(): void {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
}

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 = [];
}

View file

@ -0,0 +1,59 @@
/**
* Bottom sheet shelter list component.
* Renders the 3 nearest shelters with distance, bearing mini-arrow, and address.
*/
import type { ShelterWithDistance } from '../types';
import { formatDistance } from '../util/distance-utils';
import { t } from '../i18n/i18n';
let container: HTMLElement | null = null;
let onSelect: ((index: number) => void) | null = null;
let selectedIndex = 0;
/** Initialize the shelter list inside the given container. */
export function initShelterList(
el: HTMLElement,
onShelterSelect: (index: number) => void,
): void {
container = el;
onSelect = onShelterSelect;
}
/** Render the list of nearest shelters using safe DOM methods. */
export function updateList(
shelters: ShelterWithDistance[],
currentSelectedIndex: number,
): void {
if (!container) return;
selectedIndex = currentSelectedIndex;
// Clear existing items
while (container.firstChild) {
container.removeChild(container.firstChild);
}
shelters.forEach((swd, i) => {
const item = document.createElement('button');
item.className = `shelter-item${i === selectedIndex ? ' selected' : ''}`;
const addressSpan = document.createElement('span');
addressSpan.className = 'shelter-item-address';
addressSpan.textContent = swd.shelter.adresse;
const detailsSpan = document.createElement('span');
detailsSpan.className = 'shelter-item-details';
detailsSpan.textContent = [
formatDistance(swd.distanceMeters),
t('shelter_capacity', swd.shelter.plasser),
t('shelter_room_nr', swd.shelter.romnr),
].join(' \u00B7 ');
item.appendChild(addressSpan);
item.appendChild(detailsSpan);
item.addEventListener('click', () => {
onSelect?.(i);
});
container!.appendChild(item);
});
}

15
pwa/src/ui/status-bar.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* Status bar: status text + refresh button.
*/
/** Update the status text. */
export function setStatus(text: string): void {
const el = document.getElementById('status-text');
if (el) el.textContent = text;
}
/** Set the refresh button click handler. */
export function onRefreshClick(handler: () => void): void {
const btn = document.getElementById('refresh-btn');
if (btn) btn.addEventListener('click', handler);
}

View file

@ -0,0 +1,64 @@
/**
* Haversine distance and initial bearing calculations.
* Ported from DistanceUtils.kt in the Android app.
*/
const EARTH_RADIUS_METERS = 6371000;
/** Degrees to radians. */
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
/** Radians to degrees. */
function toDeg(rad: number): number {
return (rad * 180) / Math.PI;
}
/**
* Calculate distance in meters between two WGS84 points
* using the Haversine formula.
*/
export function distanceMeters(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Calculate initial bearing (0=north, clockwise) from point 1 to point 2.
* Returns degrees in range [0, 360).
*/
export function bearingDegrees(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLambda = toRad(lon2 - lon1);
const y = Math.sin(dLambda) * Math.cos(phi2);
const x =
Math.cos(phi1) * Math.sin(phi2) -
Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
/**
* Format distance for display: meters if <1km, km with one decimal otherwise.
*/
export function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}

15
pwa/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
/// <reference types="vite/client" />
// Build-time cache-breaker injected by vite.config.ts
declare const __BUILD_REVISION__: string;
// Asset imports
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}