GdzieWjade/src/components/GuideBanner.tsx

150 lines
7.4 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import type { LatLon } from '../lib/geo';
import { barrierPositions, buildPath, guideState, maneuvers, pointAt, type GuideState } from '../lib/guide';
import { CueTracker, VoicePlayer, type Utt } from '../lib/voice';
import { useI18n, type Key } from '../lib/i18n';
import { HARD_FLAGS, type RouteResult } from '../lib/route';
import { Icon } from './ui';
interface Props {
result: RouteResult;
onStop: () => void;
onPos: (p: LatLon | null) => void;
onReroute: (from: LatLon) => void;
}
const SIM_STEP_M = 6;
/** Live guidance: watches GPS (or a simulated walk), shows the next turn and upcoming barriers on top. */
export function GuideBanner({ result, onStop, onPos, onReroute }: Props) {
const { t, lang } = useI18n();
const path = useMemo(() => buildPath(result.coords), [result]);
const mans = useMemo(() => maneuvers(result, path), [result, path]);
const barriers = useMemo(() => barrierPositions(path, result.barriers, HARD_FLAGS), [path, result]);
const [pos, setPos] = useState<LatLon | null>(null);
const [sim, setSim] = useState(false);
const [voice, setVoice] = useState<'off' | 'all' | 'warn'>('off');
const [err, setErr] = useState<'denied' | 'none' | 'insecure' | null>(null);
const simAlong = useRef(0);
// ---- position source ----
useEffect(() => {
if (sim) {
setErr(null);
simAlong.current = 0;
setPos(pointAt(path, 0));
const id = window.setInterval(() => {
simAlong.current = Math.min(path.total, simAlong.current + SIM_STEP_M);
setPos(pointAt(path, simAlong.current));
}, 1000);
return () => window.clearInterval(id);
}
if (!('geolocation' in navigator)) { setErr('none'); return; }
if (!window.isSecureContext) { setErr('insecure'); return; } // browsers hand out location on HTTPS only
setErr(null);
const id = navigator.geolocation.watchPosition(
(p) => { setErr(null); setPos([p.coords.latitude, p.coords.longitude]); },
(e) => { if (e.code !== 3) setErr(e.code === 1 ? 'denied' : 'none'); }, // a timeout just means "no fix yet": keep waiting
{ enableHighAccuracy: true, maximumAge: 1000, timeout: 15_000 },
);
return () => navigator.geolocation.clearWatch(id);
}, [sim, path]);
useEffect(() => { onPos(pos); }, [pos, onPos]);
useEffect(() => () => onPos(null), [onPos]);
// ---- keep the screen on while guiding ----
useEffect(() => {
let lock: { release: () => Promise<void> } | null = null;
const wl = (navigator as unknown as { wakeLock?: { request: (t: 'screen') => Promise<{ release: () => Promise<void> }> } }).wakeLock;
void wl?.request('screen').then((l) => { lock = l; }).catch(() => undefined);
return () => { void lock?.release().catch(() => undefined); };
}, []);
const st = pos ? guideState(path, mans, barriers, pos) : null;
const round5 = (d: number) => Math.max(5, Math.round(d / 5) * 5);
let headline = t('guide.waiting');
if (st) {
if (st.arrived) headline = t('guide.arrived');
else if (st.offRoute) headline = t('guide.off');
else if (st.next) {
const m = st.next.maneuver;
headline = t('guide.next', { d: round5(st.next.distance), action: t(`turn.${m.turn}` as Key), name: m.name ? ` — ${m.name}` : '' });
} else headline = t('guide.straight', { d: round5(st.remaining) });
}
const barrierLine = st && !st.arrived && !st.offRoute && st.barrier
? t('guide.barrier', { d: round5(st.barrier.distance), what: t(`flag.${st.barrier.b.flag}` as Key) })
: null;
// ---- speech: events only (turn within 25 m, barrier within 60 m, off-route, arrival), never overlapping ----
const latest = useRef<GuideState | null>(null);
latest.current = st;
const player = useRef<VoicePlayer | null>(null);
const tracker = useRef<CueTracker | null>(null);
useEffect(() => {
if (voice === 'off' || !('speechSynthesis' in window)) { player.current?.stop(); player.current = null; tracker.current = null; return; }
const synth = window.speechSynthesis;
const make = (text: string): Utt => {
const u = new SpeechSynthesisUtterance(text);
u.lang = lang === 'pl' ? 'pl-PL' : 'en-GB';
return u as unknown as Utt;
};
player.current = new VoicePlayer({ speak: (u) => synth.speak(u as unknown as SpeechSynthesisUtterance), cancel: () => synth.cancel() }, make);
tracker.current = new CueTracker(
{
turn: (d, turn) => t('voice.turn', { d, turn }),
barrier: (d, what) => t('voice.barrier', { d, what }),
off: t('voice.off'),
arrived: t('voice.arrived'),
},
{ turn: (x) => t(`turn.${x}` as Key).toLocaleLowerCase(lang), flag: (f) => t(`flag.${f}` as Key).toLocaleLowerCase(lang) },
(at) => (latest.current?.along ?? 0) < at - 3,
);
player.current.say({ id: 'on', kind: 'info', text: t(voice === 'all' ? 'voice.onAll' : 'voice.onWarn'), priority: 1 });
return () => { player.current?.stop(); player.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [voice, lang]);
useEffect(() => {
if (!st || !player.current || !tracker.current) return;
for (const cue of tracker.current.update(st)) {
if (voice === 'warn' && cue.kind === 'turn') continue; // warnings-only mode: no turn prompts
player.current.say(cue);
}
}, [st, voice]);
const etaMin = st ? Math.max(1, Math.round(result.durationMin * (st.remaining / Math.max(1, path.total)))) : null;
return (
<section className="kbb-guide" aria-label={t('guide.active')}>
<div className="kbb-guide__main">
<p className="kbb-guide__headline" role="status" aria-live="polite">
<Icon name={st?.arrived ? 'check_circle' : st?.offRoute ? 'error' : 'directions_walk'} size={4} className="margin-right-1" />
{headline}
</p>
{barrierLine && <p className="kbb-guide__barrier" role="alert"><Icon name="warning" size={3} className="margin-right-05" />{barrierLine}</p>}
{st && !st.arrived && (
<p className="kbb-guide__meta">{t('guide.remaining', { km: (st.remaining / 1000).toFixed(2), min: etaMin ?? 0 })}</p>
)}
{err && <p className="kbb-guide__meta" role="alert">{t(err === 'denied' ? 'guide.denied' : err === 'insecure' ? 'loc.insecure' : 'guide.noGps')}</p>}
{sim && <p className="kbb-guide__meta">{t('guide.simOn')}</p>}
</div>
<div className="kbb-guide__actions">
{st?.offRoute && pos && (
<button type="button" className="usa-button usa-button--accent-cool margin-0" onClick={() => onReroute(pos)}>{t('guide.reroute')}</button>
)}
<div className="kbb-guide__voice">
<label className="usa-sr-only" htmlFor="guide-voice">{t('guide.voice')}</label>
<select id="guide-voice" className="usa-select kbb-guide__select" value={voice} onChange={(e) => setVoice(e.target.value as 'off' | 'all' | 'warn')}>
<option value="off">{t('guide.voice.off')}</option>
<option value="all">{t('guide.voice.all')}</option>
<option value="warn">{t('guide.voice.warn')}</option>
</select>
</div>
<button type="button" className="usa-button usa-button--outline usa-button--inverse margin-0" aria-pressed={sim} onClick={() => setSim((v) => !v)}>{t('guide.demo')}</button>
<button type="button" className="usa-button usa-button--secondary margin-0" onClick={onStop}>{t('guide.stop')}</button>
</div>
</section>
);
}