Stabile djuplenker og fleire bruksforbetringar
- Bytt djuplenkjenøkkel frå lokalId til romnr fordi Geonorge
regenererer lokalId-UUID-en på kvar eksport (556/556 endra på sju
dagar), medan romnr er DSB sin stabile rom-nummer-nøkkel. Dokumentert
i ARCHITECTURE.md.
- PWA: ny del-knapp som genererer same HTTPS-djuplenke som Android-appen
(Web Share API med utklippstavle-fallback).
- PWA: vald tilfluktsrom overlever no posisjonsoppdateringar og
manuell dataoppdatering — sporast på romnr i staden for lista.
- Android: kart-bufferspørsmålet dukkar berre opp éin gong per økt
("Hopp over" sit), og forceRefresh viser lasteoverlegg + hindrar
samtidige refresh-kall.
- i18n.ts: vakta DOM-skriving slik at vitest køyrer utan jsdom.
- Oppdatert pakka tilfluktsromdata frå Geonorge.
Refs #15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87ac698d55
commit
3d6f8f362e
10 changed files with 786 additions and 615 deletions
|
|
@ -196,7 +196,7 @@ Both flavors produce identical user experiences — `standard` achieves faster G
|
||||||
|
|
||||||
### Deep Linking
|
### Deep Linking
|
||||||
|
|
||||||
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{lokalId}`
|
**HTTPS App Links:** `https://tilfluktsrom.naiv.no/shelter/{romnr}`
|
||||||
|
|
||||||
The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts` (exposed as `BuildConfig.DEEP_LINK_DOMAIN` and manifest placeholder `${deepLinkHost}`).
|
The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts` (exposed as `BuildConfig.DEEP_LINK_DOMAIN` and manifest placeholder `${deepLinkHost}`).
|
||||||
|
|
||||||
|
|
@ -207,6 +207,20 @@ The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts`
|
||||||
|
|
||||||
Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL.
|
Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL.
|
||||||
|
|
||||||
|
#### Deep link identifier — why `romnr`, not `lokalId`
|
||||||
|
|
||||||
|
The path component is the shelter's `romnr` (DSB room number — an integer like `776`), not the GeoJSON `lokalId` UUID, even though `lokalId` is what Room and Leaflet use as the in-memory primary key.
|
||||||
|
|
||||||
|
**Empirical reason:** the upstream GeoJSON ZIP at `nedlasting.geonorge.no/.../TilfluktsromOffentlige_GeoJSON.zip` re-rolls every `lokalId` on every export. Three snapshots of the same dataset (taken Dec 2025, Apr 20 2026, Apr 27 2026) had **556/556 different lokalIds** while every other field (`romnr`, `adresse`, `plasser`, `latitude`, `longitude`) was byte-identical and the shelter count was unchanged. The most recent two snapshots are only seven days apart, so this is regular drift, not a one-off re-issue.
|
||||||
|
|
||||||
|
That makes `lokalId` unsuitable for any cross-device or cross-build identifier:
|
||||||
|
- Sender and receiver who fetched the dataset on different days have different lokalIds for the same physical shelter, so a lokalId-keyed share link fails with "shelter not found" on the receiving side.
|
||||||
|
- Even on a single device, a user who hits "Refresh data" while a shelter is selected would lose the selection if it was tracked by lokalId.
|
||||||
|
|
||||||
|
`romnr` is the actual DSB business key — the room number physically assigned to the shelter by the civil-defence authority — and is stable across exports. Verified unique (556/556) and present (no zeros) on the current dataset.
|
||||||
|
|
||||||
|
The internal Room primary key remains `lokalId` because (a) it's already the upstream-supplied UUID and changing it would force a destructive Room schema migration, and (b) within a single fetch it's a perfectly fine in-memory key. Only the *external* deep-link identifier was switched.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Progressive Web App
|
## Progressive Web App
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
private var deviceHeading = 0f
|
private var deviceHeading = 0f
|
||||||
private var isCompassMode = false
|
private var isCompassMode = false
|
||||||
private var cachingJob: Job? = null
|
private var cachingJob: Job? = null
|
||||||
|
private var refreshJob: Job? = null
|
||||||
|
|
||||||
|
// Whether to consider showing the map-cache prompt on the next location
|
||||||
|
// update. Mirrors the PWA's firstLocationFix flag: we only prompt once per
|
||||||
|
// session, regardless of whether the user accepts or skips. Without this
|
||||||
|
// guard, every location update re-checks hasCacheForLocation and re-shows
|
||||||
|
// the prompt if the user previously chose "Skip".
|
||||||
|
private var mapCachePromptPending = true
|
||||||
// Map from shelter lokalId to its map marker, for icon swapping on selection
|
// Map from shelter lokalId to its map marker, for icon swapping on selection
|
||||||
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
|
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
|
||||||
private var highlightedMarkerId: String? = null
|
private var highlightedMarkerId: String? = null
|
||||||
|
|
@ -78,8 +86,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
// Whether a compass sensor is available on this device
|
// Whether a compass sensor is available on this device
|
||||||
private var hasCompassSensor = false
|
private var hasCompassSensor = false
|
||||||
|
|
||||||
// Deep link: shelter ID to select once data is loaded
|
// Deep link: shelter to select once data is loaded.
|
||||||
private var pendingDeepLinkShelterId: String? = null
|
// We key on `romnr` (DSB's room number) rather than `lokalId` because
|
||||||
|
// upstream Geonorge re-rolls the lokalId UUID on every export. Two
|
||||||
|
// devices that fetched at different times have different lokalIds for
|
||||||
|
// the same physical shelter, breaking cross-device share links.
|
||||||
|
// Romnr is the actual DSB business key and is stable across exports.
|
||||||
|
// See ARCHITECTURE.md → "Deep link identifier".
|
||||||
|
private var pendingDeepLinkRomnr: Int? = null
|
||||||
|
|
||||||
// The currently selected shelter — can be any shelter, not just one from nearestShelters
|
// The currently selected shelter — can be any shelter, not just one from nearestShelters
|
||||||
private var selectedShelter: ShelterWithDistance? = null
|
private var selectedShelter: ShelterWithDistance? = null
|
||||||
|
|
@ -131,7 +145,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle https://{domain}/shelter/{lokalId} deep link.
|
* Handle https://{domain}/shelter/{romnr} deep link.
|
||||||
|
* `romnr` is DSB's stable shelter room number — see field comment on
|
||||||
|
* pendingDeepLinkRomnr for why we don't use lokalId.
|
||||||
* If shelters are already loaded, select immediately; otherwise store as pending.
|
* If shelters are already loaded, select immediately; otherwise store as pending.
|
||||||
*/
|
*/
|
||||||
private fun handleDeepLinkIntent(intent: Intent?) {
|
private fun handleDeepLinkIntent(intent: Intent?) {
|
||||||
|
|
@ -140,15 +156,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
|
uri.host != BuildConfig.DEEP_LINK_DOMAIN ||
|
||||||
uri.path?.startsWith("/shelter/") != true) return
|
uri.path?.startsWith("/shelter/") != true) return
|
||||||
|
|
||||||
val lokalId = uri.lastPathSegment ?: return
|
val romnr = uri.lastPathSegment?.toIntOrNull() ?: return
|
||||||
// Clear intent data so config changes don't re-trigger
|
// Clear intent data so config changes don't re-trigger
|
||||||
intent.data = null
|
intent.data = null
|
||||||
|
|
||||||
val shelter = allShelters.find { it.lokalId == lokalId }
|
val shelter = allShelters.find { it.romnr == romnr }
|
||||||
if (shelter != null) {
|
if (shelter != null) {
|
||||||
selectShelterByData(shelter)
|
selectShelterByData(shelter)
|
||||||
} else {
|
} else {
|
||||||
pendingDeepLinkShelterId = lokalId
|
pendingDeepLinkRomnr = romnr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,9 +297,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
updateShelterMarkers()
|
updateShelterMarkers()
|
||||||
|
|
||||||
// Process pending deep links now that shelter data is available
|
// Process pending deep links now that shelter data is available
|
||||||
pendingDeepLinkShelterId?.let { id ->
|
pendingDeepLinkRomnr?.let { romnr ->
|
||||||
pendingDeepLinkShelterId = null
|
pendingDeepLinkRomnr = null
|
||||||
val shelter = shelters.find { it.lokalId == id }
|
val shelter = shelters.find { it.romnr == romnr }
|
||||||
if (shelter != null) {
|
if (shelter != null) {
|
||||||
selectShelterByData(shelter)
|
selectShelterByData(shelter)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -418,11 +434,14 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache map tiles on first launch
|
// Cache map tiles on first launch — at most one prompt
|
||||||
if (!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude)) {
|
// per session so a "Skip" decision sticks.
|
||||||
if (isNetworkAvailable()) {
|
if (mapCachePromptPending &&
|
||||||
cacheMapTiles(location.latitude, location.longitude)
|
!mapCacheManager.hasCacheForLocation(location.latitude, location.longitude) &&
|
||||||
}
|
isNetworkAvailable()
|
||||||
|
) {
|
||||||
|
mapCachePromptPending = false
|
||||||
|
cacheMapTiles(location.latitude, location.longitude)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
|
@ -681,14 +700,26 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycleScope.launch {
|
// Guard against double-tap / overlapping refreshes. Without this, the
|
||||||
binding.statusText.text = getString(R.string.status_updating)
|
// user can fire several refreshData() calls that serialize on the
|
||||||
val success = repository.refreshData()
|
// single Room write-lock and stack 30–90 s of OkHttp timeouts on top
|
||||||
if (success) {
|
// of each other — perceived as "hang" with no feedback.
|
||||||
updateFreshnessIndicator()
|
if (refreshJob?.isActive == true) return
|
||||||
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
binding.statusText.text = getString(R.string.status_updating)
|
||||||
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
|
showLoading(getString(R.string.loading_shelters))
|
||||||
|
|
||||||
|
refreshJob = lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val success = repository.refreshData()
|
||||||
|
if (success) {
|
||||||
|
updateFreshnessIndicator()
|
||||||
|
Toast.makeText(this@MainActivity, R.string.update_success, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this@MainActivity, R.string.update_failed, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -706,7 +737,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
val shelter = selected.shelter
|
val shelter = selected.shelter
|
||||||
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.lokalId}"
|
// Path component is romnr (stable DSB business key), not lokalId —
|
||||||
|
// upstream re-rolls lokalId on every Geonorge export, which would
|
||||||
|
// break cross-device links. See pendingDeepLinkRomnr comment.
|
||||||
|
val deepLink = "https://${BuildConfig.DEEP_LINK_DOMAIN}/shelter/${shelter.romnr}"
|
||||||
val body = getString(
|
val body = getString(
|
||||||
R.string.share_body,
|
R.string.share_body,
|
||||||
shelter.adresse,
|
shelter.adresse,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<header id="status-bar" role="banner">
|
<header id="status-bar" role="banner">
|
||||||
<span id="status-text" aria-live="polite"></span>
|
<span id="status-text" aria-live="polite"></span>
|
||||||
<button id="about-btn" aria-label="About">ℹ</button>
|
<button id="about-btn" aria-label="About">ℹ</button>
|
||||||
|
<button id="share-btn" aria-label="Share shelter">↪</button>
|
||||||
<button id="refresh-btn" aria-label="Refresh data">↻</button>
|
<button id="refresh-btn" aria-label="Refresh data">↻</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
167
pwa/src/app.ts
167
pwa/src/app.ts
|
|
@ -11,6 +11,7 @@ import type { Shelter, ShelterWithDistance, LatLon } from './types';
|
||||||
import { t } from './i18n/i18n';
|
import { t } from './i18n/i18n';
|
||||||
import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils';
|
import { formatDistance, distanceMeters, bearingDegrees } from './util/distance-utils';
|
||||||
import { findNearest } from './location/shelter-finder';
|
import { findNearest } from './location/shelter-finder';
|
||||||
|
import { DEEP_LINK_DOMAIN } from './config';
|
||||||
import * as repo from './data/shelter-repository';
|
import * as repo from './data/shelter-repository';
|
||||||
import * as locationProvider from './location/location-provider';
|
import * as locationProvider from './location/location-provider';
|
||||||
import * as compassProvider from './location/compass-provider';
|
import * as compassProvider from './location/compass-provider';
|
||||||
|
|
@ -35,6 +36,12 @@ let firstLocationFix = true;
|
||||||
// Track whether user manually selected a shelter (prevents auto-reselection
|
// Track whether user manually selected a shelter (prevents auto-reselection
|
||||||
// on location updates)
|
// on location updates)
|
||||||
let userSelectedShelter = false;
|
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> {
|
export async function init(): Promise<void> {
|
||||||
applyA11yLabels();
|
applyA11yLabels();
|
||||||
|
|
@ -58,21 +65,16 @@ function applyA11yLabels(): void {
|
||||||
document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));
|
document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));
|
||||||
document.getElementById('shelter-list')?.setAttribute('aria-label', t('a11y_nearest_shelters'));
|
document.getElementById('shelter-list')?.setAttribute('aria-label', t('a11y_nearest_shelters'));
|
||||||
document.getElementById('refresh-btn')?.setAttribute('aria-label', t('action_refresh'));
|
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'));
|
document.getElementById('toggle-fab')?.setAttribute('aria-label', t('action_toggle_view'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupMap(): void {
|
function setupMap(): void {
|
||||||
const container = document.getElementById('map-container')!;
|
const container = document.getElementById('map-container')!;
|
||||||
mapView.initMap(container, (shelter: Shelter) => {
|
mapView.initMap(container, (shelter: Shelter) => {
|
||||||
// Marker click — select this shelter
|
// Marker click — select this shelter (route through selectShelterByData
|
||||||
const idx = nearestShelters.findIndex(
|
// so a tap on a far-away marker also survives location updates).
|
||||||
(s) => s.shelter.lokalId === shelter.lokalId,
|
selectShelterByData(shelter);
|
||||||
);
|
|
||||||
if (idx >= 0) {
|
|
||||||
userSelectedShelter = true;
|
|
||||||
selectedShelterIndex = idx;
|
|
||||||
updateSelectedShelter(true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ function setupShelterList(): void {
|
||||||
shelterList.initShelterList(container, (index: number) => {
|
shelterList.initShelterList(container, (index: number) => {
|
||||||
userSelectedShelter = true;
|
userSelectedShelter = true;
|
||||||
selectedShelterIndex = index;
|
selectedShelterIndex = index;
|
||||||
|
selectedRomnr = nearestShelters[index]?.shelter.romnr ?? null;
|
||||||
updateSelectedShelter(true);
|
updateSelectedShelter(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +135,14 @@ function setupButtons(): void {
|
||||||
// Refresh button
|
// Refresh button
|
||||||
statusBar.onRefreshClick(forceRefresh);
|
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
|
// Cache retry button
|
||||||
const cacheRetryBtn = document.getElementById('cache-retry-btn')!;
|
const cacheRetryBtn = document.getElementById('cache-retry-btn')!;
|
||||||
cacheRetryBtn.textContent = t('action_cache_now');
|
cacheRetryBtn.textContent = t('action_cache_now');
|
||||||
|
|
@ -247,8 +258,40 @@ function updateNearestShelters(location: LatLon): void {
|
||||||
NEAREST_COUNT,
|
NEAREST_COUNT,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only auto-select the nearest shelter if the user hasn't manually selected one
|
if (userSelectedShelter && selectedRomnr !== null) {
|
||||||
if (!userSelectedShelter) {
|
// 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;
|
||||||
|
} else {
|
||||||
|
const shelter = allShelters.find((s) => s.romnr === selectedRomnr);
|
||||||
|
if (shelter) {
|
||||||
|
nearestShelters.unshift({
|
||||||
|
shelter,
|
||||||
|
distanceMeters: distanceMeters(
|
||||||
|
location.latitude, location.longitude,
|
||||||
|
shelter.latitude, shelter.longitude,
|
||||||
|
),
|
||||||
|
bearingDegrees: bearingDegrees(
|
||||||
|
location.latitude, location.longitude,
|
||||||
|
shelter.latitude, shelter.longitude,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
selectedShelterIndex = 0;
|
||||||
|
} else {
|
||||||
|
// Selected shelter no longer exists in the dataset (e.g. DSB
|
||||||
|
// decommissioned it). Fall back to nearest.
|
||||||
|
selectedRomnr = null;
|
||||||
|
userSelectedShelter = false;
|
||||||
|
selectedShelterIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
selectedShelterIndex = 0;
|
selectedShelterIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,19 +448,22 @@ async function forceRefresh(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle /shelter/{lokalId} deep links.
|
* Handle /shelter/{romnr} deep links.
|
||||||
* Called after loadData() so allShelters is populated.
|
* 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 {
|
function handleDeepLink(): void {
|
||||||
const match = window.location.pathname.match(/^\/shelter\/(.+)$/);
|
const match = window.location.pathname.match(/^\/shelter\/(\d+)$/);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
const lokalId = decodeURIComponent(match[1]);
|
const romnr = parseInt(match[1], 10);
|
||||||
|
|
||||||
// Clean the URL so refresh doesn't re-trigger
|
// Clean the URL so refresh doesn't re-trigger
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
|
|
||||||
const shelter = allShelters.find((s) => s.lokalId === lokalId);
|
const shelter = allShelters.find((s) => s.romnr === romnr);
|
||||||
if (!shelter) {
|
if (!shelter) {
|
||||||
statusBar.setStatus(t('error_shelter_not_found'));
|
statusBar.setStatus(t('error_shelter_not_found'));
|
||||||
return;
|
return;
|
||||||
|
|
@ -428,42 +474,95 @@ function handleDeepLink(): void {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a specific shelter, even if it's not in the current nearest-3 list.
|
* Select a specific shelter, even if it's not in the current nearest-3 list.
|
||||||
* Used for deep link targets.
|
* 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 {
|
function selectShelterByData(shelter: Shelter): void {
|
||||||
// Check if it's already in nearestShelters
|
userSelectedShelter = true;
|
||||||
|
selectedRomnr = shelter.romnr;
|
||||||
|
|
||||||
const existingIdx = nearestShelters.findIndex(
|
const existingIdx = nearestShelters.findIndex(
|
||||||
(s) => s.shelter.lokalId === shelter.lokalId,
|
(s) => s.shelter.romnr === shelter.romnr,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
userSelectedShelter = true;
|
|
||||||
selectedShelterIndex = existingIdx;
|
selectedShelterIndex = existingIdx;
|
||||||
} else {
|
} else {
|
||||||
// Compute distance/bearing if we have a location, otherwise use placeholder
|
// Compute distance/bearing if we have a location, otherwise NaN signals
|
||||||
let dist = NaN;
|
// "unknown distance" — same convention as MainActivity.kt.
|
||||||
let bearing = 0;
|
const dist = currentLocation
|
||||||
if (currentLocation) {
|
? distanceMeters(
|
||||||
dist = distanceMeters(
|
currentLocation.latitude, currentLocation.longitude,
|
||||||
currentLocation.latitude, currentLocation.longitude,
|
shelter.latitude, shelter.longitude,
|
||||||
shelter.latitude, shelter.longitude,
|
)
|
||||||
);
|
: NaN;
|
||||||
bearing = bearingDegrees(
|
const bearing = currentLocation
|
||||||
currentLocation.latitude, currentLocation.longitude,
|
? bearingDegrees(
|
||||||
shelter.latitude, shelter.longitude,
|
currentLocation.latitude, currentLocation.longitude,
|
||||||
);
|
shelter.latitude, shelter.longitude,
|
||||||
}
|
)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
// Prepend to the list so it becomes the selected shelter
|
|
||||||
nearestShelters.unshift({
|
nearestShelters.unshift({
|
||||||
shelter,
|
shelter,
|
||||||
distanceMeters: dist,
|
distanceMeters: dist,
|
||||||
bearingDegrees: bearing,
|
bearingDegrees: bearing,
|
||||||
});
|
});
|
||||||
userSelectedShelter = true;
|
|
||||||
selectedShelterIndex = 0;
|
selectedShelterIndex = 0;
|
||||||
shelterList.updateList(nearestShelters, selectedShelterIndex);
|
shelterList.updateList(nearestShelters, selectedShelterIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedShelter(true);
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ export const en: Record<string, string> = {
|
||||||
action_skip: 'Skip',
|
action_skip: 'Skip',
|
||||||
action_cache_ok: 'Cache map',
|
action_cache_ok: 'Cache map',
|
||||||
action_cache_now: 'Cache now',
|
action_cache_now: 'Cache now',
|
||||||
|
action_share: 'Share shelter',
|
||||||
|
share_subject: 'Emergency shelter',
|
||||||
|
share_no_shelter: 'No shelter selected',
|
||||||
|
share_copied: 'Shelter details copied to clipboard',
|
||||||
warning_no_map_cache: 'No offline map cached. Map requires internet.',
|
warning_no_map_cache: 'No offline map cached. Map requires internet.',
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,11 @@ export function initLocale(): void {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.documentElement.lang = currentLocale;
|
// Guard the DOM write so this module is usable from Node (vitest runs
|
||||||
|
// without jsdom). In a browser, document is always defined.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = currentLocale;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get current locale code. */
|
/** Get current locale code. */
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ export const nb: Record<string, string> = {
|
||||||
action_skip: 'Hopp over',
|
action_skip: 'Hopp over',
|
||||||
action_cache_ok: 'Lagre kart',
|
action_cache_ok: 'Lagre kart',
|
||||||
action_cache_now: 'Lagre nå',
|
action_cache_now: 'Lagre nå',
|
||||||
|
action_share: 'Del tilfluktsrom',
|
||||||
|
share_subject: 'Tilfluktsrom',
|
||||||
|
share_no_shelter: 'Ingen tilfluktsrom valgt',
|
||||||
|
share_copied: 'Tilfluktsrominfo kopiert til utklippstavlen',
|
||||||
warning_no_map_cache:
|
warning_no_map_cache:
|
||||||
'Ingen frakoblet kart lagret. Kartet krever internett.',
|
'Ingen frakoblet kart lagret. Kartet krever internett.',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ export const nn: Record<string, string> = {
|
||||||
action_skip: 'Hopp over',
|
action_skip: 'Hopp over',
|
||||||
action_cache_ok: 'Lagre kart',
|
action_cache_ok: 'Lagre kart',
|
||||||
action_cache_now: 'Lagre no',
|
action_cache_now: 'Lagre no',
|
||||||
|
action_share: 'Del tilfluktsrom',
|
||||||
|
share_subject: 'Tilfluktsrom',
|
||||||
|
share_no_shelter: 'Ingen tilfluktsrom valt',
|
||||||
|
share_copied: 'Tilfluktsrominfo kopiert til utklippstavla',
|
||||||
warning_no_map_cache:
|
warning_no_map_cache:
|
||||||
'Ingen fråkopla kart lagra. Kartet krev internett.',
|
'Ingen fråkopla kart lagra. Kartet krev internett.',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ html, body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#about-btn,
|
#about-btn,
|
||||||
|
#share-btn,
|
||||||
#refresh-btn {
|
#refresh-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -75,10 +76,16 @@ html, body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#about-btn:hover,
|
#about-btn:hover,
|
||||||
|
#share-btn:hover,
|
||||||
#refresh-btn:hover {
|
#refresh-btn:hover {
|
||||||
color: #ECEFF1;
|
color: #ECEFF1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#share-btn[disabled] {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Main content area (map or compass) --- */
|
/* --- Main content area (map or compass) --- */
|
||||||
#main-content {
|
#main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue