feat: make route map primary and localize voice guidance

This commit is contained in:
SkateSpots 2026-11-02 15:52:41 +07:00
parent 31f2858edc
commit 7254b5d763
2 changed files with 46 additions and 16 deletions

View file

@ -750,18 +750,23 @@ session with **no other memory of this project**. So:
it** (it's the point of the screen), then the **mode/vehicle row**, then the
**"From" row**, then the scrolling details, with the **Start/Stop bar
pinned** at the bottom. The map is `shrink-0` at
`h-[34vh] min-h-[220px] sm:h-[400px]`, or `h-[54vh] min-h-[320px]`/`sm:h-[60vh]`
`h-[56vh] min-h-[300px] sm:h-[60vh]`, or `h-[60vh] min-h-[340px]`/`sm:h-[66vh]`
while following. The **summary is a compact one-line strip**
(distance · time · arrive) — `StatTile` was **removed**; the three ~90px
tiles were eating the map's space. **Owner feedback (2026-10-01): the map
dominated the phone screen** — it was `50vh` (and `38vh` before that); `34vh`
gives the itinerary and controls room while staying pannable, and follow mode
restores a bigger map. The dialog's tagline ("In-app route · OpenStreetMap
routing — nothing leaves to Google or Apple Maps") was **removed from the
visible chrome** (kept `sr-only` for a11y); that promise now lives on
**/privacy → "What leaves your device" → Directions**. ⚠️ Don't go back to a single
`overflow-hidden` content box: that made everything below the fold
**unreachable** once the map grew. The map carries a metric
tiles were eating the map's space. **Owner feedback (2026-11-02): the map
should be the main thing**; the map grew from `34vh` to `56vh` and the
itinerary/details remain in the scrollable `min-h-0 flex-1 overflow-y-auto`
region. This supersedes the 2026-10-01 “34vh gives the itinerary room”
decision; keep the details scroller, don't make the content box one
`overflow-hidden` block. **Navigation voice (2026-11-02):** TTS follows the
**app language** (`dateLocaleCode()`, covering all 24 locales), not
`navigator.language`; it prefers an installed local voice for the exact
BCP-47 code, then its primary language, and sets `utter.lang` as fallback.
Route prompts already use translated `routeUi.*` keys. The dialog's
tagline (“In-app route · OpenStreetMap routing — nothing leaves to Google
or Apple Maps”) remains **removed from the visible chrome** (kept `sr-only`
for a11y); that promise lives on **/privacy → “What leaves your device” →
Directions**. The map carries a metric
`L.control.scale`. **Transit** mode uses **Transitous** (`api.transitous.org`,
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
modes. **No external map-app handoff anywhere** (product decision) and no

View file

@ -331,6 +331,21 @@ export function RouteDialog({
}, [route]);
// ── Voice guidance (device TTS — free, offline, no server, no permission) ───
// Keep the installed voices fresh; Chrome often fills this list only after
// the first voiceschanged event.
const voicesRef = useRef<SpeechSynthesisVoice[]>([]);
useEffect(() => {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return;
const synth = window.speechSynthesis;
const refresh = () => {
voicesRef.current = synth.getVoices();
};
refresh();
synth.addEventListener?.('voiceschanged', refresh);
return () => synth.removeEventListener?.('voiceschanged', refresh);
}, []);
const speak = useCallback(
(text: string) => {
if (!voiceOn) return;
@ -339,24 +354,34 @@ export function RouteDialog({
const synth = window.speechSynthesis;
const utter = new SpeechSynthesisUtterance(text);
utter.rate = 1;
const lang = (navigator.language || 'en').slice(0, 2).toLowerCase();
const voices = synth.getVoices();
// Prefer an on-device voice in the user's language (offline, no network).
// Follow the APP language, not the phone/OS language. A German user
// with an English device must hear German route prompts.
const lang = dateLocaleCode();
const normalized = lang.toLowerCase();
const primary = normalized.split('-')[0];
const voices = voicesRef.current.length > 0 ? voicesRef.current : synth.getVoices();
const voice =
voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(lang)) ??
voices.find((v) => v.lang.toLowerCase().startsWith(lang));
voices.find((v) => v.localService && v.lang.toLowerCase() === normalized) ??
voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(primary)) ??
voices.find((v) => v.lang.toLowerCase() === normalized) ??
voices.find((v) => v.lang.toLowerCase().startsWith(primary));
if (voice) {
utter.voice = voice;
utter.lang = voice.lang;
} else {
utter.lang = lang;
}
synth.cancel(); // never let prompts queue up and lag behind you
synth.speak(utter);
} catch {
// No TTS — haptics still guide.
}
},
// dateLocaleCode() reads the active app locale at call time, so this
// callback stays correct after a language change.
[voiceOn],
);
@ -864,7 +889,7 @@ export function RouteDialog({
`.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to
0 — a fully invisible map with no route line. */}
<div className={cn('order-2 relative shrink-0 bg-[#0b0b0c]', following ? 'h-[54vh] min-h-[320px] sm:h-[60vh]' : 'h-[34vh] min-h-[220px] sm:h-[400px]')}>
<div className={cn('order-2 relative shrink-0 bg-[#0b0b0c]', following ? 'h-[60vh] min-h-[340px] sm:h-[66vh]' : 'h-[56vh] min-h-[300px] sm:h-[60vh]')}>
<div
ref={setMapDiv}
className="h-full w-full bg-[#0b0b0c]"