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:
parent
f9f8ac3d60
commit
6ba35add2f
17 changed files with 240 additions and 36 deletions
|
|
@ -522,10 +522,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
R.string.direction_arrow_description, distanceText
|
||||
)
|
||||
|
||||
// Update compass view
|
||||
// Update compass view (large arrow gets a north indicator)
|
||||
binding.compassDistanceText.text = distanceText
|
||||
binding.compassAddressText.text = selected.shelter.adresse
|
||||
binding.directionArrow.setDirection(arrowAngle)
|
||||
binding.directionArrow.setNorthAngle(-deviceHeading)
|
||||
binding.directionArrow.contentDescription = getString(
|
||||
R.string.direction_arrow_description, distanceText
|
||||
)
|
||||
|
|
@ -840,6 +841,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
val arrowAngle = bearing - deviceHeading
|
||||
|
||||
binding.directionArrow.setDirection(arrowAngle)
|
||||
binding.directionArrow.setNorthAngle(-deviceHeading)
|
||||
binding.miniArrow.setDirection(arrowAngle)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import no.naiv.tilfluktsrom.R
|
|||
* rotationAngle = shelterBearing - deviceHeading
|
||||
* This gives the direction the user needs to walk, adjusted for which
|
||||
* 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(
|
||||
context: Context,
|
||||
|
|
@ -25,6 +28,7 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var rotationAngle = 0f
|
||||
private var northAngle = Float.NaN
|
||||
|
||||
private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = context.getColor(R.color.shelter_primary)
|
||||
|
|
@ -37,7 +41,18 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
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 northPath = Path()
|
||||
|
||||
/**
|
||||
* Set the rotation angle in degrees.
|
||||
|
|
@ -48,6 +63,16 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
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) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
|
|
@ -55,6 +80,11 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
val cy = height / 2f
|
||||
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.rotate(rotationAngle, cx, cy)
|
||||
|
||||
|
|
@ -74,4 +104,32 @@ class DirectionArrowView @JvmOverloads constructor(
|
|||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,14 +31,15 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:accessibilityLiveRegion="polite"
|
||||
android:textColor="@color/status_text"
|
||||
android:textSize="12sp"
|
||||
tools:text="@string/status_ready" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/infoButton"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_civil_defense_info"
|
||||
android:src="@drawable/ic_info"
|
||||
|
|
@ -46,8 +47,8 @@
|
|||
|
||||
<ImageButton
|
||||
android:id="@+id/refreshButton"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_refresh"
|
||||
android:src="@drawable/ic_refresh"
|
||||
|
|
@ -71,6 +72,7 @@
|
|||
android:id="@+id/mapView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:contentDescription="@string/a11y_map"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet" />
|
||||
|
||||
|
|
@ -80,6 +82,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:background="@color/compass_bg"
|
||||
android:contentDescription="@string/a11y_compass"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusBar"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomSheet">
|
||||
|
|
@ -223,8 +226,8 @@
|
|||
|
||||
<ImageButton
|
||||
android:id="@+id/shareButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_share"
|
||||
|
|
@ -249,6 +252,8 @@
|
|||
android:background="@color/loading_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:importantForAccessibility="yes"
|
||||
android:accessibilityLiveRegion="assertive"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
|
|
@ -262,6 +267,7 @@
|
|||
android:id="@+id/loadingProgress"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:contentDescription="@string/status_loading"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<TextView
|
||||
|
|
|
|||
|
|
@ -65,9 +65,10 @@
|
|||
|
||||
<ImageView
|
||||
android:id="@+id/widgetRefreshButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:padding="8dp"
|
||||
android:contentDescription="@string/action_refresh"
|
||||
android:src="@drawable/ic_refresh" />
|
||||
</LinearLayout>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@
|
|||
<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="compass_accuracy_warning">Upresist kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
|
||||
<!-- Sivilforsvar -->
|
||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@
|
|||
<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="compass_accuracy_warning">Upresis kompass - %s</string>
|
||||
<string name="a11y_map">Tilfluktsromkart</string>
|
||||
<string name="a11y_compass">Kompassnavigasjon</string>
|
||||
|
||||
<!-- Sivilforsvar -->
|
||||
<string name="action_civil_defense_info">Sivilforsvarsinformasjon</string>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@
|
|||
<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="compass_accuracy_warning">Low accuracy - %s</string>
|
||||
<string name="a11y_map">Shelter map</string>
|
||||
<string name="a11y_compass">Compass navigation</string>
|
||||
|
||||
<!-- Civil defense info -->
|
||||
<string name="action_civil_defense_info">Civil defense information</string>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
|
|
@ -16,47 +16,47 @@
|
|||
<body>
|
||||
<div id="app">
|
||||
<!-- Status bar -->
|
||||
<div id="status-bar">
|
||||
<span id="status-text"></span>
|
||||
<header id="status-bar" role="banner">
|
||||
<span id="status-text" aria-live="polite"></span>
|
||||
<button id="refresh-btn" aria-label="Refresh data">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content: map or compass -->
|
||||
<div id="main-content">
|
||||
<div id="map-container"></div>
|
||||
<div id="compass-container">
|
||||
<main id="main-content">
|
||||
<div id="map-container" role="application" aria-label="Map"></div>
|
||||
<div id="compass-container" role="img" aria-label="Compass">
|
||||
<span id="compass-address"></span>
|
||||
<span id="compass-distance"></span>
|
||||
<span id="compass-distance" aria-live="polite"></span>
|
||||
</div>
|
||||
<!-- Toggle map/compass FAB -->
|
||||
<button id="toggle-fab" aria-label="Toggle map/compass view">🧭</button>
|
||||
<!-- Reset view button -->
|
||||
<button id="reset-view-btn" aria-label="Reset view">⌖</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- No-cache warning banner -->
|
||||
<div id="no-cache-banner">
|
||||
<div id="no-cache-banner" role="alert">
|
||||
<span id="no-cache-text"></span>
|
||||
<button id="cache-retry-btn"></button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet with shelter info -->
|
||||
<div id="bottom-sheet">
|
||||
<div id="selected-shelter">
|
||||
<aside id="bottom-sheet" aria-label="Shelter info">
|
||||
<div id="selected-shelter" aria-live="polite">
|
||||
<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-address"></div>
|
||||
<div id="selected-shelter-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="shelter-list"></div>
|
||||
</div>
|
||||
<div id="shelter-list" role="list" aria-label="Nearest shelters"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="loading-overlay">
|
||||
<div id="loading-spinner"></div>
|
||||
<div id="loading-text"></div>
|
||||
<div id="loading-overlay" role="dialog" aria-modal="true" aria-label="Loading">
|
||||
<div id="loading-spinner" aria-hidden="true"></div>
|
||||
<div id="loading-text" aria-live="assertive" tabindex="-1"></div>
|
||||
<div id="loading-button-row">
|
||||
<button id="loading-skip-btn"></button>
|
||||
<button id="loading-ok-btn"></button>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ let firstLocationFix = true;
|
|||
let userSelectedShelter = false;
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
applyA11yLabels();
|
||||
setupMap();
|
||||
setupCompass();
|
||||
setupShelterList();
|
||||
|
|
@ -43,6 +44,16 @@ export async function init(): Promise<void> {
|
|||
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 {
|
||||
const container = document.getElementById('map-container')!;
|
||||
mapView.initMap(container, (shelter: Shelter) => {
|
||||
|
|
@ -268,6 +279,7 @@ function updateSelectedShelter(isUserAction: boolean): void {
|
|||
document.getElementById('compass-address')!.textContent =
|
||||
selected.shelter.adresse;
|
||||
compassView.setDirection(selected.bearingDegrees - deviceHeading);
|
||||
compassView.setNorthAngle(-deviceHeading);
|
||||
|
||||
// Update shelter list selection
|
||||
shelterList.updateList(nearestShelters, selectedShelterIndex);
|
||||
|
|
@ -290,6 +302,7 @@ function onHeadingUpdate(heading: number): void {
|
|||
const angle = selected.bearingDegrees - heading;
|
||||
|
||||
compassView.setDirection(angle);
|
||||
compassView.setNorthAngle(-heading);
|
||||
updateMiniArrow(angle);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,4 +49,8 @@ export const en: Record<string, string> = {
|
|||
|
||||
// Accessibility
|
||||
direction_arrow_description: 'Direction to shelter, %s away',
|
||||
a11y_map: 'Map',
|
||||
a11y_compass: 'Compass',
|
||||
a11y_shelter_info: 'Shelter info',
|
||||
a11y_nearest_shelters: 'Nearest shelters',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,21 +11,22 @@ const locales: Record<string, Record<string, string>> = { en, nb, nn };
|
|||
|
||||
let currentLocale = 'en';
|
||||
|
||||
/** Detect and set locale from browser preferences. */
|
||||
/** Detect and set locale from browser preferences, update document lang. */
|
||||
export function initLocale(): void {
|
||||
const langs = navigator.languages ?? [navigator.language];
|
||||
for (const lang of langs) {
|
||||
const code = lang.toLowerCase().split('-')[0];
|
||||
if (code in locales) {
|
||||
currentLocale = code;
|
||||
return;
|
||||
break;
|
||||
}
|
||||
// nb and nn both start with "n" — also match "no" as Bokmål
|
||||
if (code === 'no') {
|
||||
currentLocale = 'nb';
|
||||
return;
|
||||
break;
|
||||
}
|
||||
}
|
||||
document.documentElement.lang = currentLocale;
|
||||
}
|
||||
|
||||
/** Get current locale code. */
|
||||
|
|
|
|||
|
|
@ -44,4 +44,8 @@ export const nb: Record<string, string> = {
|
|||
|
||||
// Tilgjengelighet
|
||||
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
|
||||
a11y_map: 'Kart',
|
||||
a11y_compass: 'Kompass',
|
||||
a11y_shelter_info: 'Tilfluktsrominfo',
|
||||
a11y_nearest_shelters: 'Nærmeste tilfluktsrom',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,4 +44,8 @@ export const nn: Record<string, string> = {
|
|||
|
||||
// Tilgjenge
|
||||
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
|
||||
a11y_map: 'Kart',
|
||||
a11y_compass: 'Kompass',
|
||||
a11y_shelter_info: 'Tilfluktsrominfo',
|
||||
a11y_nearest_shelters: 'Nærmaste tilfluktsrom',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,21 @@ html, body {
|
|||
-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 {
|
||||
display: flex;
|
||||
|
|
@ -263,6 +278,18 @@ html, body {
|
|||
.shelter-item.selected {
|
||||
border-color: #FF6B35;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,20 @@
|
|||
* Ported from DirectionArrowView.kt — same 7-point arrow polygon.
|
||||
*
|
||||
* 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 OUTLINE_COLOR = '#FFFFFF';
|
||||
const OUTLINE_WIDTH = 4;
|
||||
const NORTH_COLOR = 'rgba(207, 216, 220, 0.6)'; // text_secondary at ~60%
|
||||
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let currentAngle = 0;
|
||||
let northAngle: number | null = null;
|
||||
let animFrameId = 0;
|
||||
|
||||
/** Initialize the compass canvas inside the given container element. */
|
||||
|
|
@ -20,6 +25,7 @@ export function initCompass(container: HTMLElement): void {
|
|||
canvas.id = 'compass-canvas';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
container.prepend(canvas);
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
|
@ -43,6 +49,14 @@ export function setDirection(degrees: number): void {
|
|||
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 {
|
||||
if (!canvas) return;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
|
@ -55,6 +69,12 @@ function draw(): void {
|
|||
const size = Math.min(w, h) * 0.4;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Draw north indicator behind the main arrow
|
||||
if (northAngle !== null) {
|
||||
drawNorthIndicator(ctx, cx, cy, size);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate((currentAngle * Math.PI) / 180);
|
||||
|
|
@ -79,6 +99,42 @@ function draw(): void {
|
|||
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. */
|
||||
export function destroyCompass(): void {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
/**
|
||||
* Loading overlay: spinner + message + OK/Skip buttons.
|
||||
* 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. */
|
||||
export function showLoading(message: string, showSpinner = true): void {
|
||||
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 buttonRow = document.getElementById('loading-button-row')!;
|
||||
|
||||
previousFocus = document.activeElement as HTMLElement | null;
|
||||
text.textContent = message;
|
||||
overlay.setAttribute('aria-label', message);
|
||||
spinner.style.display = showSpinner ? 'block' : 'none';
|
||||
buttonRow.style.display = 'none';
|
||||
overlay.style.display = 'flex';
|
||||
text.focus();
|
||||
}
|
||||
|
||||
/** Show the cache prompt (OK / Skip buttons, no spinner). */
|
||||
|
|
@ -29,7 +38,9 @@ export function showCachePrompt(
|
|||
const okBtn = document.getElementById('loading-ok-btn')!;
|
||||
const skipBtn = document.getElementById('loading-skip-btn')!;
|
||||
|
||||
previousFocus = document.activeElement as HTMLElement | null;
|
||||
text.textContent = message;
|
||||
overlay.setAttribute('aria-label', message);
|
||||
spinner.style.display = 'none';
|
||||
buttonRow.style.display = 'flex';
|
||||
overlay.style.display = 'flex';
|
||||
|
|
@ -42,6 +53,8 @@ export function showCachePrompt(
|
|||
hideLoading();
|
||||
onSkip();
|
||||
};
|
||||
|
||||
okBtn.focus();
|
||||
}
|
||||
|
||||
/** Update loading text (e.g. progress). */
|
||||
|
|
@ -50,8 +63,10 @@ export function updateLoadingText(message: string): void {
|
|||
if (text) text.textContent = message;
|
||||
}
|
||||
|
||||
/** Hide the loading overlay. */
|
||||
/** Hide the loading overlay and restore focus. */
|
||||
export function hideLoading(): void {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
previousFocus?.focus();
|
||||
previousFocus = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,19 @@ export function updateList(
|
|||
}
|
||||
|
||||
shelters.forEach((swd, i) => {
|
||||
const isSelected = i === selectedIndex;
|
||||
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');
|
||||
addressSpan.className = 'shelter-item-address';
|
||||
|
|
@ -43,11 +54,7 @@ export function updateList(
|
|||
|
||||
const detailsSpan = document.createElement('span');
|
||||
detailsSpan.className = 'shelter-item-details';
|
||||
detailsSpan.textContent = [
|
||||
formatDistance(swd.distanceMeters),
|
||||
t('shelter_capacity', swd.shelter.plasser),
|
||||
t('shelter_room_nr', swd.shelter.romnr),
|
||||
].join(' \u00B7 ');
|
||||
detailsSpan.textContent = details;
|
||||
|
||||
item.appendChild(addressSpan);
|
||||
item.appendChild(detailsSpan);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue