tilfluktsrom/pwa/src/app.ts
Ole-Morten Duesund 6a50d86266 PWA: vis dyplenket tilfluktsrom nederst i lista med badge (hybrid)
Speiler Android-fiksen i 1fb9f14/337e9b9. Når en dyplenke eller
markørtap velger et tilfluktsrom utenfor de N nærmeste, ble det tidligere
unshift-et inn på indeks 0 og framstod som nærmest — uten forklaring.

Nå:
- app.ts: push i stedet for unshift på begge steder, valgt indeks = siste;
  ny modultilstand outsideNearestRomnr og renderShelterList()-helper
- shelter-list.ts: updateList() tar outsideNearestRomnr, tegner badge på
  den raden, prefikser badge-teksten i aria-label (samme rekkefølge som
  contentDescription i ShelterListAdapter.kt) og scrollIntoView() til
  valgt rad (auto i stedet for smooth ved prefers-reduced-motion)
- main.css: .shelter-item-badge med #BF360C (= warning_bg på Android,
  ~5.6:1 mot hvit → WCAG 2.2 AA SC 1.4.3)
- i18n en/nb/nn: shelter_outside_nearest_badge

Verifisert i Chromium (Playwright): posisjon Oslo, dyplenke /shelter/1681
(Vardø) → tre nærmeste først, fjerde rad med badge, selected og
aria-label "Selected (outside nearest). Kaigaten 8 …, 1487.1 km …".

Forgejo: #17

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrDGDmN2WAXvNPS85rWJGK
2026-08-17 12:48:41 +02:00

