Spaces:
Configuration error
Configuration error
Upload 4 files
Browse files- src/lib/notifications.ts +60 -0
- src/lib/orsApi.ts +114 -0
- src/lib/routeCache.ts +47 -0
- src/lib/voiceNavigation.ts +76 -0
src/lib/notifications.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export async function requestNotificationPermission(): Promise<boolean> {
|
| 2 |
+
if (!('Notification' in window)) {
|
| 3 |
+
console.warn('This browser does not support notifications');
|
| 4 |
+
return false;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
if (Notification.permission === 'granted') {
|
| 8 |
+
return true;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
if (Notification.permission !== 'denied') {
|
| 12 |
+
const permission = await Notification.requestPermission();
|
| 13 |
+
return permission === 'granted';
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return false;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
|
| 20 |
+
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
| 21 |
+
return null;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const notification = new Notification(title, {
|
| 25 |
+
icon: '/favicon.ico',
|
| 26 |
+
badge: '/favicon.ico',
|
| 27 |
+
...options
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
setTimeout(() => notification.close(), 5000);
|
| 31 |
+
|
| 32 |
+
return notification;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function showWeatherAlert(weather: string, temp: number): void {
|
| 36 |
+
const badWeather = ['Rain', 'Drizzle', 'Snow', 'Thunderstorm', 'Fog', 'Mist'];
|
| 37 |
+
if (!badWeather.includes(weather)) return;
|
| 38 |
+
|
| 39 |
+
showNotification('Weather Alert', {
|
| 40 |
+
body: `${weather} detected! Temperature: ${Math.round(temp - 273.15)}°C. Drive safely!`,
|
| 41 |
+
tag: 'weather-alert',
|
| 42 |
+
requireInteraction: false
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function showRouteAlert(distance: string, duration: string, safetyScore: number): void {
|
| 47 |
+
const emoji = safetyScore >= 70 ? '✅' : safetyScore >= 40 ? '⚠️' : '🚨';
|
| 48 |
+
showNotification('Route Ready', {
|
| 49 |
+
body: `${emoji} ${distance} • ${duration} • Safety: ${safetyScore}%`,
|
| 50 |
+
tag: 'route-ready'
|
| 51 |
+
});
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function showIncidentAlert(incidentType: string, severity: string): void {
|
| 55 |
+
showNotification('Incident Nearby', {
|
| 56 |
+
body: `${incidentType} reported - ${severity} severity. Please drive carefully.`,
|
| 57 |
+
tag: 'incident-alert',
|
| 58 |
+
requireInteraction: true
|
| 59 |
+
});
|
| 60 |
+
}
|
src/lib/orsApi.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
|
| 3 |
+
const API_KEY = import.meta.env.VITE_OPENROUTESERVICE_API_KEY || '';
|
| 4 |
+
|
| 5 |
+
export async function getORSRoute(
|
| 6 |
+
start: [number, number],
|
| 7 |
+
end: [number, number],
|
| 8 |
+
profile: string = 'driving-car'
|
| 9 |
+
): Promise<any> {
|
| 10 |
+
const res = await axios.post(
|
| 11 |
+
`https://api.openrouteservice.org/v2/directions/${profile}`,
|
| 12 |
+
{
|
| 13 |
+
coordinates: [
|
| 14 |
+
[start[0], start[1]],
|
| 15 |
+
[end[0], end[1]]
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
headers: {
|
| 20 |
+
'Content-Type': 'application/json',
|
| 21 |
+
'Authorization': API_KEY
|
| 22 |
+
},
|
| 23 |
+
params: {
|
| 24 |
+
geometries: 'geojson',
|
| 25 |
+
overview: 'full',
|
| 26 |
+
steps: true
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
return res.data;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export async function getORSRouteWithWaypoints(
|
| 35 |
+
coordinates: [number, number][],
|
| 36 |
+
profile: string = 'driving-car'
|
| 37 |
+
): Promise<any> {
|
| 38 |
+
const res = await axios.post(
|
| 39 |
+
`https://api.openrouteservice.org/v2/directions/${profile}`,
|
| 40 |
+
{
|
| 41 |
+
coordinates: coordinates.map(([lng, lat]) => [lng, lat])
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
headers: {
|
| 45 |
+
'Content-Type': 'application/json',
|
| 46 |
+
'Authorization': API_KEY
|
| 47 |
+
},
|
| 48 |
+
params: {
|
| 49 |
+
geometries: 'geojson',
|
| 50 |
+
overview: 'full',
|
| 51 |
+
steps: true
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
return res.data;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function searchLocation(query: string) {
|
| 60 |
+
const res = await axios.get(
|
| 61 |
+
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=10`
|
| 62 |
+
);
|
| 63 |
+
return res.data;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function reverseGeocode(lat: number, lon: number) {
|
| 67 |
+
const res = await axios.get(
|
| 68 |
+
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`
|
| 69 |
+
);
|
| 70 |
+
return res.data;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function decodePolyline(encoded: string): [number, number][] {
|
| 74 |
+
const poly: [number, number][] = [];
|
| 75 |
+
let index = 0;
|
| 76 |
+
const len = encoded.length;
|
| 77 |
+
let lat = 0;
|
| 78 |
+
let lng = 0;
|
| 79 |
+
|
| 80 |
+
while (index < len) {
|
| 81 |
+
let b;
|
| 82 |
+
let shift = 0;
|
| 83 |
+
let result = 0;
|
| 84 |
+
|
| 85 |
+
do {
|
| 86 |
+
b = encoded.charCodeAt(index++) - 63;
|
| 87 |
+
result |= (b & 0x1f) << shift;
|
| 88 |
+
shift += 5;
|
| 89 |
+
} while (b >= 0x20);
|
| 90 |
+
|
| 91 |
+
const dlat = (result & 1) !== 0 ? ~(result >> 1) : (result >> 1);
|
| 92 |
+
lat += dlat;
|
| 93 |
+
|
| 94 |
+
shift = 0;
|
| 95 |
+
result = 0;
|
| 96 |
+
|
| 97 |
+
do {
|
| 98 |
+
b = encoded.charCodeAt(index++) - 63;
|
| 99 |
+
result |= (b & 0x1f) << shift;
|
| 100 |
+
shift += 5;
|
| 101 |
+
} while (b >= 0x20);
|
| 102 |
+
|
| 103 |
+
const dlng = (result & 1) !== 0 ? ~(result >> 1) : (result >> 1);
|
| 104 |
+
lng += dlng;
|
| 105 |
+
|
| 106 |
+
poly.push([lat / 1e5, lng / 1e5]);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return poly;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export function coordsToLeaflet(coords: [number, number][]): [number, number][] {
|
| 113 |
+
return coords.map(([lng, lat]) => [lat, lng] as [number, number]);
|
| 114 |
+
}
|
src/lib/routeCache.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface CacheEntry<T> {
|
| 2 |
+
data: T;
|
| 3 |
+
timestamp: number;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
const cache = new Map<string, CacheEntry<unknown>>();
|
| 7 |
+
const DEFAULT_TTL = 3 * 60 * 1000; // 3 minutes
|
| 8 |
+
|
| 9 |
+
export function getCached<T>(key: string): T | null {
|
| 10 |
+
const entry = cache.get(key) as CacheEntry<T> | undefined;
|
| 11 |
+
if (!entry) return null;
|
| 12 |
+
|
| 13 |
+
const age = Date.now() - entry.timestamp;
|
| 14 |
+
if (age > DEFAULT_TTL) {
|
| 15 |
+
cache.delete(key);
|
| 16 |
+
return null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
return entry.data;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function setCache<T>(key: string, data: T, ttl = DEFAULT_TTL): void {
|
| 23 |
+
if (!data || (Array.isArray(data) && data.length === 0)) {
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
cache.set(key, { data, timestamp: Date.now() });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function clearCache(): void {
|
| 30 |
+
cache.clear();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function getCacheKey(prefix: string, ...params: (string | number | boolean)[]): string {
|
| 34 |
+
return `${prefix}:${params.join(':')}`;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function generateRouteCacheKey(start: [number, number], end: [number, number], profile: string): string {
|
| 38 |
+
return getCacheKey('route', start.join(','), end.join(','), profile);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function generateGeocodeCacheKey(query: string): string {
|
| 42 |
+
return getCacheKey('geocode', query.toLowerCase().trim());
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function generateWeatherCacheKey(lat: number, lon: number): string {
|
| 46 |
+
return getCacheKey('weather', lat.toFixed(3), lon.toFixed(3));
|
| 47 |
+
}
|
src/lib/voiceNavigation.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let utterance: SpeechSynthesisUtterance | null = null;
|
| 2 |
+
|
| 3 |
+
export function speak(text: string): void {
|
| 4 |
+
if (!('speechSynthesis' in window)) {
|
| 5 |
+
console.warn('Speech synthesis not supported');
|
| 6 |
+
return;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
window.speechSynthesis.cancel();
|
| 10 |
+
|
| 11 |
+
utterance = new SpeechSynthesisUtterance(text);
|
| 12 |
+
utterance.rate = 1.0;
|
| 13 |
+
utterance.pitch = 1.0;
|
| 14 |
+
utterance.volume = 1.0;
|
| 15 |
+
|
| 16 |
+
const voices = window.speechSynthesis.getVoices();
|
| 17 |
+
const englishVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Google')) ||
|
| 18 |
+
voices.find(v => v.lang.startsWith('en'));
|
| 19 |
+
if (englishVoice) {
|
| 20 |
+
utterance.voice = englishVoice;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
window.speechSynthesis.speak(utterance);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function stopSpeaking(): void {
|
| 27 |
+
if ('speechSynthesis' in window) {
|
| 28 |
+
window.speechSynthesis.cancel();
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function isSpeaking(): boolean {
|
| 33 |
+
if (!('speechSynthesis' in window)) return false;
|
| 34 |
+
return window.speechSynthesis.speaking;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function getAvailableVoices(): SpeechSynthesisVoice[] {
|
| 38 |
+
if (!('speechSynthesis' in window)) return [];
|
| 39 |
+
return window.speechSynthesis.getVoices().filter(v => v.lang.startsWith('en'));
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function formatDirectionForSpeech(instruction: string): string {
|
| 43 |
+
return instruction
|
| 44 |
+
.replace(/onto/gi, 'onto')
|
| 45 |
+
.replace(/Continue/gi, 'Continue')
|
| 46 |
+
.replace(/Turn left/gi, 'Turn left')
|
| 47 |
+
.replace(/Turn right/gi, 'Turn right')
|
| 48 |
+
.replace(/Slight left/gi, 'Bear left')
|
| 49 |
+
.replace(/Slight right/gi, 'Bear right')
|
| 50 |
+
.replace(/km/g, 'kilometers')
|
| 51 |
+
.replace(/m/g, 'meters')
|
| 52 |
+
.trim();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function speakDirection(step: { maneuver: { type?: string; modifier?: string; instruction?: string; name?: string }; distance?: number }): void {
|
| 56 |
+
let text = '';
|
| 57 |
+
|
| 58 |
+
if (step.maneuver.instruction) {
|
| 59 |
+
text = formatDirectionForSpeech(step.maneuver.instruction);
|
| 60 |
+
} else {
|
| 61 |
+
const type = step.maneuver.type || '';
|
| 62 |
+
const modifier = step.maneuver.modifier || '';
|
| 63 |
+
const name = step.maneuver.name || '';
|
| 64 |
+
text = `${type} ${modifier}`.trim();
|
| 65 |
+
if (name) text += ` onto ${name}`;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (step.distance && step.distance > 0) {
|
| 69 |
+
const distText = step.distance >= 1000
|
| 70 |
+
? `${(step.distance / 1000).toFixed(1)} kilometers`
|
| 71 |
+
: `${Math.round(step.distance)} meters`;
|
| 72 |
+
text += `. Then go ${distText}.`;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
speak(text);
|
| 76 |
+
}
|