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
375
pwa/src/app.ts
Normal file
375
pwa/src/app.ts
Normal 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
112
pwa/src/cache/map-cache-manager.ts
vendored
Normal 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));
|
||||
}
|
||||
73
pwa/src/data/shelter-db.ts
Normal file
73
pwa/src/data/shelter-db.ts
Normal 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;
|
||||
}
|
||||
33
pwa/src/data/shelter-repository.ts
Normal file
33
pwa/src/data/shelter-repository.ts
Normal 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
49
pwa/src/i18n/en.ts
Normal 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
49
pwa/src/i18n/i18n.ts
Normal 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
44
pwa/src/i18n/nb.ts
Normal 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
44
pwa/src/i18n/nn.ts
Normal 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',
|
||||
};
|
||||
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);
|
||||
}
|
||||
36
pwa/src/main.ts
Normal file
36
pwa/src/main.ts
Normal 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
374
pwa/src/styles/main.css
Normal 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
22
pwa/src/types.ts
Normal 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;
|
||||
}
|
||||
89
pwa/src/ui/compass-view.ts
Normal file
89
pwa/src/ui/compass-view.ts
Normal 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;
|
||||
}
|
||||
57
pwa/src/ui/loading-overlay.ts
Normal file
57
pwa/src/ui/loading-overlay.ts
Normal 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
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 = [];
|
||||
}
|
||||
59
pwa/src/ui/shelter-list.ts
Normal file
59
pwa/src/ui/shelter-list.ts
Normal 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
15
pwa/src/ui/status-bar.ts
Normal 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);
|
||||
}
|
||||
64
pwa/src/util/distance-utils.ts
Normal file
64
pwa/src/util/distance-utils.ts
Normal 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
15
pwa/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue