2026-03-08 17:41:38 +01:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-03-23 16:37:13 +01:00
|
|
|
import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils';
|
2026-03-08 17:41:38 +01:00
|
|
|
import { findNearest } from './location/shelter-finder';
|
2026-04-27 16:11:28 +02:00
|
|
|
import { DEEP_LINK_DOMAIN } from './config';
|
2026-03-08 17:41:38 +01:00
|
|
|
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';
|
2026-03-23 15:51:42 +01:00
|
|
|
import * as civilDefenseDialog from './ui/civil-defense-dialog';
|
2026-03-08 17:41:38 +01:00
|
|
|
|
|
|
|
|
const NEAREST_COUNT = 3;
|
|
|
|
|
|
|
|
|
|
let allShelters: Shelter[] = [];
|
|
|
|
|
let nearestShelters: ShelterWithDistance[] = [];
|
|
|
|
|
let selectedShelterIndex = 0;
|
2026-08-17 12:48:41 +02:00
|
|
|
// 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;
|
2026-03-08 17:41:38 +01:00
|
|
|
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;
|
2026-04-27 16:11:28 +02:00
|
|
|
// 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;
|
Gjør romnr til autoritativ tilfluktsrom-ID (Room-PK og IndexedDB-key)
Produktbeslutning (Forgejo #15): romnr — DSBs romnummer — er den
autoritative IDen. lokalId regenereres av Geonorge ved hver eksport og
beholdes bare som informasjonsfelt; det skal aldri sammenlignes eller
nøkles på. Utredningen mot Geonorge/DSB droppes.
Dette retter også en reell feil på Android: etter «Oppdater data» holdt
selectedShelter det gamle objektet med utdatert lokalId, så
rebuildShelterList() la til en duplikatrad med «utenfor nærområdet»-
badge for et rom som var blant de nærmeste, og markørhøylysingen mistet
målet (markørkartet var nøklet på nye lokalId-er).
Android:
- Shelter: @PrimaryKey romnr; lokalId vanlig kolonne
- ShelterDatabase: versjon 2 + fallbackToDestructiveMigration() — tabellen
er ren cache som reseedes fra asset når den er tom
- Parsere (GeoJSON + bundled): krever romnr > 0, lokalId valgfri
- MainActivity: markørkart Map<Int, Marker>, høylysing og
isSelectedAmongNearest/indexOfFirst på romnr
- ShelterListAdapter: DiffUtil areItemsTheSame på romnr
PWA:
- shelter-db.ts: DB_VERSION 2, keyPath 'romnr', upgrade sletter og
gjenoppretter store ved oldVersion < 2
- map-view.ts: _shelterRomnr i stedet for _shelterLokalId
- types.ts: dokumentert identitet; fetch-shelters.ts hopper over
features uten romnr (speiler Android-parseren)
ARCHITECTURE.md: nytt avsnitt «Shelter identity — romnr is authoritative»,
felt-tabell, valideringsregel og IndexedDB-skjema oppdatert.
Verifisert: emulator med v1.10.2-DB → migrering til v2 + reseed (556 rom);
velg rad 2 → Oppdater → ingen duplikatrad, valg og høylysing beholdt.
Playwright: seedet v1-IndexedDB → app oppgraderer til v2 (keyPath romnr,
556 poster, get(1681) treffer), dyplenke og markør OK.
Forgejo: #15
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrDGDmN2WAXvNPS85rWJGK
2026-08-17 13:30:25 +02:00
|
|
|
// see ARCHITECTURE.md → "Shelter identity" for rationale.
|
2026-04-27 16:11:28 +02:00
|
|
|
let selectedRomnr: number | null = null;
|
2026-03-08 17:41:38 +01:00
|
|
|
|
|
|
|
|
export async function init(): Promise<void> {
|
2026-03-23 14:02:32 +01:00
|
|
|
applyA11yLabels();
|
2026-03-08 17:41:38 +01:00
|
|
|
setupMap();
|
|
|
|
|
setupCompass();
|
|
|
|
|
setupShelterList();
|
|
|
|
|
setupButtons();
|
|
|
|
|
await loadData();
|
2026-03-23 16:37:13 +01:00
|
|
|
handleDeepLink();
|
2026-03-08 17:41:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 14:27:45 +01:00
|
|
|
/** Set localized aria-labels and wire the about button. */
|
2026-03-23 14:02:32 +01:00
|
|
|
function applyA11yLabels(): void {
|
2026-03-23 15:51:42 +01:00
|
|
|
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_civil_defense_info'));
|
2026-03-23 14:27:45 +01:00
|
|
|
document.getElementById('about-btn')?.addEventListener('click', () => {
|
|
|
|
|
navigator.vibrate?.(10);
|
2026-03-23 15:51:42 +01:00
|
|
|
civilDefenseDialog.showCivilDefenseInfo();
|
2026-03-23 14:27:45 +01:00
|
|
|
});
|
2026-03-23 14:02:32 +01:00
|
|
|
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'));
|
2026-04-27 16:11:28 +02:00
|
|
|
document.getElementById('share-btn')?.setAttribute('aria-label', t('action_share'));
|
2026-03-23 14:02:32 +01:00
|
|
|
document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:41:38 +01:00
|
|
|
function setupMap(): void {
|
|
|
|
|
const container = document.getElementById('map-container')!;
|
|
|
|
|
mapView.initMap(container, (shelter: Shelter) => {
|
2026-04-27 16:11:28 +02:00
|
|
|
// Marker click — select this shelter (route through selectShelterByData
|
|
|
|
|
// so a tap on a far-away marker also survives location updates).
|
|
|
|
|
selectShelterByData(shelter);
|
2026-03-08 17:41:38 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-27 16:11:28 +02:00
|
|
|
selectedRomnr = nearestShelters[index]?.shelter.romnr ?? null;
|
2026-03-08 17:41:38 +01:00
|
|
|
updateSelectedShelter(true);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupButtons(): void {
|
|
|
|
|
// Toggle map/compass
|
|
|
|
|
const toggleFab = document.getElementById('toggle-fab')!;
|
|
|
|
|
toggleFab.addEventListener('click', async () => {
|
2026-03-09 10:01:58 +01:00
|
|
|
navigator.vibrate?.(10);
|
2026-03-08 17:41:38 +01:00
|
|
|
isCompassMode = !isCompassMode;
|
|
|
|
|
|
|
|
|
|
const mapContainer = document.getElementById('map-container')!;
|
|
|
|
|
const compassContainer = document.getElementById('compass-container')!;
|
|
|
|
|
|
|
|
|
|
if (isCompassMode) {
|
2026-04-20 13:22:21 +02:00
|
|
|
// 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.
|
2026-03-08 17:41:38 +01:00
|
|
|
const granted = await compassProvider.requestPermission();
|
|
|
|
|
if (!granted) {
|
|
|
|
|
isCompassMode = false;
|
2026-04-20 13:22:21 +02:00
|
|
|
statusBar.setStatus(t('compass_permission_denied'));
|
2026-03-08 17:41:38 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
mapContainer.style.display = 'none';
|
|
|
|
|
compassContainer.classList.add('active');
|
2026-03-23 15:51:42 +01:00
|
|
|
compassView.resize();
|
2026-03-08 17:41:38 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-04-27 16:11:28 +02:00
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-08 17:41:38 +01:00
|
|
|
// Cache retry button
|
|
|
|
|
const cacheRetryBtn = document.getElementById('cache-retry-btn')!;
|
|
|
|
|
cacheRetryBtn.textContent = t('action_cache_now');
|
|
|
|
|
cacheRetryBtn.addEventListener('click', () => {
|
2026-03-09 10:01:58 +01:00
|
|
|
navigator.vibrate?.(10);
|
2026-03-08 17:41:38 +01:00
|
|
|
if (currentLocation && navigator.onLine) {
|
|
|
|
|
startCaching(currentLocation.latitude, currentLocation.longitude);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Reset view button
|
|
|
|
|
const resetBtn = document.getElementById('reset-view-btn')!;
|
|
|
|
|
resetBtn.addEventListener('click', () => {
|
2026-03-09 10:01:58 +01:00
|
|
|
navigator.vibrate?.(10);
|
2026-03-08 17:41:38 +01:00
|
|
|
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'));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 12:48:41 +02:00
|
|
|
/** Re-render the bottom-sheet list from current module state. */
|
|
|
|
|
function renderShelterList(): void {
|
|
|
|
|
shelterList.updateList(
|
|
|
|
|
nearestShelters,
|
|
|
|
|
selectedShelterIndex,
|
|
|
|
|
outsideNearestRomnr,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:41:38 +01:00
|
|
|
function updateNearestShelters(location: LatLon): void {
|
|
|
|
|
if (allShelters.length === 0) return;
|
|
|
|
|
|
|
|
|
|
nearestShelters = findNearest(
|
|
|
|
|
allShelters,
|
|
|
|
|
location.latitude,
|
|
|
|
|
location.longitude,
|
|
|
|
|
NEAREST_COUNT,
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-27 16:11:28 +02:00
|
|
|
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;
|
2026-08-17 12:48:41 +02:00
|
|
|
outsideNearestRomnr = null;
|
2026-04-27 16:11:28 +02:00
|
|
|
} else {
|
|
|
|
|
const shelter = allShelters.find((s) => s.romnr === selectedRomnr);
|
|
|
|
|
if (shelter) {
|
2026-08-17 12:48:41 +02:00
|
|
|
// 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({
|
2026-04-27 16:11:28 +02:00
|
|
|
shelter,
|
|
|
|
|
distanceMeters: distanceMeters(
|
|
|
|
|
location.latitude, location.longitude,
|
|
|
|
|
shelter.latitude, shelter.longitude,
|
|
|
|
|
),
|
|
|
|
|
bearingDegrees: bearingDegrees(
|
|
|
|
|
location.latitude, location.longitude,
|
|
|
|
|
shelter.latitude, shelter.longitude,
|
|
|
|
|
),
|
|
|
|
|
});
|
2026-08-17 12:48:41 +02:00
|
|
|
selectedShelterIndex = nearestShelters.length - 1;
|
|
|
|
|
outsideNearestRomnr = shelter.romnr;
|
2026-04-27 16:11:28 +02:00
|
|
|
} else {
|
|
|
|
|
// Selected shelter no longer exists in the dataset (e.g. DSB
|
|
|
|
|
// decommissioned it). Fall back to nearest.
|
|
|
|
|
selectedRomnr = null;
|
|
|
|
|
userSelectedShelter = false;
|
|
|
|
|
selectedShelterIndex = 0;
|
2026-08-17 12:48:41 +02:00
|
|
|
outsideNearestRomnr = null;
|
2026-04-27 16:11:28 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-03-08 17:41:38 +01:00
|
|
|
selectedShelterIndex = 0;
|
2026-08-17 12:48:41 +02:00
|
|
|
outsideNearestRomnr = null;
|
2026-03-08 17:41:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 12:48:41 +02:00
|
|
|
renderShelterList();
|
2026-03-08 17:41:38 +01:00
|
|
|
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
|
2026-03-09 09:53:17 +01:00
|
|
|
const miniArrow = document.getElementById('mini-arrow')!;
|
|
|
|
|
miniArrow.setAttribute('aria-label', t('direction_arrow_description', dist));
|
2026-03-08 17:41:38 +01:00
|
|
|
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);
|
2026-03-23 14:02:32 +01:00
|
|
|
compassView.setNorthAngle(-deviceHeading);
|
2026-03-08 17:41:38 +01:00
|
|
|
|
|
|
|
|
// Update shelter list selection
|
2026-08-17 12:48:41 +02:00
|
|
|
renderShelterList();
|
2026-03-08 17:41:38 +01:00
|
|
|
|
|
|
|
|
// 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);
|
2026-03-23 14:02:32 +01:00
|
|
|
compassView.setNorthAngle(-heading);
|
2026-03-08 17:41:38 +01:00
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-23 16:37:13 +01:00
|
|
|
|
|
|
|
|
/**
|
2026-04-27 16:11:28 +02:00
|
|
|
* Handle /shelter/{romnr} deep links.
|
2026-03-23 16:37:13 +01:00
|
|
|
* Called after loadData() so allShelters is populated.
|
2026-04-27 16:11:28 +02:00
|
|
|
*
|
|
|
|
|
* Path component is romnr (DSB room number), not lokalId — see
|
|
|
|
|
* selectedRomnr comment above for the upstream-stability rationale.
|
2026-03-23 16:37:13 +01:00
|
|
|
*/
|
|
|
|
|
function handleDeepLink(): void {
|
2026-04-27 16:11:28 +02:00
|
|
|
const match = window.location.pathname.match(/^\/shelter\/(\d+)$/);
|
2026-03-23 16:37:13 +01:00
|
|
|
if (!match) return;
|
|
|
|
|
|
2026-04-27 16:11:28 +02:00
|
|
|
const romnr = parseInt(match[1], 10);
|
2026-03-23 16:37:13 +01:00
|
|
|
|
|
|
|
|
// Clean the URL so refresh doesn't re-trigger
|
|
|
|
|
window.history.replaceState({}, '', '/');
|
|
|
|
|
|
2026-04-27 16:11:28 +02:00
|
|
|
const shelter = allShelters.find((s) => s.romnr === romnr);
|
2026-03-23 16:37:13 +01:00
|
|
|
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.
|
2026-04-27 16:11:28 +02:00
|
|
|
* Used for deep link targets, marker taps, and list taps. The selection is
|
|
|
|
|
* remembered via selectedLokalId so subsequent location updates preserve it.
|
2026-03-23 16:37:13 +01:00
|
|
|
*/
|
|
|
|
|
function selectShelterByData(shelter: Shelter): void {
|
2026-04-27 16:11:28 +02:00
|
|
|
userSelectedShelter = true;
|
|
|
|
|
selectedRomnr = shelter.romnr;
|
|
|
|
|
|
2026-03-23 16:37:13 +01:00
|
|
|
const existingIdx = nearestShelters.findIndex(
|
2026-04-27 16:11:28 +02:00
|
|
|
(s) => s.shelter.romnr === shelter.romnr,
|
2026-03-23 16:37:13 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existingIdx >= 0) {
|
|
|
|
|
selectedShelterIndex = existingIdx;
|
|
|
|
|
} else {
|
2026-04-27 16:11:28 +02:00
|
|
|
// 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;
|
2026-03-23 16:37:13 +01:00
|
|
|
|
2026-08-17 12:48:41 +02:00
|
|
|
// Append rather than prepend — see updateNearestShelters().
|
|
|
|
|
nearestShelters.push({
|
2026-03-23 16:37:13 +01:00
|
|
|
shelter,
|
|
|
|
|
distanceMeters: dist,
|
|
|
|
|
bearingDegrees: bearing,
|
|
|
|
|
});
|
2026-08-17 12:48:41 +02:00
|
|
|
selectedShelterIndex = nearestShelters.length - 1;
|
|
|
|
|
outsideNearestRomnr = shelter.romnr;
|
|
|
|
|
renderShelterList();
|
2026-03-23 16:37:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateSelectedShelter(true);
|
|
|
|
|
}
|
2026-04-27 16:11:28 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|