feat: make route map primary and localize voice guidance
This commit is contained in:
parent
31f2858edc
commit
7254b5d763
2 changed files with 46 additions and 16 deletions
25
AGENTS.md
25
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue