Legg til TalkBack-støtte og nordindikator på kompasset

Tilgjengelegheit (Android + PWA):
- Semantiske landemerke (header, main, aside, role=dialog)
- aria-live-regionar for statusoppdateringar og lasteoverlegg
- Fokusindikatorar (:focus-visible) og prefers-reduced-motion
- Auka trykkmål til 48dp (infoknapp, oppdater, del, widget)
- contentDescription på kart, kompass og framdriftsindikator
- aria-current og role=listitem på tilfluktsromliste
- Fokusfangst og fokusgjenoppretting i lasteoverlegg
- Ikkje-farge-indikator (▶) for valt tilfluktsrom
- Dynamisk lang-attributt basert på oppdaga språk
- Lokaliserte aria-label (en/nb/nn)

Nordindikator:
- DirectionArrowView teiknar diskret «N»-markør på omkrinsen
- Roterer uavhengig av hovudpila for kompasskalibrering
- Berre på stor kompassvisning, ikkje minipila

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 14:02:32 +01:00
commit 6ba35add2f
17 changed files with 240 additions and 36 deletions

View file

@ -522,10 +522,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
R.string.direction_arrow_description, distanceText R.string.direction_arrow_description, distanceText
) )
// Update compass view // Update compass view (large arrow gets a north indicator)
binding.compassDistanceText.text = distanceText binding.compassDistanceText.text = distanceText
binding.compassAddressText.text = selected.shelter.adresse binding.compassAddressText.text = selected.shelter.adresse
binding.directionArrow.setDirection(arrowAngle) binding.directionArrow.setDirection(arrowAngle)
binding.directionArrow.setNorthAngle(-deviceHeading)
binding.directionArrow.contentDescription = getString( binding.directionArrow.contentDescription = getString(
R.string.direction_arrow_description, distanceText R.string.direction_arrow_description, distanceText
) )
@ -840,6 +841,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
val arrowAngle = bearing - deviceHeading val arrowAngle = bearing - deviceHeading
binding.directionArrow.setDirection(arrowAngle) binding.directionArrow.setDirection(arrowAngle)
binding.directionArrow.setNorthAngle(-deviceHeading)
binding.miniArrow.setDirection(arrowAngle) binding.miniArrow.setDirection(arrowAngle)
} }

View file

