import { location } from "$lib/components/lnv/location.svelte"; import { map } from "$lib/components/lnv/map.svelte"; import say, { findLocaleForValhallaLanguage } from "./TTS"; import type { ValhallaRequest } from "./ValhallaRequest"; import type { LngLatBoundsLike } from "maplibre-gl"; import { generateVoiceGuidance } from "./VoiceGuidance"; import { keepScreenOn } from "tauri-plugin-keep-screen-on-api"; import { m } from "$lang/messages"; export const routing = $state({ geojson: { route: null as GeoJSON.Feature | null, routePast: null as GeoJSON.Feature | null, al0: null as GeoJSON.Feature | null, al1: null as GeoJSON.Feature | null, }, currentTrip: null as Trip | null, currentTripInfo: { maneuverIdx: 0, past: [] as WorldLocation[], route: [] as WorldLocation[], int: null as NodeJS.Timeout | null, isOffRoute: false, currentManeuver: null as Maneuver | null, }, }); export function resetRouting() { routing.geojson.route = null; routing.geojson.routePast = null; routing.geojson.al0 = null; routing.geojson.al1 = null; } export async function fetchRoute(server: string, request: ValhallaRequest) { try { const res = await fetch( server + "/route?json=" + JSON.stringify(request), ).then((res) => res.json()); console.log(res); return res; } catch (error) { console.error("Error calculating route:", error); throw error; } } function tripToGeoJSON(trip: Trip): GeoJSON.Feature { const polyline = decodePolyline(trip.legs[0].shape); return { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: polyline.map((p) => [p.lon, p.lat]), }, }; } function geometryToGeoJSON(polyline: WorldLocation[]): GeoJSON.Feature { return { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: polyline.map((p) => [p.lon, p.lat]), }, }; } export function decodePolyline(encoded: string): WorldLocation[] { const points = []; let index = 0; const len = encoded.length; let lat = 0; let lng = 0; while (index < len) { let shift = 0; let result = 0; let byte; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); const deltaLat = result & 1 ? ~(result >> 1) : result >> 1; lat += deltaLat; shift = 0; result = 0; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); const deltaLng = result & 1 ? ~(result >> 1) : result >> 1; lng += deltaLng; // Convert the latitude and longitude to decimal format with six digits of precision points.push({ lat: lat / 1000000, // Divide by 1,000,000 for six digits of precision lon: lng / 1000000, // Divide by 1,000,000 for six digits of precision }); } return points; } export function drawAllRoutes(trips: Trip[]) { routing.geojson.routePast = null; routing.geojson.route = tripToGeoJSON(trips[0]); if (trips[1]) routing.geojson.al0 = tripToGeoJSON(trips[1]); if (trips[2]) routing.geojson.al1 = tripToGeoJSON(trips[2]); } export function drawRoute(trip: Trip) { routing.geojson.route = tripToGeoJSON(trip); } function drawCurrentTrip() { if (!routing.currentTrip) return; routing.geojson.route = geometryToGeoJSON(routing.currentTripInfo.route); routing.geojson.routePast = geometryToGeoJSON( routing.currentTripInfo.past.flat(), ); } export async function startRoute(trip: Trip) { if (window.__TAURI__) { await keepScreenOn(true); } routing.currentTrip = trip; removeAllRoutes(); routing.geojson.route = tripToGeoJSON(trip); routing.currentTripInfo.maneuverIdx = 0; routing.currentTripInfo.past = []; routing.currentTripInfo.isOffRoute = false; drawRoute(trip); routing.currentTripInfo.currentManeuver = routing.currentTrip.legs[0].maneuvers[0]; routing.currentTripInfo.int = setInterval(tickRoute, 500); } let hasAnnouncedPreInstruction = false; const USE_LANDMARK_INSTRUCTIONS = false; async function tickRoute() { const trip = routing.currentTrip; const info = routing.currentTripInfo; if (!trip) return; const currentManeuver = trip.legs[0].maneuvers[routing.currentTripInfo.maneuverIdx]; if (!currentManeuver) { // No more maneuvers, stop navigation stopNavigation(); return; } const bgi = currentManeuver.begin_shape_index; const loc = { lat: location.lat, lon: location.lng, }; const polyline = decodePolyline(trip.legs[0].shape); // Check if the user location is on the last point of the entire route if (isOnPoint(loc, polyline[polyline.length - 1])) { console.log("Reached destination!"); stopNavigation(); return; } // Check if the user is on the route if (!isOnShape(loc, polyline, 30)) { console.log("Off route!"); if (!info.isOffRoute) { say(m["routing.off-route"]()); } info.isOffRoute = true; // TODO: Implement re-routing logic return; } else { if (info.isOffRoute) { say(m["routing.back-on-route"]()); } info.isOffRoute = false; } if ( currentManeuver.verbal_pre_transition_instruction && !hasAnnouncedPreInstruction ) { const distanceToEnd = calculateDistance(loc, polyline[bgi]); // console.log("Distance to end of current maneuver: ", distanceToEnd, " meters"); // console.log("Speed: ", location.speed, " km/h"); const verbalDistance = verbalPreInstructionDistance( location.speed || 50, // Assuming location has a speed property ); if (distanceToEnd <= verbalDistance) { hasAnnouncedPreInstruction = true; const instruction = USE_LANDMARK_INSTRUCTIONS ? await generateVoiceGuidance(currentManeuver, polyline) : currentManeuver.verbal_pre_transition_instruction; console.log( "[Verbal instruction] ", // currentManeuver.verbal_pre_transition_instruction, instruction, ); const locale = findLocaleForValhallaLanguage(trip.language); say(instruction, locale); } } // Check if the user is past the current maneuver // Checks if the user is still on the current maneuver's polyline if (!isOnShape(loc, polyline.slice(bgi))) { return; // User is not on the current maneuver's polyline, do not update } // Update the past and current route polylines info.past = polyline.slice(0, bgi + 1); info.route = polyline.slice(bgi); // Update the geojson drawCurrentTrip(); // announce the "verbal_post_transition_instruction" if (currentManeuver.verbal_post_transition_instruction) { hasAnnouncedPreInstruction = false; const distanceToEnd = calculateDistance( loc, polyline[ trip.legs[0].maneuvers[routing.currentTripInfo.maneuverIdx + 1] .begin_shape_index ], ); if (distanceToEnd >= 200) { console.log( "[Verbal instruction] ", currentManeuver.verbal_post_transition_instruction, ); const locale = findLocaleForValhallaLanguage(trip.language); say(currentManeuver.verbal_post_transition_instruction, locale); } } // Advance to the next maneuver info.maneuverIdx++; if (info.maneuverIdx >= trip.legs[0].maneuvers.length) { // No more maneuvers stopNavigation(); return; } info.currentManeuver = trip.legs[0].maneuvers[info.maneuverIdx]; // queueSpeech(info.currentManeuver.verbal_pre_transition_instruction || ""); // queueSpeech(info.currentManeuver.verbal_post_transition_instruction || ""); // TODO: verbal instructions } function verbalPreInstructionDistance(speed: number): number { return (Math.min(speed, 30) * 2.222 + 37.144) * 2; } export function stopNavigation() { if (routing.currentTripInfo.int) { clearInterval(routing.currentTripInfo.int); routing.currentTripInfo.int = null; } routing.currentTrip = null; map.updateMapPadding(); // TODO: REMOVE removeAllRoutes(); if (window.__TAURI__) { keepScreenOn(false); } } // function getUserLocation(): WorldLocation { // // return geolocate.currentLocation!; // return { // lat: location.lat, // lon: location.lng, // }; // // const lnglat = window.geolocate._userLocationDotMarker.getLngLat(); // // return { lat: lnglat.lat, lon: lnglat.lng }; // // console.log(map.value!) // // return { // // lat: 0, // // lon: 0 // // } // } function isOnLine( location: WorldLocation, from: WorldLocation, to: WorldLocation, toleranceMeters = 12, ) { // Convert the tolerance to degrees (approximation) const tolerance = toleranceMeters / 111320; // 1 degree latitude ≈ 111.32 km // Calculate the vector components const dx = to.lon - from.lon; const dy = to.lat - from.lat; // Calculate the projection of the location onto the line segment const t = ((location.lon - from.lon) * dx + (location.lat - from.lat) * dy) / (dx * dx + dy * dy); // Clamp t to the range [0, 1] to ensure the projection is on the segment const clampedT = Math.max(0, Math.min(1, t)); // Calculate the closest point on the line segment const closestPoint = { lon: from.lon + clampedT * dx, lat: from.lat + clampedT * dy, }; // Calculate the distance from the location to the closest point const distance = Math.sqrt( Math.pow(location.lon - closestPoint.lon, 2) + Math.pow(location.lat - closestPoint.lat, 2), ); // Check if the distance is within the tolerance return distance <= tolerance; } function isOnPoint(location: WorldLocation, point: WorldLocation) { // Convert the 6-meter tolerance to degrees (approximation) const tolerance = 6 / 111320; // 1 degree latitude ≈ 111.32 km // Calculate the distance from the location to the point const distance = Math.sqrt( Math.pow(location.lon - point.lon, 2) + Math.pow(location.lat - point.lat, 2), ); // Check if the distance is within the tolerance return distance <= tolerance; } function isOnShape( location: WorldLocation, shape: WorldLocation[], toleranceMeters = 12, ) { for (let i = 0; i < shape.length - 1; i++) { if (isOnLine(location, shape[i], shape[i + 1], toleranceMeters)) { return true; } } return false; } function calculateDistance( point1: WorldLocation, point2: WorldLocation, ): number { const R = 6371000; // Earth's radius in meters const lat1 = point1.lat * (Math.PI / 180); const lat2 = point2.lat * (Math.PI / 180); const deltaLat = (point2.lat - point1.lat) * (Math.PI / 180); const deltaLon = (point2.lon - point1.lon) * (Math.PI / 180); const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // Distance in meters } export function zoomToPoints( from: WorldLocation, to: WorldLocation, map: maplibregl.Map, ) { const getBoundingBox = ( point1: [number, number], point2: [number, number], ): LngLatBoundsLike => { const [lng1, lat1] = point1; const [lng2, lat2] = point2; const sw = [Math.min(lng1, lng2), Math.min(lat1, lat2)] as [number, number]; const ne = [Math.max(lng1, lng2), Math.max(lat1, lat2)] as [number, number]; return [sw, ne]; }; map.fitBounds(getBoundingBox([from.lon, from.lat], [to.lon, to.lat]), { padding: 40, }); } export function removeAllRoutes() { routing.geojson.route = null; routing.geojson.routePast = null; routing.geojson.al0 = null; routing.geojson.al1 = null; }