Om-side (Android + PWA): - Ny AboutDialog med personvernerklæring, datakjelder og opphavsrett - Opphavsrett flytta frå sivilforsvardialogen til om-sida - Tilgjengeleg via «Om denne appen»-lenke i sivilforsvarsdialogen (Android) og ny om-knapp i statuslinja (PWA) - Lokalisert til en/nb/nn Personvern og sikkerheit: - Lagra GPS-posisjon utløper etter 24 timar (widget_prefs) - Widget viser «Trykk for å oppdatere» når posisjon manglar eller er utløpt - Eigendefinert User-Agent (Tilfluktsrom/1.6.1) i OkHttp - Content Security Policy (CSP) meta-tag i PWA - Tenararbeidar bufrar berre HTTP 200-svar (ikkje opake) - Kartbuffer-metadata runda til ~11km presisjon i localStorage - crossorigin="anonymous" på Leaflet CSS i18n-opprydding: - Unicode-escapes erstatta med UTF-8-teikn i nb.ts og nn.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
/**
|
|
* 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';
|
|
import * as aboutDialog from './ui/about-dialog';
|
|
|
|
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> {
|
|
applyA11yLabels();
|
|
setupMap();
|
|
setupCompass();
|
|
setupShelterList();
|
|
setupButtons();
|
|
await loadData();
|
|
}
|
|
|
|
/** Set localized aria-labels and wire the about button. */
|
|
function applyA11yLabels(): void {
|
|
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_about'));
|
|
document.getElementById('about-btn')?.addEventListener('click', () => {
|
|
navigator.vibrate?.(10);
|
|
aboutDialog.showAbout();
|
|
});
|
|
document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map'));
|
|
document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass'));
|
|
document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));
|
|
document.getElementById('shelter-list')?.setAttribute('aria-label', t('a11y_nearest_shelters'));
|
|
document.getElementById('refresh-btn')?.setAttribute('aria-label', t('action_refresh'));
|
|
document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view'));
|
|
}
|
|
|
|
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 () => {
|
|
navigator.vibrate?.(10);
|
|
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', () => {
|
|
navigator.vibrate?.(10);
|
|
if (currentLocation && navigator.onLine) {
|
|
startCaching(currentLocation.latitude, currentLocation.longitude);
|
|
}
|
|
});
|
|
|
|
// Reset view button
|
|
const resetBtn = document.getElementById('reset-view-btn')!;
|
|
resetBtn.addEventListener('click', () => {
|
|
navigator.vibrate?.(10);
|
|
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
|
|
const miniArrow = document.getElementById('mini-arrow')!;
|
|
miniArrow.setAttribute('aria-label', t('direction_arrow_description', dist));
|
|
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);
|
|
compassView.setNorthAngle(-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);
|
|
compassView.setNorthAngle(-heading);
|
|
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'));
|
|
}
|
|
}
|