@ -17,6 +17,9 @@ import no.naiv.tilfluktsrom.R
* rotationAngle = shelterBearing - deviceHeading * rotationAngle = shelterBearing - deviceHeading
* This gives the direction the user needs to walk, adjusted for which * This gives the direction the user needs to walk, adjusted for which
* way they're currently facing. * way they're currently facing.
*
* Optionally draws a discrete north indicator on the perimeter so users
* can validate compass calibration against a known direction.
*/ */
class DirectionArrowView @JvmOverloads constructor( class DirectionArrowView @JvmOverloads constructor(
context: Context, context: Context,
@ -25,6 +28,7 @@ class DirectionArrowView @JvmOverloads constructor(
) : View(context, attrs, defStyleAttr) { ) : View(context, attrs, defStyleAttr) {
private var rotationAngle = 0f private var rotationAngle = 0f
private var northAngle = Float.NaN
private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColor(R.color.shelter_primary) color = context.getColor(R.color.shelter_primary)
@ -37,7 +41,18 @@ class DirectionArrowView @JvmOverloads constructor(
strokeWidth = 4f strokeWidth = 4f
} }
private val northPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0x99CFD8DC.toInt() // text_secondary at ~60% opacity
style = Paint.Style.FILL
}
private val northTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0x99CFD8DC.toInt()
textAlign = Paint.Align.CENTER
}
private val arrowPath = Path() private val arrowPath = Path()
private val northPath = Path()
/** /**
* Set the rotation angle in degrees. * Set the rotation angle in degrees.
@ -48,6 +63,16 @@ class DirectionArrowView @JvmOverloads constructor(
invalidate() invalidate()
} }
/**
* Set the angle to north in the view's coordinate space.
* This is typically -deviceHeading (where north is on screen).
* Set to Float.NaN to hide the north indicator.
*/
fun setNorthAngle(degrees: Float) {
northAngle = degrees
invalidate()
}
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
@ -55,6 +80,11 @@ class DirectionArrowView @JvmOverloads constructor(
val cy = height / 2f val cy = height / 2f
val size = minOf(width, height) * 0.4f val size = minOf(width, height) * 0.4f
// Draw north indicator first (behind the main arrow)
if (!northAngle.isNaN()) {
drawNorthIndicator(canvas, cx, cy, size)
}
canvas.save() canvas.save()
canvas.rotate(rotationAngle, cx, cy) canvas.rotate(rotationAngle, cx, cy)
@ -74,4 +104,32 @@ class DirectionArrowView @JvmOverloads constructor(
canvas.restore() canvas.restore()
} }
/**
* Draw a small north indicator: a tiny triangle and "N" label
* placed on the perimeter of the view, pointing inward toward center.
*/
private fun drawNorthIndicator(canvas: Canvas, cx: Float, cy: Float, arrowSize: Float) {
val radius = arrowSize * 1.35f
val tickSize = arrowSize * 0.1f
// Scale "N" text relative to the view
northTextPaint.textSize = arrowSize * 0.18f
canvas.save()
canvas.rotate(northAngle, cx, cy)
// Small triangle at the top of the perimeter circle
northPath.reset()
northPath.moveTo(cx, cy - radius)
northPath.lineTo(cx - tickSize, cy - radius - tickSize * 1.8f)
northPath.lineTo(cx + tickSize, cy - radius - tickSize * 1.8f)
northPath.close()
canvas.drawPath(northPath, northPaint)
// "N" label just outside the triangle
canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint)
canvas.restore()
}
} }

View file

@ -31,14 +31,15 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:accessibilityLiveRegion="polite"
android:textColor="@color/status_text" android:textColor="@color/status_text"
android:textSize="12sp" android:textSize="12sp"
tools:text="@string/status_ready" /> tools:text="@string/status_ready" />
<ImageButton <ImageButton
android:id="@+id/infoButton" android:id="@+id/infoButton"
android:layout_width="36dp" android:layout_width="48dp"
android:layout_height="36dp" android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_civil_defense_info" android:contentDescription="@string/action_civil_defense_info"
android:src="@drawable/ic_info" android:src="@drawable/ic_info"
@ -46,8 +47,8 @@
<ImageButton <ImageButton
android:id="@+id/refreshButton" android:id="@+id/refreshButton"
android:layout_width="36dp" android:layout_width="48dp"
android:layout_height="36dp" android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_refresh" android:contentDescription="@string/action_refresh"
android:src="@drawable/ic_refresh" android:src="@drawable/ic_refresh"
@ -71,6 +72,7 @@
android:id="@+id/mapView" android:id="@+id/mapView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:contentDescription="@string/a11y_map"
app:layout_constraintTop_toBottomOf="@id/statusBar" app:layout_constraintTop_toBottomOf="@id/statusBar"
app:layout_constraintBottom_toTopOf="@id/bottomSheet" /> app:layout_constraintBottom_toTopOf="@id/bottomSheet" />
@ -80,6 +82,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:background="@color/compass_bg" android:background="@color/compass_bg"
android:contentDescription="@string/a11y_compass"
android:visibility="gone" android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/statusBar" app:layout_constraintTop_toBottomOf="@id/statusBar"
app:layout_constraintBottom_toTopOf="@id/bottomSheet"> app:layout_constraintBottom_toTopOf="@id/bottomSheet">
@ -223,8 +226,8 @@
<ImageButton <ImageButton
android:id="@+id/shareButton" android:id="@+id/shareButton"
android:layout_width="40dp" android:layout_width="48dp"
android:layout_height="40dp" android:layout_height="48dp"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_share" android:contentDescription="@string/action_share"
@ -249,6 +252,8 @@
android:background="@color/loading_bg" android:background="@color/loading_bg"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:importantForAccessibility="yes"
android:accessibilityLiveRegion="assertive"
android:visibility="gone"> android:visibility="gone">
<LinearLayout <LinearLayout
@ -262,6 +267,7 @@
android:id="@+id/loadingProgress" android:id="@+id/loadingProgress"
android:layout_width="64dp" android:layout_width="64dp"
android:layout_height="64dp" android:layout_height="64dp"
android:contentDescription="@string/status_loading"
android:indeterminate="true" /> android:indeterminate="true" />
<TextView <TextView

