2026-10-03 14:29:28 +02:00
|
|
|
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');
|
2026-10-03 19:14:41 +02:00
|
|
|
const [err, setErr] = useState<'denied' | 'none' | 'insecure' | null>(null);
|
2026-10-03 14:29:28 +02:00
|
|
|
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; }
|
2026-10-03 19:14:41 +02:00
|
|
|
if (!window.isSecureContext) { setErr('insecure'); return; } // browsers hand out location on HTTPS only
|
2026-10-03 14:29:28 +02:00
|
|
|
setErr(null);
|
|
|
|
|
const id = navigator.geolocation.watchPosition(
|
|
|
|
|
(p) => { setErr(null); setPos([p.coords.latitude, p.coords.longitude]); },
|
2026-10-03 19:14:41 +02:00
|
|
|
(e) => { if (e.code !== 3) setErr(e.code === 1 ? 'denied' : 'none'); }, // a timeout just means "no fix yet": keep waiting
|
2026-10-03 14:29:28 +02:00
|
|
|
{ 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>
|
|
|
|
|
)}
|
2026-10-03 19:14:41 +02:00
|
|
|
{err && <p className="kbb-guide__meta" role="alert">{t(err === 'denied' ? 'guide.denied' : err === 'insecure' ? 'loc.insecure' : 'guide.noGps')}</p>}
|
2026-10-03 14:29:28 +02:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|