Fiks PWA: sivilforsvarsinfo, knappetekst og kompassvisning

- Info-knappen (ℹ) åpner nå sivilforsvarsdialog med alle 5 steg,
  med «Om»-lenke i bunnen (som Android-appen)
- Laste-overleggets knapper («Lagre kart» / «Hopp over») får
  tekst fra i18n i stedet for å være tomme
- Kompassvisningen resizes canvas når den blir synlig, fikser
  0×0-canvas når den initialiseres mens containeren er skjult

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 15:51:42 +01:00
commit 7d40c9e9a8
8 changed files with 179 additions and 3 deletions

View file

@ -20,7 +20,7 @@ import * as shelterList from './ui/shelter-list';
import * as statusBar from './ui/status-bar';
import * as loading from './ui/loading-overlay';
import * as mapCache from './cache/map-cache-manager';
import * as aboutDialog from './ui/about-dialog';
import * as civilDefenseDialog from './ui/civil-defense-dialog';
const NEAREST_COUNT = 3;
@ -47,10 +47,10 @@ export async function init(): Promise<void> {
/** Set localized aria-labels and wire the about button. */
function applyA11yLabels(): void {
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_about'));
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_civil_defense_info'));
document.getElementById('about-btn')?.addEventListener('click', () => {
navigator.vibrate?.(10);
aboutDialog.showAbout();
civilDefenseDialog.showCivilDefenseInfo();
});
document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map'));
document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass'));
@ -108,6 +108,7 @@ function setupButtons(): void {
}
mapContainer.style.display = 'none';
compassContainer.classList.add('active');
compassView.resize();
toggleFab.textContent = '\uD83D\uDDFA\uFE0F'; // map emoji
compassProvider.startCompass(onHeadingUpdate);
} else {

View file

@ -54,6 +54,21 @@ export const en: Record<string, string> = {
a11y_shelter_info: 'Shelter info',
a11y_nearest_shelters: 'Nearest shelters',
// Civil defense
action_civil_defense_info: 'Civil defense information',
civil_defense_title: 'What to do if the alarm sounds',
civil_defense_step1_title: '1. Important message signal',
civil_defense_step1_body: 'Three series of short blasts with one minute of silence between each series. This means: seek information immediately. Turn on DAB radio, TV, or check official sources online.',
civil_defense_step2_title: '2. Air raid alarm',
civil_defense_step2_body: 'Short blasts lasting approximately one minute. This means immediate danger of attack — seek shelter now. Go to the nearest shelter, basement, or inner room immediately.',
civil_defense_step3_title: '3. Go indoors and find shelter',
civil_defense_step3_body: 'Get indoors. Close all windows, doors, and ventilation openings. Use this app to find the nearest public shelter. The compass and map work offline. If no shelter is nearby, go to a basement or an inner room away from windows.',
civil_defense_step4_title: '4. Listen to NRK on DAB radio',
civil_defense_step4_body: 'Tune in to NRK P1 on DAB radio for official updates and instructions from authorities. DAB radio works even when mobile networks and the internet are down.',
civil_defense_step5_title: '5. All clear',
civil_defense_step5_body: 'One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities.',
civil_defense_source: 'Source: DSB (Norwegian Directorate for Civil Protection)',
// About
about_title: 'About Tilfluktsrom',
about_description:

View file

@ -49,6 +49,21 @@ export const nb: Record<string, string> = {
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmeste tilfluktsrom',
// Sivilforsvar
action_civil_defense_info: 'Sivilforsvarsinformasjon',
civil_defense_title: 'Hva du skal gjøre hvis alarmen går',
civil_defense_step1_title: '1. Viktig melding-signalet',
civil_defense_step1_body: 'Tre serier med korte støt med ett minutts stillhet mellom hver serie. Dette betyr: søk informasjon umiddelbart. Slå på DAB-radio, TV, eller sjekk offisielle kilder på nett.',
civil_defense_step2_title: '2. Flyalarm',
civil_defense_step2_body: 'Korte støt som varer omtrent ett minutt. Dette betyr umiddelbar fare for angrep — søk dekning nå. Gå til nærmeste tilfluktsrom, kjeller eller innerrom umiddelbart.',
civil_defense_step3_title: '3. Gå innendørs og finn dekning',
civil_defense_step3_body: 'Kom deg innendørs. Lukk alle vinduer, dører og ventilasjonsåpninger. Bruk denne appen for å finne nærmeste offentlige tilfluktsrom. Kompasset og kartet fungerer uten internett. Hvis det ikke er noe tilfluktsrom i nærheten, gå til en kjeller eller et innerrom bort fra vinduer.',
civil_defense_step4_title: '4. Lytt til NRK på DAB-radio',
civil_defense_step4_body: 'Lytt til NRK P1 på DAB-radio for offisielle oppdateringer og instruksjoner fra myndighetene. DAB-radio fungerer selv når mobilnettet og internett er nede.',
civil_defense_step5_title: '5. Faren over',
civil_defense_step5_body: 'Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene.',
civil_defense_source: 'Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap)',
// Om
about_title: 'Om Tilfluktsrom',
about_description:

View file

@ -49,6 +49,21 @@ export const nn: Record<string, string> = {
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmaste tilfluktsrom',
// Sivilforsvar
action_civil_defense_info: 'Sivilforsvarsinformasjon',
civil_defense_title: 'Kva du skal gjere om alarmen går',
civil_defense_step1_title: '1. Viktig melding-signalet',
civil_defense_step1_body: 'Tre seriar med korte støyt med eitt minutt stille mellom kvar serie. Dette tyder: søk informasjon med ein gong. Slå på DAB-radio, TV, eller sjekk offisielle kjelder på nett.',
civil_defense_step2_title: '2. Flyalarm',
civil_defense_step2_body: 'Korte støyt som varar omtrent eitt minutt. Dette tyder umiddelbar fare for åtak — søk dekning no. Gå til næraste tilfluktsrom, kjellar eller innerrom med ein gong.',
civil_defense_step3_title: '3. Gå innandørs og finn dekning',
civil_defense_step3_body: 'Kom deg innandørs. Lukk alle vindauge, dører og ventilasjonsopningar. Bruk denne appen for å finne næraste offentlege tilfluktsrom. Kompasset og kartet fungerer utan internett. Om det ikkje er noko tilfluktsrom i nærleiken, gå til ein kjellar eller eit innerrom bort frå vindauge.',
civil_defense_step4_title: '4. Lytt til NRK på DAB-radio',
civil_defense_step4_body: 'Lytt til NRK P1 på DAB-radio for offisielle oppdateringar og instruksjonar frå styresmaktene. DAB-radio fungerer sjølv når mobilnettet og internett er nede.',
civil_defense_step5_title: '5. Faren over',
civil_defense_step5_body: 'Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene.',
civil_defense_source: 'Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap)',
// Om
about_title: 'Om Tilfluktsrom',
about_description:

View file

@ -458,6 +458,30 @@ html, body {
margin-bottom: 2px;
}
#civil-defense-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.about-link-btn {
display: block;
margin: 16px 0 0;
padding: 0;
background: none;
border: none;
color: #FF6B35;
font-size: 14px;
cursor: pointer;
min-height: 48px;
line-height: 48px;
}
.about-clear-btn {
display: block;
margin: 12px 0 0;

View file

@ -0,0 +1,96 @@
/**
* Civil defense info dialog: what to do when the alarm sounds.
* Same content as the Android CivilDefenseInfoDialog.
* Links to the about dialog at the bottom.
*/
import { t } from '../i18n/i18n';
import { showAbout } from './about-dialog';
let overlay: HTMLDivElement | null = null;
let previousFocus: HTMLElement | null = null;
/** Show the civil defense info dialog. */
export function showCivilDefenseInfo(): void {
if (overlay) return;
previousFocus = document.activeElement as HTMLElement | null;
overlay = document.createElement('div');
overlay.id = 'civil-defense-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', t('civil_defense_title'));
const content = document.createElement('div');
content.className = 'about-content';
content.appendChild(heading(t('civil_defense_title')));
for (let i = 1; i <= 5; i++) {
content.appendChild(subheading(t(`civil_defense_step${i}_title`)));
content.appendChild(para(t(`civil_defense_step${i}_body`)));
}
content.appendChild(small(t('civil_defense_source')));
// "About this app" link
const aboutLink = document.createElement('button');
aboutLink.className = 'about-link-btn';
aboutLink.textContent = t('action_about');
aboutLink.addEventListener('click', () => {
hideCivilDefenseInfo();
showAbout();
});
content.appendChild(aboutLink);
const closeBtn = document.createElement('button');
closeBtn.className = 'about-close-btn';
closeBtn.textContent = t('action_close');
closeBtn.addEventListener('click', hideCivilDefenseInfo);
content.appendChild(closeBtn);
overlay.appendChild(content);
document.body.appendChild(overlay);
closeBtn.focus();
}
/** Hide the dialog and restore focus. */
export function hideCivilDefenseInfo(): void {
if (overlay) {
overlay.remove();
overlay = null;
}
previousFocus?.focus();
previousFocus = null;
}
function heading(text: string): HTMLElement {
const el = document.createElement('h2');
el.textContent = text;
el.className = 'about-heading';
return el;
}
function subheading(text: string): HTMLElement {
const el = document.createElement('h3');
el.textContent = text;
el.className = 'about-subheading';
return el;
}
function para(text: string): HTMLElement {
const el = document.createElement('p');
el.textContent = text;
el.className = 'about-para';
return el;
}
function small(text: string): HTMLElement {
const el = document.createElement('p');
el.textContent = text;
el.className = 'about-small';
el.style.fontStyle = 'italic';
return el;
}

View file

@ -135,6 +135,11 @@ function drawNorthIndicator(
c.restore();
}
/** Resize the canvas (call when the compass container becomes visible). */
export function resize(): void {
resizeCanvas();
}
/** Clean up compass resources. */
export function destroyCompass(): void {
window.removeEventListener('resize', resizeCanvas);

View file

@ -6,6 +6,8 @@
* Focus is moved into the dialog when shown and restored when hidden.
*/
import { t } from '../i18n/i18n';
/** Element that had focus before the overlay opened. */
let previousFocus: HTMLElement | null = null;
@ -45,6 +47,9 @@ export function showCachePrompt(
buttonRow.style.display = 'flex';
overlay.style.display = 'flex';
okBtn.textContent = t('action_cache_ok');
skipBtn.textContent = t('action_skip');
okBtn.onclick = () => {
hideLoading();
onOk();