View file

@ -65,9 +65,10 @@
<ImageView <ImageView
android:id="@+id/widgetRefreshButton" android:id="@+id/widgetRefreshButton"
android:layout_width="32dp" android:layout_width="48dp"
android:layout_height="32dp" android:layout_height="48dp"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:padding="8dp"
android:contentDescription="@string/action_refresh" android:contentDescription="@string/action_refresh"
android:src="@drawable/ic_refresh" /> android:src="@drawable/ic_refresh" />
</LinearLayout> </LinearLayout>

View file

@ -67,6 +67,8 @@
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string> <string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
<string name="content_desc_shelter_item">%1$s, %2$s, %3$d plasser</string> <string name="content_desc_shelter_item">%1$s, %2$s, %3$d plasser</string>
<string name="compass_accuracy_warning">Upresist kompass - %s</string> <string name="compass_accuracy_warning">Upresist kompass - %s</string>
<string name="a11y_map">Tilfluktsromkart</string>
<string name="a11y_compass">Kompassnavigasjon</string>
<!-- Sivilforsvar --> <!-- Sivilforsvar -->
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string> <string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>

View file

@ -67,6 +67,8 @@
<string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string> <string name="direction_arrow_description">Retning til tilfluktsrom, %s unna</string>
<string name="content_desc_shelter_item">%1$s, %2$s, %3$d plassar</string> <string name="content_desc_shelter_item">%1$s, %2$s, %3$d plassar</string>
<string name="compass_accuracy_warning">Upresis kompass - %s</string> <string name="compass_accuracy_warning">Upresis kompass - %s</string>
<string name="a11y_map">Tilfluktsromkart</string>
<string name="a11y_compass">Kompassnavigasjon</string>
<!-- Sivilforsvar --> <!-- Sivilforsvar -->
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string> <string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>

View file

