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(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 } | null = null; const wl = (navigator as unknown as { wakeLock?: { request: (t: 'screen') => Promise<{ release: () => Promise }> } }).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(null); latest.current = st; const player = useRef(null); const tracker = useRef(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 (

{headline}

{barrierLine &&

{barrierLine}

} {st && !st.arrived && (

{t('guide.remaining', { km: (st.remaining / 1000).toFixed(2), min: etaMin ?? 0 })}

)} {err &&

{t(err === 'denied' ? 'guide.denied' : err === 'insecure' ? 'loc.insecure' : 'guide.noGps')}

} {sim &&

{t('guide.simOn')}

}
{st?.offRoute && pos && ( )}
); }