591 lines
19 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, distanceMeters, bearingDegrees } from './util/distance-utils';
import { findNearest } from './location/shelter-finder';
import { DEEP_LINK_DOMAIN } from './config';
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 civilDefenseDialog from './ui/civil-defense-dialog';
const NEAREST_COUNT = 3;
let allShelters: Shelter[] = [];
let nearestShelters: ShelterWithDistance[] = [];
let selectedShelterIndex = 0;
// romnr of a user-selected shelter that has been appended to the list because
// it is outside the geographic top-N (deep link, marker tap). null when the
// selection is a genuine top-N entry. Drives the "outside nearest" badge in
// the list — mirrors ShelterListItem.isOutsideNearest in the Android app.
let outsideNearestRomnr: number | null = null;
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;
// Romnr (DSB room number) of the user-selected shelter — survives location
// updates that recompute nearestShelters (deep links, marker taps to a
// far-away shelter), and *also* survives a forceRefresh() that replaces
// every lokalId in the dataset. Romnr is the stable upstream business key;
// see ARCHITECTURE.md → "Deep link identifier" for rationale.
let selectedRomnr: number | null = null;
export async function init(): Promise<void> {
applyA11yLabels();
setupMap();
setupCompass();
setupShelterList();
setupButtons();
await loadData();
handleDeepLink();
}
/** Set localized aria-labels and wire the about button. */
function applyA11yLabels(): void {
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_civil_defense_info'));
document.getElementById('about-btn')?.addEventListener('click', () => {
navigator.vibrate?.(10);
civilDefenseDialog.showCivilDefenseInfo();
});
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('share-btn')?.setAttribute('aria-label', t('action_share'));
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 (route through selectShelterByData
// so a tap on a far-away marker also survives location updates).
selectShelterByData(shelter);
});
}
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;
selectedRomnr = nearestShelters[index]?.shelter.romnr ?? null;
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).
// On denial we stay in map mode and surface a status message so the
// user understands why nothing happened — silent reverting is the
// failure mode the Android GPS-denied banner was designed to avoid.
const granted = await compassProvider.requestPermission();
if (!granted) {
isCompassMode = false;
statusBar.setStatus(t('compass_permission_denied'));
return;
}
mapContainer.style.display = 'none';
compassContainer.classList.add('active');
compassView.resize();
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);
// Share button — emits the same HTTPS deep link the Android app uses,
// so a recipient with the app installed (and verified App Links) opens
// the shelter natively, otherwise it opens in the PWA.
document.getElementById('share-btn')?.addEventListener('click', () => {
navigator.vibrate?.(10);
shareSelectedShelter();
});
// 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'));
},
);
}
/** Re-render the bottom-sheet list from current module state. */
function renderShelterList(): void {
shelterList.updateList(
nearestShelters,
selectedShelterIndex,
outsideNearestRomnr,
);
}
function updateNearestShelters(location: LatLon): void {
if (allShelters.length === 0) return;
nearestShelters = findNearest(
allShelters,
location.latitude,
location.longitude,
NEAREST_COUNT,
);
if (userSelectedShelter && selectedRomnr !== null) {
// Preserve the user's chosen shelter (deep link, marker click, list tap)
// even when it isn't in the geographic top-N. We re-add it with a
// freshly computed distance/bearing so the arrow stays correct.
// Match by romnr — survives a forceRefresh() that replaces lokalIds.
const inList = nearestShelters.findIndex(
(s) => s.shelter.romnr === selectedRomnr,
);
if (inList >= 0) {
selectedShelterIndex = inList;
outsideNearestRomnr = null;
} else {
const shelter = allShelters.find((s) => s.romnr === selectedRomnr);
if (shelter) {
// Append (not prepend) so the list keeps its sorted-by-distance
// invariant; the badge explains why this row is here. Same hybrid
// approach as MainActivity.rebuildShelterList() on Android.
nearestShelters.push({
shelter,
distanceMeters: distanceMeters(
location.latitude, location.longitude,
shelter.latitude, shelter.longitude,
),
bearingDegrees: bearingDegrees(
location.latitude, location.longitude,
shelter.latitude, shelter.longitude,
),
});
selectedShelterIndex = nearestShelters.length - 1;
outsideNearestRomnr = shelter.romnr;
} else {
// Selected shelter no longer exists in the dataset (e.g. DSB
// decommissioned it). Fall back to nearest.
selectedRomnr = null;
userSelectedShelter = false;
selectedShelterIndex = 0;
outsideNearestRomnr = null;
}
}
} else {
selectedShelterIndex = 0;
outsideNearestRomnr = null;
}
renderShelterList();
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
renderShelterList();
// 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'));
}
}
/**
* Handle /shelter/{romnr} deep links.
* Called after loadData() so allShelters is populated.
*
* Path component is romnr (DSB room number), not lokalId — see
* selectedRomnr comment above for the upstream-stability rationale.
*/
function handleDeepLink(): void {
const match = window.location.pathname.match(/^\/shelter\/(\d+)$/);
if (!match) return;
const romnr = parseInt(match[1], 10);
// Clean the URL so refresh doesn't re-trigger
window.history.replaceState({}, '', '/');
const shelter = allShelters.find((s) => s.romnr === romnr);
if (!shelter) {
statusBar.setStatus(t('error_shelter_not_found'));
return;
}
selectShelterByData(shelter);
}
/**
* Select a specific shelter, even if it's not in the current nearest-3 list.
* Used for deep link targets, marker taps, and list taps. The selection is
* remembered via selectedLokalId so subsequent location updates preserve it.
*/
function selectShelterByData(shelter: Shelter): void {
userSelectedShelter = true;
selectedRomnr = shelter.romnr;
const existingIdx = nearestShelters.findIndex(
(s) => s.shelter.romnr === shelter.romnr,
);
if (existingIdx >= 0) {
selectedShelterIndex = existingIdx;
} else {
// Compute distance/bearing if we have a location, otherwise NaN signals
// "unknown distance" — same convention as MainActivity.kt.
const dist = currentLocation
? distanceMeters(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
)
: NaN;
const bearing = currentLocation
? bearingDegrees(
currentLocation.latitude, currentLocation.longitude,
shelter.latitude, shelter.longitude,
)
: NaN;
// Append rather than prepend — see updateNearestShelters().
nearestShelters.push({
shelter,
distanceMeters: dist,
bearingDegrees: bearing,
});
selectedShelterIndex = nearestShelters.length - 1;
outsideNearestRomnr = shelter.romnr;
renderShelterList();
}
updateSelectedShelter(true);
}
/**
* Share the currently selected shelter. Uses the Web Share API when
* available (mobile browsers, installed PWAs) and falls back to copying
* the same body to the clipboard. Body format mirrors share_body in the
* Android strings.xml so the two clients produce equivalent messages.
*/
async function shareSelectedShelter(): Promise<void> {
const selected = nearestShelters[selectedShelterIndex];
if (!selected || !selected.shelter) {
statusBar.setStatus(t('share_no_shelter'));
return;
}
const shelter = selected.shelter;
const lat = shelter.latitude.toFixed(6);
const lon = shelter.longitude.toFixed(6);
// Path component is romnr (stable DSB business key), not lokalId. The
// upstream Geonorge GeoJSON re-rolls lokalId on every export, so a
// lokalId-keyed link breaks the moment either party refreshes their
// dataset. Romnr is unique (verified 556/556) and stable across exports.
const deepLink = `https://${DEEP_LINK_DOMAIN}/shelter/${shelter.romnr}`;
const subject = t('share_subject');
const body = [
`${subject}: ${shelter.adresse}`,
t('shelter_capacity', shelter.plasser),
`${lat}, ${lon}`,
`geo:${lat},${lon}`,
deepLink,
].join('\n');
if (navigator.share) {
try {
await navigator.share({ title: subject, text: body, url: deepLink });
return;
} catch (err) {
// AbortError means the user cancelled — silent. Anything else falls
// through to the clipboard fallback below.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(body);
statusBar.setStatus(t('share_copied'));
} catch {
statusBar.setStatus(t('share_no_shelter'));
}
}
}