@ -67,6 +67,8 @@
<string name="direction_arrow_description">Direction to shelter, %s away</string> <string name="direction_arrow_description">Direction to shelter, %s away</string>
<string name="content_desc_shelter_item">%1$s, %2$s, %3$d places</string> <string name="content_desc_shelter_item">%1$s, %2$s, %3$d places</string>
<string name="compass_accuracy_warning">Low accuracy - %s</string> <string name="compass_accuracy_warning">Low accuracy - %s</string>
<string name="a11y_map">Shelter map</string>
<string name="a11y_compass">Compass navigation</string>
<!-- Civil defense info --> <!-- Civil defense info -->
<string name="action_civil_defense_info">Civil defense information</string> <string name="action_civil_defense_info">Civil defense information</string>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" dir="ltr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
@ -16,47 +16,47 @@
<body> <body>
<div id="app"> <div id="app">
<!-- Status bar --> <!-- Status bar -->
<div id="status-bar"> <header id="status-bar" role="banner">
<span id="status-text"></span> <span id="status-text" aria-live="polite"></span>
<button id="refresh-btn" aria-label="Refresh data">&#x21bb;</button> <button id="refresh-btn" aria-label="Refresh data">&#x21bb;</button>
</div> </header>
<!-- Main content: map or compass --> <!-- Main content: map or compass -->
<div id="main-content"> <main id="main-content">
<div id="map-container"></div> <div id="map-container" role="application" aria-label="Map"></div>
<div id="compass-container"> <div id="compass-container" role="img" aria-label="Compass">
<span id="compass-address"></span> <span id="compass-address"></span>
<span id="compass-distance"></span> <span id="compass-distance" aria-live="polite"></span>
</div> </div>
<!-- Toggle map/compass FAB --> <!-- Toggle map/compass FAB -->
<button id="toggle-fab" aria-label="Toggle map/compass view">&#x1F9ED;</button> <button id="toggle-fab" aria-label="Toggle map/compass view">&#x1F9ED;</button>
<!-- Reset view button --> <!-- Reset view button -->
<button id="reset-view-btn" aria-label="Reset view">&#x2316;</button> <button id="reset-view-btn" aria-label="Reset view">&#x2316;</button>
</div> </main>
<!-- No-cache warning banner --> <!-- No-cache warning banner -->
<div id="no-cache-banner"> <div id="no-cache-banner" role="alert">
<span id="no-cache-text"></span> <span id="no-cache-text"></span>
<button id="cache-retry-btn"></button> <button id="cache-retry-btn"></button>
</div> </div>
<!-- Bottom sheet with shelter info --> <!-- Bottom sheet with shelter info -->
<div id="bottom-sheet"> <aside id="bottom-sheet" aria-label="Shelter info">
<div id="selected-shelter"> <div id="selected-shelter" aria-live="polite">
<canvas id="mini-arrow" width="96" height="96" role="img" aria-label="Direction to shelter"></canvas> <canvas id="mini-arrow" width="96" height="96" role="img" aria-label="Direction to shelter"></canvas>
<div id="selected-shelter-info"> <div id="selected-shelter-info">
<div id="selected-shelter-address"></div> <div id="selected-shelter-address"></div>
<div id="selected-shelter-details"></div> <div id="selected-shelter-details"></div>
</div> </div>
</div> </div>
<div id="shelter-list"></div> <div id="shelter-list" role="list" aria-label="Nearest shelters"></div>
</div> </aside>
</div> </div>
<!-- Loading overlay --> <!-- Loading overlay -->
<div id="loading-overlay"> <div id="loading-overlay" role="dialog" aria-modal="true" aria-label="Loading">
<div id="loading-spinner"></div> <div id="loading-spinner" aria-hidden="true"></div>
<div id="loading-text"></div> <div id="loading-text" aria-live="assertive" tabindex="-1"></div>
<div id="loading-button-row"> <div id="loading-button-row">
<button id="loading-skip-btn"></button> <button id="loading-skip-btn"></button>
<button id="loading-ok-btn"></button> <button id="loading-ok-btn"></button>

View file

