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

View file

@ -331,6 +331,21 @@ export function RouteDialog({
}, [route]); }, [route]);
// ── Voice guidance (device TTS — free, offline, no server, no permission) ─── // ── 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( const speak = useCallback(
(text: string) => { (text: string) => {
if (!voiceOn) return; if (!voiceOn) return;
@ -339,24 +354,34 @@ export function RouteDialog({
const synth = window.speechSynthesis; const synth = window.speechSynthesis;
const utter = new SpeechSynthesisUtterance(text); const utter = new SpeechSynthesisUtterance(text);
utter.rate = 1; utter.rate = 1;
const lang = (navigator.language || 'en').slice(0, 2).toLowerCase();
const voices = synth.getVoices(); // Follow the APP language, not the phone/OS language. A German user
// Prefer an on-device voice in the user's language (offline, no network). // 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 = const voice =
voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(lang)) ?? voices.find((v) => v.localService && v.lang.toLowerCase() === normalized) ??
voices.find((v) => v.lang.toLowerCase().startsWith(lang)); 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) { if (voice) {
utter.voice = voice; utter.voice = voice;
utter.lang = voice.lang; utter.lang = voice.lang;
} else { } else {
utter.lang = lang; utter.lang = lang;
} }
synth.cancel(); // never let prompts queue up and lag behind you synth.cancel(); // never let prompts queue up and lag behind you
synth.speak(utter); synth.speak(utter);
} catch { } catch {
// No TTS — haptics still guide. // No TTS — haptics still guide.
} }
}, },
// dateLocaleCode() reads the active app locale at call time, so this
// callback stays correct after a language change.
[voiceOn], [voiceOn],
); );
@ -864,7 +889,7 @@ export function RouteDialog({
`.leaflet-container { height: 100% }` override beats a `h-64` on the `.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to map element itself, and against a height-less parent that resolved to
0 — a fully invisible map with no route line. */} 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 <div
ref={setMapDiv} ref={setMapDiv}
className="h-full w-full bg-[#0b0b0c]" className="h-full w-full bg-[#0b0b0c]"