Spaces:
Build error
Build error
| import os | |
| import base64 | |
| import requests | |
| import gradio as gr | |
| import pandas as pd | |
| import folium | |
| from src.gpx_parser import parse_gpx_file, haversine, save_enhanced_gpx | |
| import src.llm as llm | |
| import src.rag as rag | |
| import src.database as db | |
| # Initialize cache and temp folders and SQLite database | |
| os.makedirs("./temp", exist_ok=True) | |
| db.init_db() | |
| # Preloaded route path — resolve relative to this file for cross-platform compatibility | |
| _APP_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| PRELOADED_ROUTE_PATH = os.path.join(_APP_DIR, "Routes", "track_5-14724236830.gpx") | |
| # Bundled pre-parsed cache for the Trento track (includes POIs — avoids Overpass API on HF) | |
| PRELOADED_CACHE_PATH = os.path.join(_APP_DIR, "src", "data", "trento_route_cache.json") | |
| MAP_HTML_INITIALIZER = """ | |
| <div id="trailhead-leaflet-map" style="height: 520px; width: 100%; border:1px solid rgba(245,158,11,0.2); border-radius: 12px; background: #0c1014; z-index: 1;"></div> | |
| """ | |
| MAP_INIT_JS = r""" | |
| () => { | |
| // Hide communication textboxes instantly | |
| var hideElements = function() { | |
| ['hiker-pos-coords', 'live-gps-coords', 'route-data-json'].forEach(function(id) { | |
| var el = document.getElementById(id); | |
| if (el) el.style.setProperty("display", "none", "important"); | |
| }); | |
| }; | |
| hideElements(); | |
| var hideInterval = setInterval(hideElements, 50); | |
| setTimeout(function() { clearInterval(hideInterval); }, 4000); | |
| // 1. Dynamically append Leaflet CSS | |
| if (!document.getElementById("leaflet-css")) { | |
| var link = document.createElement("link"); | |
| link.id = "leaflet-css"; | |
| link.rel = "stylesheet"; | |
| link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; | |
| document.head.appendChild(link); | |
| } | |
| // 2. Dynamically append Leaflet JS | |
| if (!document.getElementById("leaflet-js")) { | |
| var script = document.createElement("script"); | |
| script.id = "leaflet-js"; | |
| script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; | |
| document.head.appendChild(script); | |
| } | |
| // 3. Wait for Leaflet to load and initialize map | |
| var checkExist = setInterval(function() { | |
| if (typeof L !== 'undefined' && L.map) { | |
| var mapContainer = document.getElementById("trailhead-leaflet-map"); | |
| if (!mapContainer) return; // Wait for Gradio to mount the container | |
| clearInterval(checkExist); | |
| if (window.myLeafletMap) return; // Already initialized | |
| var map = L.map("trailhead-leaflet-map").setView([46.0734974, 11.1717214], 13); | |
| window.myLeafletMap = map; | |
| if (typeof L.TileLayer.OfflineFirst === 'undefined') { | |
| L.TileLayer.OfflineFirst = L.TileLayer.extend({ | |
| createTile: function(coords, done) { | |
| var tile = L.TileLayer.prototype.createTile.call(this, coords, done); | |
| L.DomEvent.on(tile, 'error', function() { | |
| var osmUrl = 'https://tile.openstreetmap.org/' + coords.z + '/' + coords.x + '/' + coords.y + '.png'; | |
| if (tile.src !== osmUrl) { | |
| tile.src = osmUrl; | |
| } | |
| }); | |
| return tile; | |
| } | |
| }); | |
| } | |
| window.activeTileLayer = new L.TileLayer.OfflineFirst('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| maxZoom: 19, | |
| attribution: '© OpenStreetMap' | |
| }).addTo(map); | |
| window.mapLayers = L.layerGroup().addTo(map); | |
| window.hikerMarker = null; | |
| // Define window.routeDataChangeHandler | |
| window.routeDataChangeHandler = function(routeJson) { | |
| if (!routeJson) return; | |
| try { | |
| var data = JSON.parse(routeJson); | |
| var map = window.myLeafletMap; | |
| if (!map) return; | |
| var tilesDir = data.tiles_dir || ''; | |
| if (tilesDir && window.activeTileLayer) { | |
| map.removeLayer(window.activeTileLayer); | |
| var localUrl = '/file=' + tilesDir + '/{z}/{x}/{y}.png'; | |
| window.activeTileLayer = new L.TileLayer.OfflineFirst(localUrl, { | |
| maxZoom: 16, | |
| minZoom: 13, | |
| attribution: '© OpenStreetMap' | |
| }).addTo(map); | |
| } | |
| if (window.mapLayers) { | |
| window.mapLayers.clearLayers(); | |
| } | |
| if (window.hikerMarker) { | |
| window.hikerMarker.remove(); | |
| window.hikerMarker = null; | |
| } | |
| window.routePolyline = null; | |
| var points = data.points || []; | |
| var checkpoints = data.checkpoints || []; | |
| var pois = data.pois || []; | |
| if (points.length === 0) return; | |
| var latlngs = points.map(p => [p.lat, p.lon]); | |
| var polyline = L.polyline(latlngs, { | |
| color: "#f59e0b", | |
| weight: 5, | |
| opacity: 0.85 | |
| }).addTo(window.mapLayers); | |
| window.routePolyline = polyline; | |
| map.fitBounds(polyline.getBounds()); | |
| checkpoints.forEach(cp => { | |
| var color = "cadetblue"; | |
| if (cp.name === "Start") color = "green"; | |
| else if (cp.name === "End") color = "red"; | |
| var popupText = ` | |
| <div style="font-family: 'Outfit', sans-serif; font-size: 11px; color: #111;"> | |
| <b>${cp.name}</b><br> | |
| Distance: ${cp.cum_dist.toFixed(2)} km<br> | |
| Elevation: ${cp.ele.toFixed(1)} m | |
| </div>`; | |
| L.circleMarker([cp.lat, cp.lon], { | |
| radius: cp.name === "Start" || cp.name === "End" ? 8 : 6, | |
| fillColor: color, | |
| color: "#ffffff", | |
| weight: 1.5, | |
| fillOpacity: 0.9 | |
| }) | |
| .bindPopup(popupText) | |
| .addTo(window.mapLayers); | |
| }); | |
| pois.forEach(poi => { | |
| var color = "purple"; | |
| var type = poi.type; | |
| if (["drinking_water", "water_point", "fountain"].includes(type)) { | |
| color = "#3b82f6"; | |
| } else if (type === "spring") { | |
| color = "#60a5fa"; | |
| } else if (["alpine_hut", "wilderness_hut"].includes(type)) { | |
| color = "#047857"; | |
| } else if (type === "camp_site") { | |
| color = "#f97316"; | |
| } else if (type === "shelter") { | |
| color = "#10b981"; | |
| } else if (type === "viewpoint") { | |
| color = "#a855f7"; | |
| } else if (type === "peak") { | |
| color = "#7c3aed"; | |
| } else if (type === "phone") { | |
| color = "#ef4444"; | |
| } | |
| var popupText = ` | |
| <div style="font-family: 'Outfit', sans-serif; font-size: 11px; color: #111;"> | |
| <b>${poi.name}</b><br> | |
| Type: ${type.replace(/_/g, ' ').toUpperCase()}<br> | |
| Distance to Route: ${poi.distance.toFixed(1)} m | |
| </div>`; | |
| L.circleMarker([poi.lat, poi.lon], { | |
| radius: 5, | |
| fillColor: color, | |
| color: "#ffffff", | |
| weight: 1.2, | |
| fillOpacity: 0.95 | |
| }) | |
| .bindPopup(popupText) | |
| .addTo(window.mapLayers); | |
| }); | |
| } catch(e) { | |
| console.error("Error drawing route:", e); | |
| } | |
| }; | |
| // Define window.updateHikerPosHandler | |
| window.updateHikerPosHandler = function(coords) { | |
| if (!coords) return; | |
| try { | |
| var data = JSON.parse(coords); | |
| var map = window.myLeafletMap; | |
| if (!map) return; | |
| var pos = [data.lat, data.lon]; | |
| if (!window.hikerMarker) { | |
| window.hikerMarker = L.circleMarker(pos, { | |
| radius: 9, | |
| fillColor: "#ef4444", | |
| color: "#ffffff", | |
| weight: 2.5, | |
| fillOpacity: 1.0 | |
| }).addTo(map); | |
| } else { | |
| window.hikerMarker.setLatLng(pos); | |
| } | |
| var popupText = ` | |
| <div style="font-family: 'Outfit', sans-serif; font-size: 11px; color: #111;"> | |
| <b>Current position</b><br> | |
| Distance Walked: ${data.cum_dist.toFixed(2)} km<br> | |
| Altitude: ${data.ele.toFixed(1)} m | |
| </div>`; | |
| window.hikerMarker.bindPopup(popupText); | |
| if (window.routePolyline) { | |
| var routeBounds = window.routePolyline.getBounds(); | |
| var combinedBounds = routeBounds.extend(pos); | |
| map.fitBounds(combinedBounds, { padding: [50, 50] }); | |
| } else { | |
| map.panTo(pos); | |
| } | |
| } catch(e) { | |
| console.error("Error updating hiker position:", e); | |
| } | |
| }; | |
| // Trigger handlers immediately with any pending data | |
| if (window.pendingRouteData) { | |
| window.routeDataChangeHandler(window.pendingRouteData); | |
| } | |
| if (window.pendingHikerCoords) { | |
| window.updateHikerPosHandler(window.pendingHikerCoords); | |
| } | |
| // --- Live GPS Tracking Logic --- | |
| window.gpsWatchId = null; | |
| window.lastGpsTime = -99999; // Allow immediate first fix | |
| window.liveGpsMarker = null; // Blue pulsing dot (user location) | |
| window.liveAccCircle = null; // Accuracy radius circle | |
| window.liveGpsActive = false; | |
| // Inject pulsing CSS for the live GPS dot | |
| if (!document.getElementById('gps-pulse-style')) { | |
| var style = document.createElement('style'); | |
| style.id = 'gps-pulse-style'; | |
| style.textContent = ` | |
| @keyframes gpsPulse { | |
| 0% { transform: scale(1); opacity: 1; } | |
| 70% { transform: scale(2.5); opacity: 0; } | |
| 100% { transform: scale(1); opacity: 0; } | |
| } | |
| .gps-dot-icon { | |
| position: relative; | |
| width: 18px; height: 18px; | |
| } | |
| .gps-dot-icon .pulse { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 18px; height: 18px; | |
| border-radius: 50%; | |
| background: rgba(66,133,244,0.4); | |
| animation: gpsPulse 1.6s ease-out infinite; | |
| } | |
| .gps-dot-icon .core { | |
| position: absolute; | |
| top: 3px; left: 3px; | |
| width: 12px; height: 12px; | |
| border-radius: 50%; | |
| background: #4285f4; | |
| border: 2px solid #fff; | |
| box-shadow: 0 0 6px rgba(66,133,244,0.8); | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| window.updateLiveGPSMarker = function(lat, lon, accuracy) { | |
| var map = window.myLeafletMap; | |
| if (!map) return; | |
| var pos = [lat, lon]; | |
| if (!window.liveGpsMarker) { | |
| // Create the pulsing blue icon | |
| var gpsDotIcon = L.divIcon({ | |
| className: '', | |
| html: '<div class="gps-dot-icon"><div class="pulse"></div><div class="core"></div></div>', | |
| iconSize: [18, 18], | |
| iconAnchor: [9, 9] | |
| }); | |
| window.liveGpsMarker = L.marker(pos, { icon: gpsDotIcon, zIndexOffset: 1000 }) | |
| .bindPopup('<b>📡 Your Live Location</b>') | |
| .addTo(map); | |
| } else { | |
| window.liveGpsMarker.setLatLng(pos); | |
| } | |
| // Update/create accuracy circle | |
| if (accuracy && accuracy > 0) { | |
| if (!window.liveAccCircle) { | |
| window.liveAccCircle = L.circle(pos, { | |
| radius: accuracy, | |
| color: '#4285f4', | |
| fillColor: '#4285f4', | |
| fillOpacity: 0.08, | |
| weight: 1.5, | |
| dashArray: '4,4' | |
| }).addTo(map); | |
| } else { | |
| window.liveAccCircle.setLatLng(pos); | |
| window.liveAccCircle.setRadius(accuracy); | |
| } | |
| } | |
| // Google Maps style: keep user centered, show route context | |
| if (window.routePolyline) { | |
| var routeBounds = window.routePolyline.getBounds(); | |
| var combinedBounds = routeBounds.extend(pos); | |
| map.fitBounds(combinedBounds, { padding: [60, 60], maxZoom: 17 }); | |
| } else { | |
| map.setView(pos, Math.max(map.getZoom(), 15)); | |
| } | |
| }; | |
| window.stopLiveGPSMarker = function() { | |
| if (window.liveGpsMarker) { | |
| window.liveGpsMarker.remove(); | |
| window.liveGpsMarker = null; | |
| } | |
| if (window.liveAccCircle) { | |
| window.liveAccCircle.remove(); | |
| window.liveAccCircle = null; | |
| } | |
| }; | |
| window.toggleLiveGPS = function(enabled) { | |
| window.liveGpsActive = !!enabled; | |
| if (!enabled) { | |
| if (window.gpsWatchId !== null) { | |
| navigator.geolocation.clearWatch(window.gpsWatchId); | |
| window.gpsWatchId = null; | |
| console.log("Live GPS tracking stopped."); | |
| } | |
| window.stopLiveGPSMarker(); | |
| return; | |
| } | |
| if (enabled && "geolocation" in navigator) { | |
| if (window.gpsWatchId !== null) { | |
| console.log("Live GPS tracking already active."); | |
| return; | |
| } | |
| window.lastGpsTime = -99999; // ensure first fix goes through | |
| console.log("Requesting Live GPS tracking..."); | |
| // Immediate one-shot to show position fast | |
| navigator.geolocation.getCurrentPosition( | |
| function(position) { | |
| var lat = position.coords.latitude; | |
| var lon = position.coords.longitude; | |
| var ele = position.coords.altitude || 0.0; | |
| var acc = position.coords.accuracy; | |
| window.updateLiveGPSMarker(lat, lon, acc); | |
| var coords = { lat: lat, lon: lon, ele: ele, acc: acc }; | |
| var textbox = document.querySelector("#live-gps-coords textarea"); | |
| if (!textbox) textbox = document.querySelector("#live-gps-coords input"); | |
| if (textbox) { | |
| var ts = '_t=' + Date.now(); // force unique value to trigger .change() | |
| textbox.value = JSON.stringify(Object.assign(coords, { _ts: Date.now() })); | |
| textbox.dispatchEvent(new Event("input", { bubbles: true })); | |
| textbox.dispatchEvent(new Event("change", { bubbles: true })); | |
| } | |
| window.lastGpsTime = Date.now(); | |
| }, | |
| function(e) { console.warn("Quick GPS fix failed:", e.message); }, | |
| { enableHighAccuracy: true, timeout: 8000, maximumAge: 0 } | |
| ); | |
| // Continuous watch for ongoing updates | |
| window.gpsWatchId = navigator.geolocation.watchPosition( | |
| function(position) { | |
| var now = Date.now(); | |
| if (now - window.lastGpsTime < 1000) return; | |
| window.lastGpsTime = now; | |
| var lat = position.coords.latitude; | |
| var lon = position.coords.longitude; | |
| var ele = position.coords.altitude || 0.0; | |
| var acc = position.coords.accuracy; | |
| // 1. Update map marker directly in JS (instant, no Python round-trip) | |
| window.updateLiveGPSMarker(lat, lon, acc); | |
| // 2. Send to Python backend for HUD / alerts update | |
| var coords = { lat: lat, lon: lon, ele: ele, acc: acc, _ts: now }; | |
| var textbox = document.querySelector("#live-gps-coords textarea"); | |
| if (!textbox) textbox = document.querySelector("#live-gps-coords input"); | |
| if (textbox) { | |
| textbox.value = JSON.stringify(coords); | |
| textbox.dispatchEvent(new Event("input", { bubbles: true })); | |
| textbox.dispatchEvent(new Event("change", { bubbles: true })); | |
| } | |
| }, | |
| function(error) { | |
| console.error("GPS Watch Error:", error); | |
| if (error.code === 1) { | |
| alert("Location permission denied. Please allow location access in your browser settings."); | |
| } else { | |
| console.warn("GPS error (code " + error.code + "): " + error.message); | |
| } | |
| }, | |
| { | |
| enableHighAccuracy: true, | |
| maximumAge: 1000, | |
| timeout: 15000 | |
| } | |
| ); | |
| } else if (enabled) { | |
| alert("Geolocation is not supported by this browser or not running in a secure context (use localhost or HTTPS)."); | |
| } | |
| }; | |
| } | |
| }, 100); | |
| } | |
| """ | |
| def generate_elevation_plot(points, current_idx=None): | |
| """ | |
| Generate an offline-ready Plotly elevation profile chart. | |
| If current_idx is provided, displays a vertical line or marker for the hiker's current position. | |
| """ | |
| try: | |
| import plotly.graph_objects as go | |
| except ImportError: | |
| return None | |
| if not points: | |
| return None | |
| x = [p["cum_dist"] / 1000.0 for p in points] | |
| y = [p["ele"] for p in points] | |
| fig = go.Figure() | |
| # Area chart for elevation | |
| fig.add_trace(go.Scatter( | |
| x=x, | |
| y=y, | |
| mode='lines', | |
| fill='tozeroy', | |
| line=dict(color='#f59e0b', width=2.5), | |
| fillcolor='rgba(245,158,11,0.15)', | |
| name='Elevation' | |
| )) | |
| # Hiker's current position dot | |
| if current_idx is not None and 0 <= current_idx < len(points): | |
| hiker_pt = points[current_idx] | |
| h_x = hiker_pt["cum_dist"] / 1000.0 | |
| h_y = hiker_pt["ele"] | |
| # Add hiker position dot | |
| fig.add_trace(go.Scatter( | |
| x=[h_x], | |
| y=[h_y], | |
| mode='markers', | |
| marker=dict(color='#ef4444', size=12, symbol='circle', line=dict(color='#ffffff', width=2)), | |
| name='You' | |
| )) | |
| # Add vertical line | |
| fig.add_vline(x=h_x, line_width=1, line_dash="dash", line_color="#ef4444") | |
| fig.update_layout( | |
| title="🏔️ ELEVATION PROFILE", | |
| xaxis_title="Distance (km)", | |
| yaxis_title="Elevation (m)", | |
| plot_bgcolor='#0c1014', | |
| paper_bgcolor='#0c1014', | |
| font=dict(color='#f59e0b', family='Share Tech Mono, monospace'), | |
| margin=dict(l=50, r=20, t=50, b=50), | |
| hovermode="x unified", | |
| showlegend=False, | |
| height=280 | |
| ) | |
| fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='rgba(245,158,11,0.1)') | |
| fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='rgba(245,158,11,0.1)') | |
| return fig | |
| EMERGENCY_CARD = """ | |
| ## 🚨 IMMEDIATE BACKCOUNTRY EMERGENCY CARD (OFFLINE) | |
| If you encounter a medical crisis with no cellular signal, follow these basic steps: | |
| 1. **Severe Bleeding:** Apply direct pressure with clean dressing. Elevate limb. Use tourniquet if blood is spurting. | |
| 2. **Hypothermia:** Wrap in windproof shell/sleeping bag. Replace wet clothes. Provide warm sweet drinks. | |
| 3. **Heat Stroke:** Move to shade. Actively cool by wetting skin and fanning. Sip cool water. | |
| 4. **Altitude Illness (AMS/HAPE/HACE):** Descend immediately. Do not ascend. Administer oxygen if available. | |
| 5. **Ankle Sprain (R.I.C.E):** Rest the joint. Ice or apply cold pack. Compress with elastic bandage. Elevate limb. | |
| *Disclaimer: This guide is for offline reference only. Always carry a PLB/satellite communicator on remote trails.* | |
| """ | |
| def generate_folium_map(points, checkpoints, pois, hiker_pos=None): | |
| """ | |
| Generate interactive folium map rendering track, checkpoints, POIs, and current hiker pos. | |
| """ | |
| if not points: | |
| m = folium.Map(location=[46.0734974, 11.1717214], zoom_start=13) | |
| return m._repr_html_() | |
| # Center map on current hiker position or middle of track | |
| if hiker_pos: | |
| center_lat, center_lon = hiker_pos["lat"], hiker_pos["lon"] | |
| zoom_val = 15 | |
| else: | |
| mid_idx = len(points) // 2 | |
| center_lat, center_lon = points[mid_idx]["lat"], points[mid_idx]["lon"] | |
| zoom_val = 14 | |
| m = folium.Map(location=[center_lat, center_lon], zoom_start=zoom_val) | |
| # Draw track polyline | |
| locations = [(p["lat"], p["lon"]) for p in points] | |
| folium.PolyLine(locations, color="#f59e0b", weight=5, opacity=0.85).add_to(m) | |
| # Draw checkpoints | |
| for cp in checkpoints: | |
| name = cp["name"] | |
| lat = cp["lat"] | |
| lon = cp["lon"] | |
| ele = cp["ele"] | |
| dist = cp["cum_dist"] | |
| if name == "Start": | |
| color = "green" | |
| icon = "play" | |
| elif name == "End": | |
| color = "red" | |
| icon = "flag" | |
| else: | |
| color = "cadetblue" | |
| icon = "info-sign" | |
| popup_text = f""" | |
| <div style="font-family: 'Outfit', sans-serif; font-size: 11px;"> | |
| <b>{name}</b><br> | |
| Distance: {dist:.2f} km<br> | |
| Elevation: {ele:.1f} m | |
| </div> | |
| """ | |
| folium.Marker( | |
| location=[lat, lon], | |
| popup=popup_text, | |
| tooltip=name, | |
| icon=folium.Icon(color=color, icon=icon) | |
| ).add_to(m) | |
| # Draw POIs | |
| for poi in pois: | |
| poi_type = poi["type"] | |
| icon_color = "purple" | |
| icon_name = "info-sign" | |
| # Color coding: | |
| # - drinking_water / water_point / fountain -> blue / glass | |
| # - spring -> lightblue / tint | |
| # - alpine_hut / wilderness_hut -> darkgreen / home | |
| # - camp_site -> orange / fire | |
| # - shelter -> green / leaf | |
| # - viewpoint -> purple / camera | |
| # - peak -> darkpurple / flag | |
| # - phone -> red / phone | |
| if poi_type in ["drinking_water", "water_point", "fountain"]: | |
| icon_color = "blue" | |
| icon_name = "glass" | |
| elif poi_type == "spring": | |
| icon_color = "lightblue" | |
| icon_name = "tint" | |
| elif poi_type in ["alpine_hut", "wilderness_hut"]: | |
| icon_color = "darkgreen" | |
| icon_name = "home" | |
| elif poi_type == "camp_site": | |
| icon_color = "orange" | |
| icon_name = "fire" | |
| elif poi_type == "shelter": | |
| icon_color = "green" | |
| icon_name = "leaf" | |
| elif poi_type == "viewpoint": | |
| icon_color = "purple" | |
| icon_name = "camera" | |
| elif poi_type == "peak": | |
| icon_color = "darkpurple" | |
| icon_name = "flag" | |
| elif poi_type == "phone": | |
| icon_color = "red" | |
| icon_name = "phone" | |
| popup_text = f""" | |
| <div style="font-family: 'Outfit', sans-serif; font-size: 11px;"> | |
| <b>{poi['name']}</b><br> | |
| Type: {poi_type.replace('_', ' ').title()}<br> | |
| Distance to Route: {poi['distance']:.1f} m | |
| </div> | |
| """ | |
| folium.Marker( | |
| location=[poi["lat"], poi["lon"]], | |
| popup=popup_text, | |
| tooltip=poi['name'], | |
| icon=folium.Icon(color=icon_color, icon=icon_name) | |
| ).add_to(m) | |
| return m._repr_html_() | |
| def get_map_iframe(map_html): | |
| """ | |
| Helper to bundle raw HTML into a secure, sandboxed base64 data URI iframe. | |
| """ | |
| injected_js = """ | |
| <script> | |
| // Poll for Leaflet to load and override L.map to capture the map object | |
| (function() { | |
| var checkExist = setInterval(function() { | |
| if (typeof L !== 'undefined' && L.map) { | |
| clearInterval(checkExist); | |
| var originalMap = L.map; | |
| L.map = function(id, options) { | |
| var m = originalMap(id, options); | |
| window.myLeafletMap = m; | |
| return m; | |
| }; | |
| } | |
| }, 50); | |
| })(); | |
| // Listen for coordinates update from parent Gradio frame | |
| window.addEventListener("message", function(event) { | |
| if (event.data && event.data.type === "update_hiker_pos") { | |
| var lat = event.data.lat; | |
| var lon = event.data.lon; | |
| var ele = event.data.ele; | |
| var dist = event.data.dist; | |
| var map = window.myLeafletMap; | |
| if (!map) return; | |
| // Check if hikerMarker exists, otherwise create it | |
| if (!window.hikerMarker) { | |
| var redIcon = L.icon({ | |
| iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png', | |
| shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', | |
| iconSize: [25, 41], | |
| iconAnchor: [12, 41], | |
| popupAnchor: [1, -34], | |
| shadowSize: [41, 41] | |
| }); | |
| window.hikerMarker = L.marker([lat, lon], {icon: redIcon}).addTo(map); | |
| } else { | |
| window.hikerMarker.setLatLng([lat, lon]); | |
| } | |
| window.hikerMarker.bindPopup( | |
| "<div style='font-family: \\\"Outfit\\\", sans-serif; font-size: 11px;'>" + | |
| "<b>Current Position</b><br>" + | |
| "Distance: " + dist.toFixed(2) + " km<br>" + | |
| "Altitude: " + ele.toFixed(1) + " m" + | |
| "</div>" | |
| ); | |
| // Center the map smoothly on the updated coordinate | |
| map.panTo([lat, lon]); | |
| } | |
| }); | |
| </script> | |
| """ | |
| if "</body>" in map_html: | |
| map_html = map_html.replace("</body>", injected_js + "</body>") | |
| else: | |
| map_html = map_html + injected_js | |
| b64_html = base64.b64encode(map_html.encode('utf-8')).decode('utf-8') | |
| iframe_src = f"data:text/html;base64,{b64_html}" | |
| return f'<div id="trailhead-map-iframe"><iframe src="{iframe_src}" width="100%" height="520px" style="border:1px solid rgba(245,158,11,0.2); border-radius: 12px;"></iframe></div>' | |
| def fetch_ors_route(start_coords, end_coords, profile, api_key): | |
| """ | |
| Fetches hiking route between coordinates using OpenRouteService. | |
| Falls back to a straight-line GPX segment if API key is empty or request fails. | |
| """ | |
| try: | |
| start_lat, start_lon = map(float, start_coords.split(",")) | |
| end_lat, end_lon = map(float, end_coords.split(",")) | |
| except Exception: | |
| raise gr.Error("Invalid coordinate format. Ensure format is 'lat, lon'.") | |
| temp_dir = "./temp" | |
| os.makedirs(temp_dir, exist_ok=True) | |
| file_path = os.path.join(temp_dir, "ors_fetched_route.gpx") | |
| if not api_key: | |
| mid_lat = (start_lat + end_lat) / 2.0 | |
| mid_lon = (start_lon + end_lon) / 2.0 | |
| gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?> | |
| <gpx version="1.1" creator="Trailhead Mock" xmlns="http://www.topografix.com/GPX/1/1"> | |
| <trk> | |
| <trkseg> | |
| <trkpt lat="{start_lat}" lon="{start_lon}"></trkpt> | |
| <trkpt lat="{mid_lat}" lon="{mid_lon}"></trkpt> | |
| <trkpt lat="{end_lat}" lon="{end_lon}"></trkpt> | |
| </trkseg> | |
| </trk> | |
| </gpx>""" | |
| with open(file_path, "w", encoding="utf-8") as f: | |
| f.write(gpx_content) | |
| gr.Warning("No ORS API Key provided. Generated a mock direct route segment.") | |
| return file_path | |
| url = f"https://api.openrouteservice.org/v2/directions/{profile}/gpx" | |
| headers = { | |
| 'Accept': 'application/gpx+xml', | |
| 'Authorization': api_key, | |
| 'Content-Type': 'application/json' | |
| } | |
| body = { | |
| "coordinates": [[start_lon, start_lat], [end_lon, end_lat]] | |
| } | |
| try: | |
| response = requests.post(url, json=body, headers=headers, timeout=12) | |
| if response.status_code == 200: | |
| with open(file_path, "wb") as f: | |
| f.write(response.content) | |
| gr.Info("Successfully fetched route from OpenRouteService!") | |
| return file_path | |
| else: | |
| raise ValueError(f"ORS returned status {response.status_code}") | |
| except Exception as e: | |
| mid_lat = (start_lat + end_lat) / 2.0 | |
| mid_lon = (start_lon + end_lon) / 2.0 | |
| gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?> | |
| <gpx version="1.1" creator="Trailhead Fallback" xmlns="http://www.topografix.com/GPX/1/1"> | |
| <trk> | |
| <trkseg> | |
| <trkpt lat="{start_lat}" lon="{start_lon}"></trkpt> | |
| <trkpt lat="{mid_lat}" lon="{mid_lon}"></trkpt> | |
| <trkpt lat="{end_lat}" lon="{end_lon}"></trkpt> | |
| </trkseg> | |
| </trk> | |
| </gpx>""" | |
| with open(file_path, "w", encoding="utf-8") as f: | |
| f.write(gpx_content) | |
| gr.Warning(f"ORS Fetch failed ({e}). Generated straight-line fallback route.") | |
| return file_path | |
| def format_route_view(data): | |
| """ | |
| Format route data for presentation inside stats display and checkpoint lists. | |
| """ | |
| stats_html = f""" | |
| <div style='display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;'> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val'>{data['total_distance_km']:.2f}</div> | |
| <div class='hud-stat-lbl'>Distance (km)</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val'>{data['elevation_gain_m']:.1f}</div> | |
| <div class='hud-stat-lbl'>Elevation Gain (m)</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val'>{data['elevation_loss_m']:.1f}</div> | |
| <div class='hud-stat-lbl'>Elevation Loss (m)</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val'>{data['min_elevation_m']:.0f} - {data['max_elevation_m']:.0f}</div> | |
| <div class='hud-stat-lbl'>Altitude Range (m)</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val'>{data['estimated_days']:.1f}</div> | |
| <div class='hud-stat-lbl'>Est. Hiking Days</div> | |
| </div> | |
| </div> | |
| """ | |
| map_html = generate_folium_map(data["points"], data["checkpoints"], data.get("pois", [])) | |
| map_iframe = get_map_iframe(map_html) | |
| checkpoint_table_data = [] | |
| for cp in data["checkpoints"]: | |
| checkpoint_table_data.append([ | |
| cp["name"], | |
| f"{cp['lat']:.5f}, {cp['lon']:.5f}", | |
| f"{cp['cum_dist']:.2f} km", | |
| f"{cp['ele']:.1f} m" | |
| ]) | |
| return stats_html, map_iframe, checkpoint_table_data | |
| def handle_route_update(preloaded_sel, uploaded_file): | |
| file_path = PRELOADED_ROUTE_PATH | |
| is_preloaded = True | |
| if uploaded_file is not None: | |
| is_preloaded = False | |
| if isinstance(uploaded_file, list) and len(uploaded_file) > 0: | |
| uploaded_file = uploaded_file[0] | |
| if isinstance(uploaded_file, dict): | |
| file_path = uploaded_file.get("path") or uploaded_file.get("name") or PRELOADED_ROUTE_PATH | |
| else: | |
| file_path = getattr(uploaded_file, "path", getattr(uploaded_file, "name", PRELOADED_ROUTE_PATH)) | |
| try: | |
| # For the preloaded Trento route, use the bundled cache (includes 4 POIs). | |
| # This avoids Overpass API calls on Hugging Face Spaces where network access may be restricted. | |
| import json as _json | |
| if is_preloaded and os.path.exists(PRELOADED_CACHE_PATH): | |
| print(f"[app] Loading bundled preloaded route cache from {PRELOADED_CACHE_PATH}") | |
| with open(PRELOADED_CACHE_PATH, "r", encoding="utf-8") as _f: | |
| data = _json.load(_f) | |
| else: | |
| data = parse_gpx_file(file_path) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return ( | |
| f"<div style='color:#ef4444;'>Error parsing GPX: {e}</div>", | |
| "", | |
| [], | |
| {}, | |
| 0, | |
| gr.update(active=False), | |
| "", | |
| "", | |
| "", | |
| None | |
| ) | |
| # Load custom waypoints from DB and merge them into POIs | |
| try: | |
| custom_wps = db.get_custom_waypoints() | |
| for wp in custom_wps: | |
| min_d = float('inf') | |
| closest_idx = -1 | |
| for idx, pt in enumerate(data["points"]): | |
| d = haversine(wp["lat"], wp["lon"], pt["lat"], pt["lon"]) | |
| if d < min_d: | |
| min_d = d | |
| closest_idx = idx | |
| data["pois"].append({ | |
| "id": f"custom_{wp['id']}", | |
| "lat": wp["lat"], | |
| "lon": wp["lon"], | |
| "type": wp["type"], | |
| "name": f"📍 {wp['name']} (Custom)", | |
| "distance": round(min_d, 1) if closest_idx != -1 else 0.0, | |
| "track_index": closest_idx | |
| }) | |
| except Exception as ex: | |
| print(f"[app] Error loading custom waypoints: {ex}") | |
| stats_html, map_iframe, checkpoint_table_data = format_route_view(data) | |
| # Save a copy with POIs saved to disk | |
| try: | |
| enhanced_file = os.path.join("./temp", "trek_with_poi.gpx") | |
| save_enhanced_gpx(file_path, enhanced_file, data.get("pois", [])) | |
| except Exception as ex: | |
| print(f"[app] Error saving enhanced GPX: {ex}") | |
| import json | |
| start_pt = data["points"][0] | |
| hiker_coords_json = json.dumps({ | |
| "lat": start_pt["lat"], | |
| "lon": start_pt["lon"], | |
| "ele": start_pt["ele"], | |
| "cum_dist": start_pt["cum_dist"] / 1000.0 | |
| }) | |
| # Get absolute path of tiles directory to pass to Leaflet | |
| tiles_dir_abs = os.path.abspath("./assets/tiles").replace("\\", "/") | |
| route_json = json.dumps({ | |
| "points": data["points"], | |
| "checkpoints": data["checkpoints"], | |
| "pois": data.get("pois", []), | |
| "tiles_dir": tiles_dir_abs | |
| }) | |
| ele_plot = generate_elevation_plot(data["points"], 0) | |
| return stats_html, route_json, checkpoint_table_data, data, 0, gr.update(active=False), "", "", hiker_coords_json, ele_plot | |
| def handle_ors_fetch_click(start_coords, end_coords, profile, api_key): | |
| try: | |
| route_file = fetch_ors_route(start_coords, end_coords, profile, api_key) | |
| data = parse_gpx_file(route_file) | |
| # Load custom waypoints from DB and merge them into POIs | |
| try: | |
| custom_wps = db.get_custom_waypoints() | |
| for wp in custom_wps: | |
| min_d = float('inf') | |
| closest_idx = -1 | |
| for idx, pt in enumerate(data["points"]): | |
| d = haversine(wp["lat"], wp["lon"], pt["lat"], pt["lon"]) | |
| if d < min_d: | |
| min_d = d | |
| closest_idx = idx | |
| data["pois"].append({ | |
| "id": f"custom_{wp['id']}", | |
| "lat": wp["lat"], | |
| "lon": wp["lon"], | |
| "type": wp["type"], | |
| "name": f"📍 {wp['name']} (Custom)", | |
| "distance": round(min_d, 1) if closest_idx != -1 else 0.0, | |
| "track_index": closest_idx | |
| }) | |
| except Exception as ex: | |
| print(f"[app] Error loading custom waypoints: {ex}") | |
| stats_html, map_iframe, checkpoint_table_data = format_route_view(data) | |
| try: | |
| enhanced_file = os.path.join("./temp", "trek_with_poi.gpx") | |
| save_enhanced_gpx(route_file, enhanced_file, data.get("pois", [])) | |
| except Exception as ex: | |
| print(f"[app] Error saving enhanced GPX: {ex}") | |
| import json | |
| start_pt = data["points"][0] | |
| hiker_coords_json = json.dumps({ | |
| "lat": start_pt["lat"], | |
| "lon": start_pt["lon"], | |
| "ele": start_pt["ele"], | |
| "cum_dist": start_pt["cum_dist"] / 1000.0 | |
| }) | |
| tiles_dir_abs = os.path.abspath("./assets/tiles").replace("\\", "/") | |
| route_json = json.dumps({ | |
| "points": data["points"], | |
| "checkpoints": data["checkpoints"], | |
| "pois": data.get("pois", []), | |
| "tiles_dir": tiles_dir_abs | |
| }) | |
| ele_plot = generate_elevation_plot(data["points"], 0) | |
| return stats_html, route_json, checkpoint_table_data, data, 0, gr.update(active=False), "", "", hiker_coords_json, ele_plot | |
| except Exception as e: | |
| return ( | |
| f"<div style='color:#ef4444;'>Error: {e}</div>", | |
| "", | |
| [], | |
| {}, | |
| 0, | |
| gr.update(active=False), | |
| "", | |
| "", | |
| "", | |
| None | |
| ) | |
| # --- Playback Simulation Loop --- | |
| def step_simulation(current_idx, route_data, speed): | |
| if not route_data or "points" not in route_data: | |
| return current_idx, gr.update(), gr.update(), gr.update(), "", gr.update(), gr.update() | |
| points = route_data["points"] | |
| checkpoints = route_data["checkpoints"] | |
| pois = route_data.get("pois", []) | |
| if current_idx >= len(points) - 1: | |
| return current_idx, gr.update(), gr.update(), gr.update(), "", gr.update(), gr.update(active=False) | |
| step_size = int(speed) | |
| next_idx = current_idx + step_size | |
| timer_update = gr.update() | |
| if next_idx >= len(points) - 1: | |
| next_idx = len(points) - 1 | |
| timer_update = gr.update(active=False) | |
| current_pt = points[next_idx] | |
| lat = current_pt["lat"] | |
| lon = current_pt["lon"] | |
| ele = current_pt["ele"] | |
| cum_dist = current_pt["cum_dist"] | |
| total_dist = points[-1]["cum_dist"] | |
| pct_complete = (cum_dist / total_dist) * 100.0 if total_dist > 0 else 0.0 | |
| # Proximity alerts check | |
| active_alerts = [] | |
| for poi in pois: | |
| d = haversine(lat, lon, poi["lat"], poi["lon"]) | |
| if d <= 150.0: | |
| icon_map = { | |
| "drinking_water": "💧", | |
| "spring": "💧", | |
| "water_point": "💧", | |
| "fountain": "⛲", | |
| "alpine_hut": "🏡", | |
| "wilderness_hut": "🏡", | |
| "camp_site": "⛺", | |
| "shelter": "🛡️", | |
| "viewpoint": "👁️", | |
| "peak": "🏔️", | |
| "phone": "📞" | |
| } | |
| icon = icon_map.get(poi["type"], "📍") | |
| active_alerts.append(f"<div style='background:rgba(245,158,11,0.15); border:1px solid #f59e0b; padding:10px; border-radius:8px; margin-bottom:5px; color:#f59e0b;'>{icon} <b>PROXIMITY:</b> {poi['name']} is {d:.0f}m away! ({poi['type'].replace('_', ' ').title()})</div>") | |
| # Checkpoint ETA progress | |
| next_cp = None | |
| for cp in checkpoints: | |
| if cp["cum_dist"] * 1000.0 > cum_dist: | |
| next_cp = cp | |
| break | |
| eta_text = "N/A" | |
| if next_cp: | |
| dist_to_cp = (next_cp["cum_dist"] * 1000.0) - cum_dist | |
| eta_sec = dist_to_cp / 1.38 | |
| eta_text = f"{int(eta_sec // 60)}m {int(eta_sec % 60)}s" | |
| alerts_html = "".join(active_alerts) if active_alerts else "<div style='color:var(--text-muted);'>No active proximity alerts.</div>" | |
| # Live HUD Panel | |
| hud_html = f""" | |
| <div style='display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;'> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val mono-display'>{pct_complete:.1f}%</div> | |
| <div class='hud-stat-lbl'>Route Progress</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val mono-display'>{cum_dist/1000.0:.2f} km</div> | |
| <div class='hud-stat-lbl'>Distance Hiked</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val mono-display'>{ele:.1f} m</div> | |
| <div class='hud-stat-lbl'>Current Altitude</div> | |
| </div> | |
| <div class='hud-stat-box'> | |
| <div class='hud-stat-val mono-display'>{eta_text}</div> | |
| <div class='hud-stat-lbl'>ETA to Next Point</div> | |
| </div> | |
| </div> | |
| """ | |
| # Proximity narration brief | |
| narration_html = "" | |
| for cp in checkpoints: | |
| cp_dist_m = cp["cum_dist"] * 1000.0 | |
| if abs(cum_dist - cp_dist_m) <= 150.0: | |
| cautions = "" | |
| if ele > 2400: | |
| cautions = " WARNING: Altitude is above 2400m. Watch for AMS symptoms (headache, dizziness)." | |
| narration_html = f""" | |
| <div style='border-left: 4px solid var(--accent-primary); background: rgba(245,158,11,0.05); padding: 15px; border-radius: 0 8px 8px 0;'> | |
| <b style='color:var(--accent-primary);'>📻 RADIO BRIEFING FOR {cp['name'].upper()}:</b> | |
| <p style='margin-top: 5px; font-style: italic;'> | |
| "Hiker, you have arrived at {cp['name']}. Current altitude is {ele:.1f}m.{cautions}" | |
| </p> | |
| </div> | |
| """ | |
| break | |
| import json | |
| hiker_coords_json = json.dumps({ | |
| "lat": lat, | |
| "lon": lon, | |
| "ele": ele, | |
| "cum_dist": cum_dist / 1000.0 | |
| }) | |
| # Throttled Plotly update (every 10 simulation steps) | |
| plot_update = gr.update() | |
| if next_idx == 0 or next_idx == len(points) - 1 or next_idx % 10 == 0: | |
| fig = generate_elevation_plot(points, next_idx) | |
| if fig: | |
| plot_update = fig | |
| return next_idx, hud_html, alerts_html, narration_html, hiker_coords_json, plot_update, timer_update | |
| # --- Live GPS Processing --- | |
| def handle_live_gps_update(coords_str, route_data): | |
| if not route_data or "points" not in route_data or not coords_str: | |
| return gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| import json | |
| try: | |
| coords = json.loads(coords_str) | |
| lat = coords["lat"] | |
| lon = coords["lon"] | |
| ele = coords["ele"] | |
| except Exception: | |
| return gr.update(), gr.update(), gr.update(), gr.update(), gr.update() | |
| points = route_data["points"] | |
| checkpoints = route_data["checkpoints"] | |
| pois = route_data.get("pois", []) | |
| # Snap to nearest point on route to determine cum_dist | |
| min_d = float('inf') | |
| closest_idx = 0 | |
| for idx, pt in enumerate(points): | |
| d = haversine(lat, lon, pt["lat"], pt["lon"]) | |
| if d < min_d: | |
| min_d = d | |
| closest_idx = idx | |
| cum_dist = points[closest_idx]["cum_dist"] | |
| total_dist = points[-1]["cum_dist"] | |
| pct_complete = (cum_dist / total_dist) * 100.0 if total_dist > 0 else 0.0 | |
| # Update hiker coords json for map JS | |
| hiker_coords_json = json.dumps({ | |
| "lat": lat, | |
| "lon": lon, | |
| "ele": ele, | |
| "cum_dist": cum_dist / 1000.0 | |
| }) | |
| # Proximity alerts check | |
| active_alerts = [] | |
| for poi in pois: | |
| d = haversine(lat, lon, poi["lat"], poi["lon"]) | |
| if d <= 150.0: | |
| icon_map = { | |
| "drinking_water": "💧", "spring": "💧", "water_point": "💧", | |
| "fountain": "⛲", "alpine_hut": "🏡", "wilderness_hut": "🏡", | |
| "camp_site": "⛺", "shelter": "🛡️", "viewpoint": "👁️", | |
| "peak": "🏔️", "phone": "📞" | |
| } | |
| icon = icon_map.get(poi["type"], "📍") | |
| active_alerts.append(f"<div style='background:rgba(245,158,11,0.15); border:1px solid #f59e0b; padding:10px; border-radius:8px; margin-bottom:5px; color:#f59e0b;'>{icon} <b>PROXIMITY:</b> {poi['name']} is {d:.0f}m away! ({poi['type'].replace('_', ' ').title()})</div>") | |
| # Checkpoint ETA progress | |
| next_cp = None | |
| for cp in checkpoints: | |
| if cp["cum_dist"] * 1000.0 > cum_dist: | |
| next_cp = cp | |
| break | |
| eta_text = "N/A" | |
| if next_cp: | |
| dist_to_cp = (next_cp["cum_dist"] * 1000.0) - cum_dist | |
| eta_sec = dist_to_cp / 1.38 | |
| eta_text = f"{int(eta_sec // 60)}m {int(eta_sec % 60)}s" | |
| alerts_html = "".join(active_alerts) if active_alerts else "<div style='color:var(--text-muted);'>No active proximity alerts.</div>" | |
| hud_html = f''' | |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;"> | |
| <div class="hud-stat-box"> | |
| <div class="hud-stat-val mono-display">{pct_complete:.1f}%</div> | |
| <div class="hud-stat-lbl">Route Progress</div> | |
| </div> | |
| <div class="hud-stat-box"> | |
| <div class="hud-stat-val mono-display">{cum_dist/1000.0:.2f} km</div> | |
| <div class="hud-stat-lbl">Distance Hiked</div> | |
| </div> | |
| <div class="hud-stat-box"> | |
| <div class="hud-stat-val mono-display">{ele:.1f} m</div> | |
| <div class="hud-stat-lbl">Current Altitude</div> | |
| </div> | |
| <div class="hud-stat-box"> | |
| <div class="hud-stat-val mono-display">{eta_text}</div> | |
| <div class="hud-stat-lbl">ETA to Next Point</div> | |
| </div> | |
| </div> | |
| ''' | |
| # Proximity narration brief | |
| narration_html = "" | |
| for cp in checkpoints: | |
| cp_dist_m = cp["cum_dist"] * 1000.0 | |
| if abs(cum_dist - cp_dist_m) <= 150.0: | |
| cautions = "" | |
| if ele > 2400: | |
| cautions = " WARNING: Altitude is above 2400m. Watch for AMS symptoms (headache, dizziness)." | |
| narration_html = f''' | |
| <div style="border-left: 4px solid var(--accent-primary); background: rgba(245,158,11,0.05); padding: 15px; border-radius: 0 8px 8px 0;"> | |
| <b style="color:var(--accent-primary);">📻 RADIO BRIEFING FOR {cp['name'].upper()}:</b> | |
| <p style="margin-top: 5px; font-style: italic;"> | |
| "Hiker, you have arrived at {cp['name']}. Current altitude is {ele:.1f}m.{cautions}" | |
| </p> | |
| </div> | |
| ''' | |
| break | |
| plot_update = gr.update() | |
| fig = generate_elevation_plot(points, closest_idx) | |
| if fig: | |
| plot_update = fig | |
| return hud_html, alerts_html, narration_html, hiker_coords_json, plot_update | |
| # --- First-Aid Manual Search --- | |
| def handle_first_aid_search(query): | |
| if not query.strip(): | |
| return "Please enter symptoms or injury to search the manual." | |
| grounding_text, sources = rag.retrieve_first_aid(query) | |
| if not grounding_text: | |
| return "No matching first-aid sections found in the manual. (Please carry a PLB/satellite communicator on remote trails)." | |
| system_prompt = ( | |
| "You are Trailhead Guide, an expert wilderness medicine counselor.\n" | |
| "Provide a concise, direct, and actionable step-by-step first-aid protocol based on the provided guide context.\n" | |
| "Cite the section at the end." | |
| ) | |
| prompt = f"Context:\n{grounding_text}\n\nQuestion: {query}\n\nAnswer:" | |
| response = llm.generate(prompt, system=system_prompt, stream=False) | |
| return f"### Retrieval Results ({', '.join(sources)})\n\n{response}" | |
| # --- Chatbot Integration --- | |
| def respond(message, history): | |
| response_accumulator = "" | |
| grounding_text, sources = rag.retrieve_first_aid(message) | |
| if grounding_text: | |
| source_cite = "Sources: " + ", ".join(sources) | |
| system_prompt = ( | |
| "You are Trailhead Guide, a wilderness first-aid advisor.\n" | |
| "Answer the query using ONLY the provided guide context.\n" | |
| f"Context:\n{grounding_text}\n" | |
| "Keep the instructions clear, numbered, and precise.\n" | |
| f"Cite: '{source_cite}'." | |
| ) | |
| else: | |
| system_prompt = ( | |
| "You are Trailhead Guide, an expert hiking guide.\n" | |
| "Provide helpful, concise trekking advice." | |
| ) | |
| for token in llm.generate(message, system=system_prompt, history=history, stream=True): | |
| response_accumulator += token | |
| yield response_accumulator | |
| # --- Phase 3 Callbacks (Waypoint Tagging, Voice Logs, AI Storytelling) --- | |
| def handle_tag_waypoint(wp_type, wp_name, hiker_pos_coords_str, route_state_val): | |
| if not hiker_pos_coords_str: | |
| return gr.update(), route_state_val, "<span style='color:#ef4444;'>Error: No active simulated hiker position to tag!</span>", [] | |
| if not wp_name.strip(): | |
| return gr.update(), route_state_val, "<span style='color:#ef4444;'>Error: Waypoint name cannot be empty!</span>", [] | |
| import json | |
| try: | |
| h_coords = json.loads(hiker_pos_coords_str) | |
| lat = h_coords["lat"] | |
| lon = h_coords["lon"] | |
| ele = h_coords["ele"] | |
| except Exception as e: | |
| return gr.update(), route_state_val, f"<span style='color:#ef4444;'>Error parsing coordinates: {e}</span>", [] | |
| # Save to DB | |
| db.add_custom_waypoint(lat, lon, ele, wp_type, wp_name) | |
| # Load updated waypoints | |
| custom_wps = db.get_custom_waypoints() | |
| wp_list = [[wp["timestamp"], wp["type"].upper(), wp["name"], f"{wp['lat']:.5f}, {wp['lon']:.5f}", f"{wp['ele']:.1f} m"] for wp in custom_wps] | |
| # Merge into map POIs | |
| route_json_str = gr.update() | |
| if route_state_val and "points" in route_state_val: | |
| min_d = float('inf') | |
| closest_idx = -1 | |
| for idx, pt in enumerate(route_state_val["points"]): | |
| d = haversine(lat, lon, pt["lat"], pt["lon"]) | |
| if d < min_d: | |
| min_d = d | |
| closest_idx = idx | |
| if "pois" not in route_state_val: | |
| route_state_val["pois"] = [] | |
| route_state_val["pois"].append({ | |
| "id": f"custom_{len(custom_wps)}", | |
| "lat": lat, | |
| "lon": lon, | |
| "type": wp_type, | |
| "name": f"📍 {wp_name} (Custom)", | |
| "distance": round(min_d, 1) if closest_idx != -1 else 0.0, | |
| "track_index": closest_idx | |
| }) | |
| tiles_dir_abs = os.path.abspath("./assets/tiles").replace("\\", "/") | |
| route_json_str = json.dumps({ | |
| "points": route_state_val["points"], | |
| "checkpoints": route_state_val["checkpoints"], | |
| "pois": route_state_val["pois"], | |
| "tiles_dir": tiles_dir_abs | |
| }) | |
| msg = f"<span style='color:#10b981;'>Successfully tagged '{wp_name}' ({wp_type.replace('_', ' ').title()}) at your position!</span>" | |
| return route_json_str, route_state_val, msg, wp_list | |
| def handle_clear_waypoints(route_state_val): | |
| db.clear_custom_waypoints() | |
| route_json_str = gr.update() | |
| if route_state_val and "pois" in route_state_val: | |
| # Remove POIs that were added custom | |
| route_state_val["pois"] = [p for p in route_state_val["pois"] if not str(p.get("id", "")).startswith("custom_")] | |
| tiles_dir_abs = os.path.abspath("./assets/tiles").replace("\\", "/") | |
| route_json_str = json.dumps({ | |
| "points": route_state_val["points"], | |
| "checkpoints": route_state_val["checkpoints"], | |
| "pois": route_state_val["pois"], | |
| "tiles_dir": tiles_dir_abs | |
| }) | |
| return route_json_str, route_state_val, "<span style='color:#ef4444;'>Cleared all custom waypoints.</span>", [] | |
| def handle_save_journal(audio_path, hiker_pos_coords_str): | |
| if not audio_path: | |
| return "", [], "<span style='color:#ef4444;'>Error: No recorded voice audio found!</span>", gr.update() | |
| lat, lon, ele, cum_dist = 0.0, 0.0, 0.0, 0.0 | |
| if hiker_pos_coords_str: | |
| try: | |
| import json | |
| h_coords = json.loads(hiker_pos_coords_str) | |
| lat = h_coords["lat"] | |
| lon = h_coords["lon"] | |
| ele = h_coords["ele"] | |
| cum_dist = h_coords["cum_dist"] | |
| except Exception as e: | |
| print(f"[app] Error parsing simulation coordinates for journal: {e}") | |
| print(f"[app] Transcribing journal audio file from {audio_path}...") | |
| transcript = llm.transcribe_audio(audio_path, prompt="Wilderness trek journal log") | |
| if not transcript or not transcript.strip(): | |
| return "", [], "<span style='color:#ef4444;'>Error: ASR transcription returned empty text!</span>", gr.update() | |
| # Save to SQLite | |
| db.add_journal_entry(lat, lon, ele, cum_dist, transcript) | |
| # Fetch updated logs | |
| logs = db.get_journal_entries() | |
| logs_table = [[l["timestamp"], f"{l['lat']:.5f}, {l['lon']:.5f}", f"{l['cum_dist']:.2f} km", l["transcript"]] for l in logs] | |
| msg = f"<span style='color:#10b981;'>Saved journal entry at Km {cum_dist:.2f} successfully!</span>" | |
| return transcript, logs_table, msg, None | |
| def handle_clear_journal(): | |
| db.clear_journal_logs() | |
| return "", [], "<span style='color:#ef4444;'>Cleared all voice journal logs.</span>" | |
| def handle_generate_story(route_state_val, style): | |
| logs = db.get_journal_entries() | |
| if not logs: | |
| return "### 📖 No Voice Logs Found\n\nPlease record and save some voice journal entries during your simulated trek before generating your AI story!", None | |
| # Compile journal logs chronologically (oldest first) | |
| logs_chron = list(reversed(logs)) | |
| journal_text = "" | |
| for idx, l in enumerate(logs_chron): | |
| journal_text += f"\n- Log #{idx+1} ({l['timestamp']}) at Km {l['cum_dist']:.2f} (Alt: {l['ele']:.1f}m):\n \"{l['transcript']}\"\n" | |
| # 1. Compile route stats (Trek details) | |
| stats_text = "Trek Details:\n" | |
| if route_state_val and "total_distance_km" in route_state_val: | |
| stats_text += f"- Total Distance: {route_state_val['total_distance_km']:.2f} km\n" | |
| stats_text += f"- Total Elevation Gain: {route_state_val['elevation_gain_m']:.1f} m\n" | |
| stats_text += f"- Total Elevation Loss: {route_state_val.get('elevation_loss_m', 0.0):.1f} m\n" | |
| stats_text += f"- Altitude Range: {route_state_val['min_elevation_m']:.1f}m - {route_state_val['max_elevation_m']:.1f}m\n" | |
| else: | |
| stats_text += "- Trento Route Simulation\n" | |
| # 2. Compile checkpoints | |
| checkpoints_text = "Checkpoints & Milestones:\n" | |
| if route_state_val and "checkpoints" in route_state_val and route_state_val["checkpoints"]: | |
| for cp in route_state_val["checkpoints"]: | |
| checkpoints_text += f"- {cp['name']} at Km {cp['cum_dist']:.2f} (Altitude: {cp['ele']:.1f}m)\n" | |
| else: | |
| checkpoints_text += "- Start and End checkpoints along the trail.\n" | |
| # 3. Compile amenities (POIs) | |
| amenities_text = "Amenities & Points of Interest along the Route:\n" | |
| if route_state_val and "pois" in route_state_val and route_state_val["pois"]: | |
| for poi in route_state_val["pois"]: | |
| poi_name = poi.get("name", "Unnamed") | |
| poi_type = poi.get("type", "Point of Interest").replace("_", " ").title() | |
| dist = poi.get("distance", 0.0) | |
| track_idx = poi.get("track_index", -1) | |
| dist_str = "" | |
| if track_idx != -1 and "points" in route_state_val and track_idx < len(route_state_val["points"]): | |
| poi_cum_dist = route_state_val["points"][track_idx]["cum_dist"] / 1000.0 | |
| dist_str = f"at approx. Km {poi_cum_dist:.2f}" | |
| amenities_text += f"- {poi_name} ({poi_type}) {dist_str} (located {dist:.1f} meters off the trail)\n" | |
| else: | |
| amenities_text += "- General alpine huts, shelters, and water streams close to the path.\n" | |
| if style == "Minimal Technical Gist": | |
| style_instruction = ( | |
| "Write a concise, bullet-pointed, and highly technical summary of the trek. " | |
| "Focus on the exact telemetry (distances, altitudes, checkpoints reached), voice note transcripts, " | |
| "and amenities used (water sources, huts, campsites, viewpoints). Keep it factual, objective, and brief." | |
| ) | |
| else: # "Social Media Post (Elaborative)" | |
| style_instruction = ( | |
| "Write a highly engaging, elaborative, and inspiring story formatted as a social media post (e.g., for Instagram or LinkedIn) " | |
| "targeted at an audience of outdoor enthusiasts.\n" | |
| "Include emojis, a narrative hook, paragraphs of descriptions of the journey's highs and lows, " | |
| "reflections on the voice notes, details about the amenities (water sources, viewpoints, campsites) encountered, " | |
| "and end with relevant hashtags (e.g., #HikingAdventurer, #Trailhead, #BackcountryExploration)." | |
| ) | |
| system_prompt = ( | |
| "You are a classic wilderness novelist and explorer. Write a compelling, first-person " | |
| "adventure story summarizing the trek based on the provided trek details, checkpoints, amenities, " | |
| "and the hiker's voice journal logs.\n" | |
| f"Format Style: {style_instruction}\n" | |
| "Emphasize the hiker's voice notes, detailing their personal reflections, physical state, and " | |
| "wilderness observations. Incorporate the trek details (distance, elevation, altitude) to frame the physical challenge. " | |
| "Synthesize the encountered amenities (water sources, campsites, alpine huts, shelters, viewpoints) naturally in a non-technical, " | |
| "cohesive narrative flow rather than listing every single route coordinate or waypoint. Do not list every milestone or amenity one-by-one. " | |
| "Do not invent external landmarks, voice notes, or major events not provided. Keep the tone rugged, epic, and highly tactical. " | |
| "At the end, sign off as 'Trailhead AI Storyteller'." | |
| ) | |
| prompt = f"""Here are the details of my wilderness journey: | |
| {stats_text} | |
| {checkpoints_text} | |
| {amenities_text} | |
| Here are my recorded voice logs during the trek: | |
| {journal_text} | |
| Please write a cohesive first-person adventure story of my trek.""" | |
| story = llm.generate(prompt, system=system_prompt, stream=False) | |
| # Save to a file in temp | |
| os.makedirs("./temp", exist_ok=True) | |
| story_file = os.path.abspath("./temp/post_trek_story.md") | |
| with open(story_file, "w", encoding="utf-8") as f: | |
| f.write(story) | |
| return story, story_file | |
| # --- Gradio Blocks UI --- | |
| with gr.Blocks(title="Trailhead — Tactical Trail Computer") as demo: | |
| route_state = gr.State(None) | |
| null_state = gr.State(None) | |
| current_point_idx = gr.State(0) | |
| hiker_pos_coords = gr.Textbox(visible=True, elem_id="hiker-pos-coords") | |
| live_gps_coords = gr.Textbox(visible=True, elem_id="live-gps-coords") | |
| route_data_json = gr.Textbox(visible=True, elem_id="route-data-json") | |
| hiker_pos_coords.change( | |
| fn=None, | |
| inputs=[hiker_pos_coords], | |
| outputs=None, | |
| js=""" | |
| (coords) => { | |
| window.pendingHikerCoords = coords; | |
| if (window.updateHikerPosHandler) { | |
| window.updateHikerPosHandler(coords); | |
| } | |
| } | |
| """ | |
| ) | |
| route_data_json.change( | |
| fn=None, | |
| inputs=[route_data_json], | |
| outputs=None, | |
| js=""" | |
| (routeJson) => { | |
| window.pendingRouteData = routeJson; | |
| if (window.routeDataChangeHandler) { | |
| window.routeDataChangeHandler(routeJson); | |
| } | |
| } | |
| """ | |
| ) | |
| gr.HTML(""" | |
| <div style='text-align: center; padding: 10px 0;'> | |
| <h1>🌲 Trailhead 🌲</h1> | |
| <p style='color: #f59e0b; font-family: "Share Tech Mono", monospace; letter-spacing: 0.1em; text-transform: uppercase; font-size: 1rem; margin-top: -5px;'> | |
| Off-the-Grid Trail Computer & Route Planner | |
| </p> | |
| </div> | |
| """) | |
| # Timer loop for simulation | |
| timer = gr.Timer(value=0.2, active=False) | |
| with gr.Tabs(): | |
| with gr.TabItem("🧭 Trek Planner & HUD"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 📂 Route Ingestion") | |
| preloaded_route = gr.Dropdown( | |
| choices=["Preloaded Route: Trento Track"], | |
| value="Preloaded Route: Trento Track", | |
| label="Preloaded Routes" | |
| ) | |
| upload_file = gr.File( | |
| file_types=[".gpx"], | |
| label="Upload GPX Route File" | |
| ) | |
| # --- Live GPS Toggle (above controls) --- | |
| live_gps_toggle = gr.Checkbox( | |
| label="📡 Enable Live GPS Tracking", | |
| value=False, | |
| elem_id="live-gps-toggle-box" | |
| ) | |
| gps_status_html = gr.HTML( | |
| value="<div style='font-size:0.78rem; color:var(--text-muted); margin-top:-6px; margin-bottom:4px;'>GPS off — toggle to track your real-world position on the map.</div>" | |
| ) | |
| gr.Markdown("### 🎮 Trek Simulation Controls") | |
| with gr.Row(): | |
| start_btn = gr.Button("▶ START", variant="primary") | |
| pause_btn = gr.Button("⏸ PAUSE", variant="secondary") | |
| reset_btn = gr.Button("🔄 RESET", variant="secondary") | |
| speed_slider = gr.Slider(minimum=1, maximum=20, step=1, value=1, label="Simulation Speed (Points per tick)") | |
| gr.Markdown("### ⚠️ Active Proximity Alerts") | |
| alerts_output = gr.HTML(value="<div style='color:var(--text-muted);'>No active proximity alerts.</div>") | |
| with gr.Accordion("📍 Tag Custom Waypoint (Offline)", open=False): | |
| wp_type = gr.Dropdown( | |
| choices=["drinking_water", "shelter", "camp_site", "viewpoint", "hazard"], | |
| value="drinking_water", | |
| label="Waypoint Type" | |
| ) | |
| wp_name = gr.Textbox(placeholder="Name (e.g. Fresh Stream)", label="Waypoint Name") | |
| tag_wp_btn = gr.Button("Tag Current Location", variant="primary") | |
| clear_wps_btn = gr.Button("Clear Tagged Waypoints", variant="stop") | |
| tag_status = gr.HTML(value="") | |
| tagged_wps_table = gr.DataFrame( | |
| headers=["Timestamp", "Type", "Name", "Coordinates", "Altitude"], | |
| datatype=["str", "str", "str", "str", "str"], | |
| value=[] | |
| ) | |
| with gr.Column(scale=2): | |
| # Stats display | |
| stats_display = gr.HTML() | |
| # Interactive Map display | |
| map_display = gr.HTML(value=MAP_HTML_INITIALIZER) | |
| # Elevation Profile display | |
| elevation_profile_plot = gr.Plot(label="Elevation Profile", elem_id="elevation-plot") | |
| # Narration briefing output | |
| narration_output = gr.HTML(value="") | |
| with gr.Accordion("📋 Route Checkpoint Briefing", open=True): | |
| checkpoint_table = gr.DataFrame( | |
| headers=["Checkpoint", "Coordinates", "Cumulative Distance", "Altitude"], | |
| datatype=["str", "str", "str", "str"], | |
| column_count=(4, "fixed") | |
| ) | |
| with gr.TabItem("💬 Wilderness Guide & First-Aid AI"): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| # Dynamically configure chatbot to use "messages" type if on Gradio 5 | |
| gradio_version = getattr(gr, "__version__", "5.0.0") | |
| if gradio_version.startswith("6"): | |
| chatbot_component = gr.Chatbot() | |
| else: | |
| chatbot_component = gr.Chatbot(type="messages") | |
| gr.ChatInterface( | |
| respond, | |
| chatbot=chatbot_component, | |
| examples=[ | |
| "What gear checklist do I need for a 3-day high-altitude trek?", | |
| "How do I treat a sprained ankle on the trail?", | |
| "What is Naismith's Rule for calculating hiking time?" | |
| ] | |
| ) | |
| with gr.Column(scale=1): | |
| gr.Markdown(EMERGENCY_CARD) | |
| with gr.Accordion("🔍 First-Aid Manual Quick Search", open=False): | |
| rag_query = gr.Textbox(placeholder="What symptoms or injury do you want to query?", label="Query Symptoms") | |
| rag_search_btn = gr.Button("Search manual", variant="primary") | |
| rag_output = gr.Markdown(value="*Manual results will be displayed here.*") | |
| with gr.TabItem("🎙️ Voice Journal & Reports"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("## 🎙️ Hands-Free Voice Trek Journal") | |
| gr.Markdown("Record audio logs during your journey. They will be automatically transcribed and geotagged with your current coordinates and altitude.") | |
| journal_audio = gr.Audio(sources=["microphone"], type="filepath", label="Record Voice Log") | |
| save_journal_btn = gr.Button("💾 Save Voice Log", variant="primary") | |
| clear_journal_btn = gr.Button("🗑️ Clear Logs", variant="stop") | |
| journal_status = gr.HTML(value="") | |
| last_transcription = gr.Textbox(label="Last Transcription (ASR)", interactive=False) | |
| with gr.Column(scale=1): | |
| gr.Markdown("## 📓 Saved Journal Logs") | |
| journal_logs_table = gr.DataFrame( | |
| headers=["Timestamp", "Coordinates", "Distance Hiked", "Transcript"], | |
| datatype=["str", "str", "str", "str"], | |
| value=[], | |
| column_widths=["22%", "20%", "18%", "40%"], | |
| elem_id="journal-logs-table" | |
| ) | |
| gr.Markdown("---") | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| gr.Markdown("## 📖 Post-Trek AI Storyteller") | |
| gr.Markdown("Click below to compile all your saved voice journal logs and route statistics into an AI-narrated story of your adventure!") | |
| story_style = gr.Radio( | |
| choices=["Minimal Technical Gist", "Social Media Post (Elaborative)"], | |
| value="Social Media Post (Elaborative)", | |
| label="Story Style / Format" | |
| ) | |
| generate_story_btn = gr.Button("🎬 Generate AI Trek Story", variant="primary") | |
| story_output = gr.Markdown(value="*Your adventure narrative will be generated here.*") | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 📥 Download Story") | |
| story_download = gr.File(label="Download Story (Markdown)", interactive=False) | |
| # --- Simulation player bindings --- | |
| timer.tick( | |
| fn=step_simulation, | |
| inputs=[current_point_idx, route_state, speed_slider], | |
| outputs=[current_point_idx, stats_display, alerts_output, narration_output, hiker_pos_coords, elevation_profile_plot, timer] | |
| ) | |
| def handle_start(live_gps_enabled): | |
| if live_gps_enabled: | |
| # GPS mode: don't start simulation timer; GPS JS handles map updates | |
| return gr.update(active=False), gr.update(value="<div style='font-size:0.78rem; color:#4ade80; margin-top:-6px; margin-bottom:4px;'>🟢 GPS Active — tracking your location every 5s.</div>") | |
| else: | |
| return gr.update(active=True), gr.update(value="<div style='font-size:0.78rem; color:var(--text-muted); margin-top:-6px; margin-bottom:4px;'>▶ Simulation running...</div>") | |
| start_btn.click( | |
| fn=handle_start, | |
| inputs=[live_gps_toggle], | |
| outputs=[timer, gps_status_html], | |
| js=""" | |
| (enabled) => { | |
| if (enabled && window.toggleLiveGPS) { | |
| window.toggleLiveGPS(true); | |
| } | |
| return enabled; | |
| } | |
| """ | |
| ) | |
| def handle_pause(live_gps_enabled): | |
| new_status = "<div style='font-size:0.78rem; color:var(--text-muted); margin-top:-6px; margin-bottom:4px;'>GPS off — toggle to track your real-world position on the map.</div>" | |
| return gr.update(active=False), False, gr.update(value=new_status) | |
| pause_btn.click( | |
| fn=handle_pause, | |
| inputs=[live_gps_toggle], | |
| outputs=[timer, live_gps_toggle, gps_status_html], | |
| js="""(e) => { if (window.toggleLiveGPS) window.toggleLiveGPS(false); return e; }""" | |
| ) | |
| def handle_reset(route): | |
| pts = route.get("points", []) if route else [] | |
| off_status = "<div style='font-size:0.78rem; color:var(--text-muted); margin-top:-6px; margin-bottom:4px;'>GPS off — toggle to track your real-world position on the map.</div>" | |
| if pts: | |
| stats_html, map_iframe, checkpoint_table_data = format_route_view(route) | |
| import json | |
| start_pt = pts[0] | |
| hiker_coords_json = json.dumps({ | |
| "lat": start_pt["lat"], | |
| "lon": start_pt["lon"], | |
| "ele": start_pt["ele"], | |
| "cum_dist": start_pt["cum_dist"] / 1000.0 | |
| }) | |
| ele_plot = generate_elevation_plot(pts, 0) | |
| return 0, gr.update(active=False), stats_html, "", "", hiker_coords_json, ele_plot, False, gr.update(value=off_status) | |
| return 0, gr.update(active=False), gr.update(), "", "", "", None, False, gr.update(value=off_status) | |
| reset_btn.click( | |
| fn=handle_reset, | |
| inputs=[route_state], | |
| outputs=[current_point_idx, timer, stats_display, alerts_output, narration_output, hiker_pos_coords, elevation_profile_plot, live_gps_toggle, gps_status_html], | |
| js="""(r) => { if (window.toggleLiveGPS) window.toggleLiveGPS(false); return r; }""" | |
| ) | |
| # --- Route Ingestion Triggers --- | |
| # Load default route on startup | |
| demo.load( | |
| fn=handle_route_update, | |
| inputs=[preloaded_route, upload_file], | |
| outputs=[stats_display, route_data_json, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output, hiker_pos_coords, elevation_profile_plot], | |
| js=MAP_INIT_JS | |
| ) | |
| # Preloaded selection change | |
| preloaded_route.change( | |
| fn=handle_route_update, | |
| inputs=[preloaded_route, null_state], | |
| outputs=[stats_display, route_data_json, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output, hiker_pos_coords, elevation_profile_plot] | |
| ) | |
| # Uploaded file change | |
| upload_file.change( | |
| fn=handle_route_update, | |
| inputs=[null_state, upload_file], | |
| outputs=[stats_display, route_data_json, checkpoint_table, route_state, current_point_idx, timer, alerts_output, narration_output, hiker_pos_coords, elevation_profile_plot] | |
| ) | |
| # --- Custom Waypoint Tagging Triggers --- | |
| tag_wp_btn.click( | |
| fn=handle_tag_waypoint, | |
| inputs=[wp_type, wp_name, hiker_pos_coords, route_state], | |
| outputs=[route_data_json, route_state, tag_status, tagged_wps_table] | |
| ) | |
| clear_wps_btn.click( | |
| fn=handle_clear_waypoints, | |
| inputs=[route_state], | |
| outputs=[route_data_json, route_state, tag_status, tagged_wps_table] | |
| ) | |
| # --- Voice Journal & Reports Triggers --- | |
| save_journal_btn.click( | |
| fn=handle_save_journal, | |
| inputs=[journal_audio, hiker_pos_coords], | |
| outputs=[last_transcription, journal_logs_table, journal_status, journal_audio] | |
| ) | |
| clear_journal_btn.click( | |
| fn=handle_clear_journal, | |
| inputs=[], | |
| outputs=[last_transcription, journal_logs_table, journal_status] | |
| ) | |
| generate_story_btn.click( | |
| fn=handle_generate_story, | |
| inputs=[route_state, story_style], | |
| outputs=[story_output, story_download] | |
| ) | |
| # --- RAG Trigger --- | |
| rag_search_btn.click( | |
| fn=handle_first_aid_search, | |
| inputs=[rag_query], | |
| outputs=[rag_output] | |
| ) | |
| # --- Live GPS Triggers --- | |
| live_gps_toggle.change( | |
| fn=lambda enabled: gr.update(value="<div style='font-size:0.78rem; color:#4ade80; margin-top:-6px; margin-bottom:4px;'>🟢 GPS Active — tracking your location every 5s.</div>") if enabled else gr.update(value="<div style='font-size:0.78rem; color:var(--text-muted); margin-top:-6px; margin-bottom:4px;'>GPS off — toggle to track your real-world position on the map.</div>"), | |
| inputs=[live_gps_toggle], | |
| outputs=[gps_status_html], | |
| js="(enabled) => { if (window.toggleLiveGPS) window.toggleLiveGPS(enabled); return enabled; }" | |
| ) | |
| live_gps_coords.change( | |
| fn=handle_live_gps_update, | |
| inputs=[live_gps_coords, route_state], | |
| outputs=[stats_display, alerts_output, narration_output, hiker_pos_coords, elevation_profile_plot] | |
| ) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| try: | |
| demo.launch(server_name="0.0.0.0", server_port=port, css="assets/custom.css") | |
| except OSError: | |
| print(f"[app] Port {port} is busy. Falling back to automatic port selection...") | |
| demo.launch(server_name="127.0.0.1", css="assets/custom.css") | |