@ -36,6 +36,7 @@ let firstLocationFix = true;
let userSelectedShelter = false; let userSelectedShelter = false;
export async function init(): Promise<void> { export async function init(): Promise<void> {
applyA11yLabels();
setupMap(); setupMap();
setupCompass(); setupCompass();
setupShelterList(); setupShelterList();
@ -43,6 +44,16 @@ export async function init(): Promise<void> {
await loadData(); await loadData();
} }
/** Set localized aria-labels on landmark elements. */
function applyA11yLabels(): void {
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 { 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) => {
@ -268,6 +279,7 @@ function updateSelectedShelter(isUserAction: boolean): void {
document.getElementById('compass-address')!.textContent = document.getElementById('compass-address')!.textContent =
selected.shelter.adresse; selected.shelter.adresse;
compassView.setDirection(selected.bearingDegrees - deviceHeading); compassView.setDirection(selected.bearingDegrees - deviceHeading);
compassView.setNorthAngle(-deviceHeading);
// Update shelter list selection // Update shelter list selection
shelterList.updateList(nearestShelters, selectedShelterIndex); shelterList.updateList(nearestShelters, selectedShelterIndex);
@ -290,6 +302,7 @@ function onHeadingUpdate(heading: number): void {
const angle = selected.bearingDegrees - heading; const angle = selected.bearingDegrees - heading;
compassView.setDirection(angle); compassView.setDirection(angle);
compassView.setNorthAngle(-heading);
updateMiniArrow(angle); updateMiniArrow(angle);
} }

View file

@ -49,4 +49,8 @@ export const en: Record<string, string> = {
// Accessibility // Accessibility
direction_arrow_description: 'Direction to shelter, %s away', direction_arrow_description: 'Direction to shelter, %s away',
a11y_map: 'Map',
a11y_compass: 'Compass',
a11y_shelter_info: 'Shelter info',
a11y_nearest_shelters: 'Nearest shelters',
}; };

View file

@ -11,21 +11,22 @@ const locales: Record<string, Record<string, string>> = { en, nb, nn };
let currentLocale = 'en'; let currentLocale = 'en';
/** Detect and set locale from browser preferences. */ /** Detect and set locale from browser preferences, update document lang. */
export function initLocale(): void { export function initLocale(): void {
const langs = navigator.languages ?? [navigator.language]; const langs = navigator.languages ?? [navigator.language];
for (const lang of langs) { for (const lang of langs) {
const code = lang.toLowerCase().split('-')[0]; const code = lang.toLowerCase().split('-')[0];
if (code in locales) { if (code in locales) {
currentLocale = code; currentLocale = code;
return; break;
} }
// nb and nn both start with "n" — also match "no" as Bokmål // nb and nn both start with "n" — also match "no" as Bokmål
if (code === 'no') { if (code === 'no') {
currentLocale = 'nb'; currentLocale = 'nb';
return; break;
} }
} }
document.documentElement.lang = currentLocale;
} }
/** Get current locale code. */ /** Get current locale code. */

View file

@ -44,4 +44,8 @@ export const nb: Record<string, string> = {
// Tilgjengelighet // Tilgjengelighet
direction_arrow_description: 'Retning til tilfluktsrom, %s unna', direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
a11y_map: 'Kart',
a11y_compass: 'Kompass',
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmeste tilfluktsrom',
}; };

View file

@ -44,4 +44,8 @@ export const nn: Record<string, string> = {
// Tilgjenge // Tilgjenge
direction_arrow_description: 'Retning til tilfluktsrom, %s unna', direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
a11y_map: 'Kart',
a11y_compass: 'Kompass',
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmaste tilfluktsrom',
}; };

View file

@ -20,6 +20,21 @@ html, body {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }
/* --- Focus indicators for screen reader / switch access --- */
:focus-visible {
outline: 2px solid #FF6B35;
outline-offset: 2px;
}
/* --- Respect reduced motion preference --- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* --- App shell layout --- */ /* --- App shell layout --- */
#app { #app {
display: flex; display: flex;
@ -263,6 +278,18 @@ html, body {
.shelter-item.selected { .shelter-item.selected {
border-color: #FF6B35; border-color: #FF6B35;
background: rgba(255, 107, 53, 0.1); background: rgba(255, 107, 53, 0.1);
padding-left: 20px;
position: relative;
}
.shelter-item.selected::before {
content: '\25B6';
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
color: #FF6B35;
font-size: 10px;
} }
.shelter-item-address { .shelter-item-address {

View file

@ -3,15 +3,20 @@
* Ported from DirectionArrowView.kt same 7-point arrow polygon. * Ported from DirectionArrowView.kt same 7-point arrow polygon.
* *
* Arrow rotation = shelterBearing - deviceHeading * Arrow rotation = shelterBearing - deviceHeading
*
* Also draws a discrete north indicator on the perimeter so users can
* validate compass calibration against a known direction.
*/ */
const ARROW_COLOR = '#FF6B35'; const ARROW_COLOR = '#FF6B35';
const OUTLINE_COLOR = '#FFFFFF'; const OUTLINE_COLOR = '#FFFFFF';
const OUTLINE_WIDTH = 4; const OUTLINE_WIDTH = 4;
const NORTH_COLOR = 'rgba(207, 216, 220, 0.6)'; // text_secondary at ~60%
let canvas: HTMLCanvasElement | null = null; let canvas: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null; let ctx: CanvasRenderingContext2D | null = null;
let currentAngle = 0; let currentAngle = 0;
let northAngle: number | null = null;
let animFrameId = 0; let animFrameId = 0;
/** Initialize the compass canvas inside the given container element. */ /** Initialize the compass canvas inside the given container element. */
@ -20,6 +25,7 @@ export function initCompass(container: HTMLElement): void {
canvas.id = 'compass-canvas'; canvas.id = 'compass-canvas';
canvas.style.width = '100%'; canvas.style.width = '100%';
canvas.style.height = '100%'; canvas.style.height = '100%';
canvas.setAttribute('aria-hidden', 'true');
container.prepend(canvas); container.prepend(canvas);
resizeCanvas(); resizeCanvas();
window.addEventListener('resize', resizeCanvas); window.addEventListener('resize', resizeCanvas);
@ -43,6 +49,14 @@ export function setDirection(degrees: number): void {
animFrameId = requestAnimationFrame(draw); animFrameId = requestAnimationFrame(draw);
} }
/**
* Set the angle to north in the view's coordinate space.
* Typically -deviceHeading. Set to null to hide.
*/
export function setNorthAngle(degrees: number): void {
northAngle = degrees;
}
function draw(): void { function draw(): void {
if (!canvas) return; if (!canvas) return;
ctx = canvas.getContext('2d'); ctx = canvas.getContext('2d');
@ -55,6 +69,12 @@ function draw(): void {
const size = Math.min(w, h) * 0.4; const size = Math.min(w, h) * 0.4;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
// Draw north indicator behind the main arrow
if (northAngle !== null) {
drawNorthIndicator(ctx, cx, cy, size);
}
ctx.save(); ctx.save();
ctx.translate(cx, cy); ctx.translate(cx, cy);
ctx.rotate((currentAngle * Math.PI) / 180); ctx.rotate((currentAngle * Math.PI) / 180);
@ -79,6 +99,42 @@ function draw(): void {
ctx.restore(); ctx.restore();
} }
/** Small triangle and "N" label on the perimeter, pointing inward. */
function drawNorthIndicator(
c: CanvasRenderingContext2D,
cx: number,
cy: number,
arrowSize: number,
): void {
if (northAngle === null) return;
const radius = arrowSize * 1.35;
const tickSize = arrowSize * 0.1;
const rad = (northAngle * Math.PI) / 180;
c.save();
c.translate(cx, cy);
c.rotate(rad);
// Small triangle
c.beginPath();
c.moveTo(0, -radius);
c.lineTo(-tickSize, -radius - tickSize * 1.8);
c.lineTo(tickSize, -radius - tickSize * 1.8);
c.closePath();
c.fillStyle = NORTH_COLOR;
c.fill();
// "N" label
c.font = `${arrowSize * 0.18}px -apple-system, sans-serif`;
c.fillStyle = NORTH_COLOR;
c.textAlign = 'center';
c.textBaseline = 'bottom';
c.fillText('N', 0, -radius - tickSize * 2.2);
c.restore();
}
/** Clean up compass resources. */ /** Clean up compass resources. */
export function destroyCompass(): void { export function destroyCompass(): void {
window.removeEventListener('resize', resizeCanvas); window.removeEventListener('resize', resizeCanvas);

View file

@ -1,8 +1,14 @@
/** /**
* Loading overlay: spinner + message + OK/Skip buttons. * Loading overlay: spinner + message + OK/Skip buttons.
* Same flow as Android: prompt before map caching, user can skip. * Same flow as Android: prompt before map caching, user can skip.
*
* Accessibility: the overlay is a modal dialog (role="dialog", aria-modal).
* Focus is moved into the dialog when shown and restored when hidden.
*/ */
/** Element that had focus before the overlay opened. */
let previousFocus: HTMLElement | null = null;
/** Show the loading overlay with a message and optional spinner. */ /** Show the loading overlay with a message and optional spinner. */
export function showLoading(message: string, showSpinner = true): void { export function showLoading(message: string, showSpinner = true): void {
const overlay = document.getElementById('loading-overlay')!; const overlay = document.getElementById('loading-overlay')!;
@ -10,10 +16,13 @@ export function showLoading(message: string, showSpinner = true): void {
const spinner = document.getElementById('loading-spinner')!; const spinner = document.getElementById('loading-spinner')!;
const buttonRow = document.getElementById('loading-button-row')!; const buttonRow = document.getElementById('loading-button-row')!;
previousFocus = document.activeElement as HTMLElement | null;
text.textContent = message; text.textContent = message;
overlay.setAttribute('aria-label', message);
spinner.style.display = showSpinner ? 'block' : 'none'; spinner.style.display = showSpinner ? 'block' : 'none';
buttonRow.style.display = 'none'; buttonRow.style.display = 'none';
overlay.style.display = 'flex'; overlay.style.display = 'flex';
text.focus();
} }
/** Show the cache prompt (OK / Skip buttons, no spinner). */ /** Show the cache prompt (OK / Skip buttons, no spinner). */
@ -29,7 +38,9 @@ export function showCachePrompt(
const okBtn = document.getElementById('loading-ok-btn')!; const okBtn = document.getElementById('loading-ok-btn')!;
const skipBtn = document.getElementById('loading-skip-btn')!; const skipBtn = document.getElementById('loading-skip-btn')!;
previousFocus = document.activeElement as HTMLElement | null;
text.textContent = message; text.textContent = message;
overlay.setAttribute('aria-label', message);
spinner.style.display = 'none'; spinner.style.display = 'none';
buttonRow.style.display = 'flex'; buttonRow.style.display = 'flex';
overlay.style.display = 'flex'; overlay.style.display = 'flex';
@ -42,6 +53,8 @@ export function showCachePrompt(
hideLoading(); hideLoading();
onSkip(); onSkip();
}; };
okBtn.focus();
} }
/** Update loading text (e.g. progress). */ /** Update loading text (e.g. progress). */
@ -50,8 +63,10 @@ export function updateLoadingText(message: string): void {
if (text) text.textContent = message; if (text) text.textContent = message;
} }
/** Hide the loading overlay. */ /** Hide the loading overlay and restore focus. */
export function hideLoading(): void { export function hideLoading(): void {
const overlay = document.getElementById('loading-overlay'); const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none'; if (overlay) overlay.style.display = 'none';
previousFocus?.focus();
previousFocus = null;
} }

View file

@ -34,8 +34,19 @@ export function updateList(
} }
shelters.forEach((swd, i) => { shelters.forEach((swd, i) => {
const isSelected = i === selectedIndex;
const item = document.createElement('button'); const item = document.createElement('button');
item.className = `shelter-item${i === selectedIndex ? ' selected' : ''}`; item.className = `shelter-item${isSelected ? ' selected' : ''}`;
item.role = 'listitem';
if (isSelected) item.setAttribute('aria-current', 'true');
const details = [
formatDistance(swd.distanceMeters),
t('shelter_capacity', swd.shelter.plasser),
t('shelter_room_nr', swd.shelter.romnr),
].join(' \u00B7 ');
item.setAttribute('aria-label', `${swd.shelter.adresse}, ${details}`);
const addressSpan = document.createElement('span'); const addressSpan = document.createElement('span');
addressSpan.className = 'shelter-item-address'; addressSpan.className = 'shelter-item-address';
@ -43,11 +54,7 @@ export function updateList(
const detailsSpan = document.createElement('span'); const detailsSpan = document.createElement('span');
detailsSpan.className = 'shelter-item-details'; detailsSpan.className = 'shelter-item-details';
detailsSpan.textContent = [ detailsSpan.textContent = details;
formatDistance(swd.distanceMeters),
t('shelter_capacity', swd.shelter.plasser),
t('shelter_room_nr', swd.shelter.romnr),
].join(' \u00B7 ');
item.appendChild(addressSpan); item.appendChild(addressSpan);
item.appendChild(detailsSpan); item.appendChild(detailsSpan);