| <!DOCTYPE html> |
| <html lang="it"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <meta name="theme-color" content="#ef4444"> |
| <meta name="apple-mobile-web-app-capable" content="yes"> |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| <title>RoadBumps Tracker</title> |
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> |
| <script src="https://unpkg.com/lucide@latest"></script> |
| |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap'); |
| |
| body { |
| font-family: 'Inter', sans-serif; |
| overscroll-behavior: none; |
| } |
| |
| #map { |
| height: 50vh; |
| width: 100%; |
| z-index: 1; |
| } |
| |
| .glass-panel { |
| background: rgba(255, 255, 255, 0.95); |
| backdrop-filter: blur(10px); |
| -webkit-backdrop-filter: blur(10px); |
| } |
| |
| .pulse-ring { |
| position: absolute; |
| border-radius: 50%; |
| animation: pulse-ring 2s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; |
| } |
| |
| @keyframes pulse-ring { |
| 0% { transform: scale(0.8); opacity: 0.8; } |
| 100% { transform: scale(2); opacity: 0; } |
| } |
| |
| .bump-alert { |
| animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; |
| } |
| |
| @keyframes shake { |
| 10%, 90% { transform: translate3d(-1px, 0, 0); } |
| 20%, 80% { transform: translate3d(2px, 0, 0); } |
| 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } |
| 40%, 60% { transform: translate3d(4px, 0, 0); } |
| } |
| |
| .sensor-bar { |
| transition: width 0.1s ease; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-100 h-screen flex flex-col overflow-hidden"> |
|
|
| |
| <div class="glass-panel shadow-lg z-20 px-4 py-3 flex items-center justify-between border-b border-gray-200"> |
| <div class="flex items-center gap-2"> |
| <div class="bg-red-500 text-white p-2 rounded-lg"> |
| <i data-lucide="alert-triangle" class="w-5 h-5"></i> |
| </div> |
| <div> |
| <h1 class="font-bold text-gray-800 text-lg leading-tight">RoadBumps</h1> |
| <p class="text-xs text-gray-500">Rilevatore dissesti stradali</p> |
| </div> |
| </div> |
| <div class="flex items-center gap-2"> |
| <div id="status-indicator" class="w-3 h-3 rounded-full bg-gray-400"></div> |
| <span id="status-text" class="text-xs font-medium text-gray-600">Standby</span> |
| </div> |
| </div> |
|
|
| |
| <div id="map" class="shadow-inner"></div> |
|
|
| |
| <div class="flex-1 glass-panel flex flex-col overflow-hidden"> |
| |
| |
| <div class="p-4 border-b border-gray-200 flex gap-2 overflow-x-auto"> |
| <button id="btn-main" onclick="toggleMain()" class="flex-1 bg-green-500 hover:bg-green-600 text-white font-semibold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 shadow-lg shadow-green-500/30"> |
| <i data-lucide="play" class="w-5 h-5"></i> |
| <span>Avvia</span> |
| </button> |
| |
| <button id="btn-stop" onclick="stopAll()" class="hidden bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-4 rounded-xl flex items-center gap-2 transition-all active:scale-95"> |
| <i data-lucide="x" class="w-5 h-5"></i> |
| <span>Termina</span> |
| </button> |
| |
| <button onclick="document.getElementById('import-input').click()" class="bg-purple-500 hover:bg-purple-600 text-white font-semibold py-3 px-4 rounded-xl flex items-center gap-2 transition-all active:scale-95 shadow-lg shadow-purple-500/30" title="Importa JSON"> |
| <i data-lucide="upload" class="w-5 h-5"></i> |
| </button> |
| <input type="file" id="import-input" accept=".json" class="hidden" onchange="importData(this)"> |
| |
| <button onclick="exportData()" class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-3 px-4 rounded-xl flex items-center gap-2 transition-all active:scale-95 shadow-lg shadow-blue-500/30"> |
| <i data-lucide="download" class="w-5 h-5"></i> |
| </button> |
| |
| <button onclick="recenterMap()" class="bg-indigo-500 hover:bg-indigo-600 text-white font-semibold py-3 px-4 rounded-xl flex items-center gap-2 transition-all active:scale-95" title="Centra mappa"> |
| <i data-lucide="crosshair" class="w-5 h-5"></i> |
| </button> |
| <button onclick="clearData()" class="bg-gray-500 hover:bg-gray-600 text-white font-semibold py-3 px-4 rounded-xl flex items-center gap-2 transition-all active:scale-95"> |
| <i data-lucide="trash-2" class="w-5 h-5"></i> |
| </button> |
| </div> |
|
|
| |
| <div class="px-4 py-2 bg-gray-50 border-b border-gray-200 space-y-2"> |
| <div class="flex items-center justify-between"> |
| <span class="text-xs font-semibold text-gray-600">Soglia rilevamento</span> |
| <select id="threshold-select" onchange="updateThreshold()" class="text-xs border rounded px-2 py-1 bg-white"> |
| <option value="8">8 m/s² (Molto sensibile)</option> |
| <option value="10">10 m/s² (Sensibile)</option> |
| <option value="12" selected>12 m/s² (Standard - consigliato)</option> |
| <option value="15">15 m/s² (Medio)</option> |
| <option value="20">20 m/s² (Solo forti)</option> |
| </select> |
| </div> |
| <div class="flex items-center gap-2"> |
| <input type="checkbox" id="follow-toggle" class="w-4 h-4 rounded border-gray-300" checked> |
| <label for="follow-toggle" class="text-xs text-gray-600">Segui posizione (come navigatore)</label> |
| </div> |
| <div class="flex items-center gap-2"> |
| <input type="checkbox" id="privacy-toggle" class="w-4 h-4 rounded border-gray-300" checked> |
| <label for="privacy-toggle" class="text-xs text-gray-600">Privacy: offset ±10m su export</label> |
| </div> |
| </div> |
|
|
| |
| <div class="px-4 py-3 bg-gray-50 border-b border-gray-200"> |
| <div class="flex items-center justify-between mb-2"> |
| <span class="text-xs font-semibold text-gray-600 uppercase tracking-wider">Intensità Scossa</span> |
| <span id="g-force" class="text-sm font-bold text-gray-800">0.0g</span> |
| </div> |
| <div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden"> |
| <div id="intensity-bar" class="sensor-bar bg-gradient-to-r from-green-400 via-yellow-400 to-red-500 h-full rounded-full" style="width: 0%"></div> |
| </div> |
| <div class="flex justify-between mt-1 text-xs text-gray-400"> |
| <span>Lieve</span> |
| <span>Moderata</span> |
| <span>Forte</span> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 overflow-y-auto p-4 space-y-3" id="bumps-list"> |
| <div class="text-center text-gray-400 py-8"> |
| <i data-lucide="map-pin" class="w-12 h-12 mx-auto mb-2 opacity-20"></i> |
| <p class="text-sm">Nessun dissesto rilevato</p> |
| <p class="text-xs mt-1">Avvia il tracking per iniziare</p> |
| </div> |
| </div> |
|
|
| |
| <div id="alert-banner" class="hidden bg-red-500 text-white p-4 shadow-lg transform transition-transform"> |
| <div class="flex items-center gap-3"> |
| <i data-lucide="alert-octagon" class="w-6 h-6 animate-bounce"></i> |
| <div class="flex-1"> |
| <p class="font-bold">ATTENZIONE! Dosse rilevato</p> |
| <p class="text-sm opacity-90">Rallentare nelle prossimità</p> |
| </div> |
| <button onclick="dismissAlert()" class="p-1 hover:bg-white/20 rounded"> |
| <i data-lucide="x" class="w-5 h-5"></i> |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="info-modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center p-4"> |
| <div class="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl"> |
| <h3 class="font-bold text-lg mb-2">Come usare l'app</h3> |
| <ul class="space-y-2 text-sm text-gray-600 mb-4"> |
| <li class="flex gap-2"><i data-lucide="check-circle" class="w-5 h-5 text-green-500 shrink-0"></i> <span>Fissa il telefono al cruscotto in posizione verticale</span></li> |
| <li class="flex gap-2"><i data-lucide="check-circle" class="w-5 h-5 text-green-500 shrink-0"></i> <span><b>Avvia</b>: registra nuovi dissesti e naviga</span></li> |
| <li class="flex gap-2"><i data-lucide="check-circle" class="w-5 h-5 text-blue-500 shrink-0"></i> <span><b>Stop</b>: ferma registrazione, continua navigazione (avvisa dissesti già salvati)</span></li> |
| <li class="flex gap-2"><i data-lucide="check-circle" class="w-5 h-5 text-green-500 shrink-0"></i> <span><b>Riprendi</b>: torna a registrare nuovi dissesti</span></li> |
| <li class="flex gap-2"><i data-lucide="alert-circle" class="w-5 h-5 text-orange-500 shrink-0"></i> <span>Tieni l'app in primo piano (usa split screen con Maps)</span></li> |
| </ul> |
| <button onclick="closeModal()" class="w-full bg-gray-800 text-white py-3 rounded-xl font-semibold">Ho capito</button> |
| </div> |
| </div> |
|
|
| <script> |
| |
| lucide.createIcons(); |
| |
| |
| let map; |
| let userMarker; |
| let pathLine; |
| let isTracking = false; |
| let isRecording = false; |
| let isNavigating = false; |
| let accelerometer = null; |
| let geolocationId = null; |
| let detectedBumps = []; |
| let currentPosition = null; |
| let pathCoordinates = []; |
| let lastPosition = null; |
| let currentSpeed = 0; |
| |
| |
| try { |
| const stored = localStorage.getItem('roadBumps'); |
| if (stored) { |
| const parsed = JSON.parse(stored); |
| if (Array.isArray(parsed)) { |
| |
| detectedBumps = parsed.filter(b => { |
| return b && typeof b === 'object' && |
| b.id && typeof b.lat === 'number' && !isNaN(b.lat) && |
| typeof b.lng === 'number' && !isNaN(b.lng); |
| }); |
| |
| if (detectedBumps.length !== parsed.length) { |
| localStorage.setItem('roadBumps', JSON.stringify(detectedBumps)); |
| console.warn('Dati corrotti rimossi dal localStorage'); |
| } |
| } else { |
| detectedBumps = []; |
| localStorage.removeItem('roadBumps'); |
| } |
| } |
| } catch (e) { |
| console.error('Errore caricamento dati:', e); |
| detectedBumps = []; |
| localStorage.removeItem('roadBumps'); |
| } |
| |
| |
| let BUMP_THRESHOLD = 12; |
| |
| function updateThreshold() { |
| const val = document.getElementById('threshold-select')?.value; |
| if (val) BUMP_THRESHOLD = parseInt(val); |
| } |
| const COOLDOWN_MS = 4000; |
| const CLUSTER_RADIUS = 15; |
| let lastBumpTime = 0; |
| let proximityCheckInterval; |
| |
| |
| const BASE_WARNING_DISTANCE = 200; |
| const MIN_WARNING_DISTANCE = 80; |
| const MAX_WARNING_DISTANCE = 400; |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| initMap(); |
| renderBumpsList(); |
| checkPermissions(); |
| |
| |
| if (!localStorage.getItem('roadBumps_seenInfo')) { |
| document.getElementById('info-modal').classList.remove('hidden'); |
| document.getElementById('info-modal').classList.add('flex'); |
| localStorage.setItem('roadBumps_seenInfo', 'true'); |
| } |
| }); |
| |
| function initMap() { |
| map = L.map('map').setView([41.9028, 12.4964], 13); |
| |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { |
| attribution: '© OpenStreetMap contributors', |
| maxZoom: 19 |
| }).addTo(map); |
| |
| |
| detectedBumps.forEach(bump => { |
| addBumpMarker(bump); |
| }); |
| } |
| |
| function checkPermissions() { |
| |
| if ('Accelerometer' in window) { |
| console.log('Accelerometer supportato'); |
| } else { |
| alert('Il tuo dispositivo non supporta l\'Accelerometer API. Prova con Chrome su Android.'); |
| } |
| } |
| |
| |
| async function toggleMain() { |
| if (!isNavigating && !isRecording) { |
| |
| try { |
| await startRecordingAndNav(); |
| } catch (e) { |
| alert('Errore nell\'avvio: ' + e.message); |
| } |
| } else if (isRecording) { |
| |
| stopRecordingOnly(); |
| } else if (isNavigating && !isRecording) { |
| |
| await startRecordingOnly(); |
| } |
| updateUI(); |
| } |
| |
| function stopAll() { |
| |
| stopNavigation(); |
| updateUI(); |
| } |
| |
| function updateUI() { |
| const btnMain = document.getElementById('btn-main'); |
| const btnStop = document.getElementById('btn-stop'); |
| const statusInd = document.getElementById('status-indicator'); |
| const statusText = document.getElementById('status-text'); |
| |
| |
| btnMain.className = 'flex-1 font-semibold py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all active:scale-95 shadow-lg'; |
| |
| if (!isNavigating && !isRecording) { |
| |
| btnMain.innerHTML = '<i data-lucide="play" class="w-5 h-5"></i><span>Avvia</span>'; |
| btnMain.classList.add('bg-green-500', 'hover:bg-green-600', 'text-white', 'shadow-green-500/30'); |
| |
| btnStop.classList.add('hidden'); |
| |
| statusInd.className = 'w-3 h-3 rounded-full bg-gray-400'; |
| statusText.textContent = 'Standby'; |
| |
| } else if (isRecording) { |
| |
| btnMain.innerHTML = '<i data-lucide="square" class="w-5 h-5"></i><span>Stop</span>'; |
| btnMain.classList.add('bg-red-500', 'hover:bg-red-600', 'text-white', 'shadow-red-500/30'); |
| |
| btnStop.classList.remove('hidden'); |
| |
| statusInd.className = 'w-3 h-3 rounded-full bg-green-500 animate-pulse'; |
| statusText.textContent = 'Registrazione...'; |
| |
| } else if (isNavigating && !isRecording) { |
| |
| btnMain.innerHTML = '<i data-lucide="refresh-cw" class="w-5 h-5"></i><span>Riprendi</span>'; |
| btnMain.classList.add('bg-blue-500', 'hover:bg-blue-600', 'text-white', 'shadow-blue-500/30'); |
| |
| btnStop.classList.remove('hidden'); |
| |
| statusInd.className = 'w-3 h-3 rounded-full bg-blue-500 animate-pulse'; |
| statusText.textContent = 'Navigazione attiva'; |
| } |
| |
| lucide.createIcons(); |
| } |
| |
| |
| async function toggleTracking() { |
| await toggleMain(); |
| } |
| |
| async function startTracking() { |
| |
| if (!navigator.geolocation) { |
| throw new Error('Geolocalizzazione non supportata'); |
| } |
| |
| geolocationId = navigator.geolocation.watchPosition( |
| (position) => { |
| const newPosition = { |
| lat: position.coords.latitude, |
| lng: position.coords.longitude, |
| accuracy: position.coords.accuracy, |
| timestamp: Date.now(), |
| speed: position.coords.speed |
| }; |
| |
| |
| if (lastPosition && !newPosition.speed) { |
| const dist = calculateDistance(lastPosition.lat, lastPosition.lng, newPosition.lat, newPosition.lng); |
| const timeDiff = (newPosition.timestamp - lastPosition.timestamp) / 1000; |
| if (timeDiff > 0) { |
| const speedMs = dist / timeDiff; |
| currentSpeed = speedMs * 3.6; |
| } |
| } else if (newPosition.speed) { |
| currentSpeed = newPosition.speed * 3.6; |
| } |
| |
| currentPosition = newPosition; |
| lastPosition = newPosition; |
| |
| updateUserPosition(currentPosition); |
| pathCoordinates.push([currentPosition.lat, currentPosition.lng]); |
| |
| if (pathLine) { |
| pathLine.setLatLngs(pathCoordinates); |
| } else { |
| pathLine = L.polyline(pathCoordinates, {color: 'blue', weight: 4, opacity: 0.7}).addTo(map); |
| } |
| }, |
| (err) => console.error('GPS Error:', err), |
| { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 } |
| ); |
| |
| |
| if ('Accelerometer' in window) { |
| try { |
| |
| if (navigator.permissions) { |
| const result = await navigator.permissions.query({ name: 'accelerometer' }); |
| if (result.state === 'denied') { |
| throw new Error('Permesso accelerometro negato'); |
| } |
| } |
| |
| accelerometer = new Accelerometer({ frequency: 60 }); |
| |
| accelerometer.addEventListener('reading', () => { |
| processAccelerometerData(accelerometer.x, accelerometer.y, accelerometer.z); |
| }); |
| |
| accelerometer.start(); |
| } catch (e) { |
| console.warn('Accelerometro non disponibile, uso mock per test:', e); |
| |
| |
| } |
| } else { |
| alert('API Accelerometro non supportata. Usa Chrome su Android con HTTPS.'); |
| } |
| |
| |
| proximityCheckInterval = setInterval(checkProximity, 3000); |
| |
| isTracking = true; |
| } |
| |
| function stopTracking() { |
| isTracking = false; |
| |
| if (geolocationId) { |
| navigator.geolocation.clearWatch(geolocationId); |
| } |
| |
| if (accelerometer) { |
| accelerometer.stop(); |
| accelerometer = null; |
| } |
| |
| if (proximityCheckInterval) { |
| clearInterval(proximityCheckInterval); |
| } |
| } |
| |
| function processAccelerometerData(x, y, z) { |
| if (!currentPosition) return; |
| |
| |
| |
| const verticalAccel = Math.abs(z - 9.8); |
| const totalAccel = Math.sqrt(x*x + y*y + z*z); |
| |
| |
| const intensity = Math.min((verticalAccel / 20) * 100, 100); |
| document.getElementById('intensity-bar').style.width = intensity + '%'; |
| document.getElementById('g-force').textContent = (verticalAccel / 9.8).toFixed(1) + 'g'; |
| |
| |
| const bar = document.getElementById('intensity-bar'); |
| if (verticalAccel > BUMP_THRESHOLD) { |
| bar.classList.remove('from-green-400', 'via-yellow-400', 'to-red-500'); |
| bar.classList.add('bg-red-600'); |
| } else { |
| bar.classList.add('from-green-400', 'via-yellow-400', 'to-red-500'); |
| bar.classList.remove('bg-red-600'); |
| } |
| |
| |
| const now = Date.now(); |
| if (verticalAccel > BUMP_THRESHOLD && (now - lastBumpTime > COOLDOWN_MS)) { |
| lastBumpTime = now; |
| registerBump(currentPosition, verticalAccel); |
| } |
| } |
| |
| function registerBump(position, intensity) { |
| |
| |
| const now = Date.now(); |
| const recentDuplicate = detectedBumps.find(b => { |
| if (!b || !b.lat || !b.lng) return false; |
| const dist = calculateDistance(position.lat, position.lng, b.lat, b.lng); |
| const timeDiff = now - (b.id || 0); |
| |
| return dist < CLUSTER_RADIUS && timeDiff < 10000; |
| }); |
| |
| if (recentDuplicate) { |
| |
| if (intensity > (recentDuplicate.intensity || 0)) { |
| recentDuplicate.intensity = intensity; |
| recentDuplicate.type = intensity > 35 ? 'buca_profonda' : (intensity > 25 ? 'dosso' : 'imperfezione'); |
| localStorage.setItem('roadBumps', JSON.stringify(detectedBumps)); |
| renderBumpsList(); |
| } |
| console.log('Clustering: punto vicino esistente, skip registrazione'); |
| return; |
| } |
| |
| |
| let type = 'imperfezione'; |
| if (intensity > 35) type = 'buca_profonda'; |
| else if (intensity > 25) type = 'dosso'; |
| else type = 'sussulto_medio'; |
| |
| const bump = { |
| id: now, |
| lat: position.lat, |
| lng: position.lng, |
| intensity: intensity, |
| timestamp: new Date().toISOString(), |
| type: type |
| }; |
| |
| detectedBumps.push(bump); |
| localStorage.setItem('roadBumps', JSON.stringify(detectedBumps)); |
| |
| |
| addBumpMarker(bump); |
| |
| |
| renderBumpsList(); |
| |
| |
| const list = document.getElementById('bumps-list'); |
| list.classList.add('bump-alert'); |
| setTimeout(() => list.classList.remove('bump-alert'), 500); |
| } |
| |
| function addBumpMarker(bump) { |
| const color = bump.intensity > 25 ? 'red' : 'orange'; |
| const icon = L.divIcon({ |
| className: 'custom-div-icon', |
| html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`, |
| iconSize: [12, 12], |
| iconAnchor: [6, 6] |
| }); |
| |
| const marker = L.marker([bump.lat, bump.lng], { icon }).addTo(map); |
| |
| |
| const safeType = bump.type === 'buca_profonda' ? 'Buca Profonda' : 'Dosso/Dissesto'; |
| const safeIntensity = parseFloat(bump.intensity) || 0; |
| const safeDate = bump.timestamp ? new Date(bump.timestamp).toLocaleString() : 'Data non disponibile'; |
| |
| const popupContent = ` |
| <div class="text-sm"> |
| <strong>${safeType}</strong><br> |
| Intensità: ${(safeIntensity/9.8).toFixed(1)}g<br> |
| <small>${safeDate}</small> |
| </div> |
| `; |
| |
| marker.bindPopup(popupContent); |
| } |
| |
| function checkProximity() { |
| if (!currentPosition || detectedBumps.length === 0 || !isNavigating) return; |
| |
| |
| |
| |
| |
| let warningDistance = BASE_WARNING_DISTANCE; |
| if (currentSpeed > 0) { |
| |
| const timeToReact = 22; |
| const speedMs = currentSpeed / 3.6; |
| warningDistance = Math.max(MIN_WARNING_DISTANCE, |
| Math.min(MAX_WARNING_DISTANCE, speedMs * timeToReact)); |
| } |
| |
| let nearestBump = null; |
| let minDistance = Infinity; |
| |
| detectedBumps.forEach(bump => { |
| if (!bump || !bump.lat || !bump.lng) return; |
| |
| const dist = calculateDistance( |
| currentPosition.lat, currentPosition.lng, |
| bump.lat, bump.lng |
| ); |
| |
| if (dist < minDistance && dist < warningDistance) { |
| minDistance = dist; |
| nearestBump = bump; |
| } |
| }); |
| |
| if (nearestBump) { |
| const timeToArrival = currentSpeed > 0 ? Math.round((minDistance / (currentSpeed / 3.6))) : 0; |
| showAlert(nearestBump, minDistance, timeToArrival); |
| } else { |
| hideAlert(); |
| } |
| } |
| |
| function calculateDistance(lat1, lon1, lat2, lon2) { |
| const R = 6371000; |
| const φ1 = lat1 * Math.PI/180; |
| const φ2 = lat2 * Math.PI/180; |
| const Δφ = (lat2-lat1) * Math.PI/180; |
| const Δλ = (lon2-lon1) * Math.PI/180; |
| |
| const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + |
| Math.cos(φ1) * Math.cos(φ2) * |
| Math.sin(Δλ/2) * Math.sin(Δλ/2); |
| const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); |
| |
| return R * c; |
| } |
| |
| function showAlert(bump, distance, secondsToArrival = 0) { |
| const banner = document.getElementById('alert-banner'); |
| const title = document.getElementById('alert-title') || banner.querySelector('p.font-bold'); |
| const subtext = document.getElementById('alert-subtext') || banner.querySelector('p.text-sm'); |
| |
| |
| if (!banner || !title || !subtext) return; |
| if (!bump || typeof bump !== 'object') { |
| console.error('showAlert: bump invalido', bump); |
| return; |
| } |
| |
| |
| const safeType = String(bump.type || 'dissesto'); |
| let tipoLabel = 'Dissesto stradale'; |
| if (safeType === 'buca_profonda') tipoLabel = 'Buca profonda'; |
| else if (safeType === 'dosso') tipoLabel = 'Dosso stradale'; |
| else if (safeType === 'sussulto_medio' || safeType === 'imperfezione') tipoLabel = 'Irregolarità'; |
| |
| const dist = Math.round(parseFloat(distance) || 0); |
| let distanceText = `tra ${dist}m`; |
| |
| |
| const secs = parseInt(secondsToArrival) || 0; |
| if (secs > 0 && secs < 60) { |
| distanceText += ` (${secs}s)`; |
| } else if (secs >= 60) { |
| distanceText += ` (${Math.round(secs/60)}min)`; |
| } |
| |
| |
| title.textContent = `⚠️ ${tipoLabel} ${distanceText}`; |
| |
| |
| const speed = parseFloat(currentSpeed) || 0; |
| let speedText = 'Rallentare nelle prossimità'; |
| if (speed > 5) { |
| speedText = `Rallenta! Stai andando a ${Math.round(speed)} km/h`; |
| } else if (speed > 0) { |
| speedText = `Vicinanza dissesto (${Math.round(speed)} km/h)`; |
| } |
| subtext.textContent = speedText; |
| |
| if (banner.classList.contains('hidden')) { |
| banner.classList.remove('hidden'); |
| banner.classList.remove('translate-y-full'); |
| |
| |
| if (navigator.vibrate && isNavigating) { |
| navigator.vibrate([200, 100, 200, 100, 400]); |
| } |
| |
| |
| if (distance < 80 && navigator.vibrate && isNavigating) { |
| setTimeout(() => navigator.vibrate([100, 50, 100, 50, 300]), 1000); |
| } |
| } |
| } |
| |
| function hideAlert() { |
| const banner = document.getElementById('alert-banner'); |
| if (!banner.classList.contains('hidden')) { |
| banner.classList.add('hidden'); |
| } |
| } |
| |
| function dismissAlert() { |
| hideAlert(); |
| |
| setTimeout(() => { |
| |
| }, 30000); |
| } |
| |
| let userHasMovedMap = false; |
| |
| |
| map.on('dragstart', () => { userHasMovedMap = true; }); |
| |
| function updateUserPosition(pos) { |
| if (!userMarker) { |
| userMarker = L.circleMarker([pos.lat, pos.lng], { |
| radius: 8, |
| fillColor: "#3b82f6", |
| color: "#fff", |
| weight: 2, |
| opacity: 1, |
| fillOpacity: 0.8 |
| }).addTo(map); |
| } else { |
| userMarker.setLatLng([pos.lat, pos.lng]); |
| } |
| |
| |
| const autoFollow = document.getElementById('follow-toggle')?.checked ?? true; |
| if (autoFollow && !userHasMovedMap) { |
| map.setView([pos.lat, pos.lng], 17); |
| } |
| } |
| |
| |
| function recenterMap() { |
| if (currentPosition) { |
| map.setView([currentPosition.lat, currentPosition.lng], 17); |
| userHasMovedMap = false; |
| } |
| } |
| |
| function renderBumpsList() { |
| const container = document.getElementById('bumps-list'); |
| |
| |
| if (!container) return; |
| |
| if (!detectedBumps || detectedBumps.length === 0) { |
| container.innerHTML = ` |
| <div class="text-center text-gray-400 py-8"> |
| <i data-lucide="map-pin" class="w-12 h-12 mx-auto mb-2 opacity-20"></i> |
| <p class="text-sm">Nessun dissesto rilevato</p> |
| <p class="text-xs mt-1">Avvia il tracking per iniziare</p> |
| </div> |
| `; |
| lucide.createIcons(); |
| return; |
| } |
| |
| |
| |
| const sorted = [...detectedBumps] |
| .filter(b => b && b.id && b.lat && b.lng && (b.intensity || 0) >= 20) |
| .sort((a, b) => (b.id || 0) - (a.id || 0)); |
| |
| container.innerHTML = sorted.map(bump => { |
| |
| const intensity = parseFloat(bump.intensity) || 0; |
| const type = bump.type || 'dosso'; |
| |
| let colorClass = 'bg-yellow-100 text-yellow-600'; |
| let iconName = 'alert-circle'; |
| if (intensity > 35) { |
| colorClass = 'bg-red-100 text-red-600'; |
| iconName = 'alert-triangle'; |
| } else if (intensity > 25) { |
| colorClass = 'bg-orange-100 text-orange-600'; |
| iconName = 'alert-circle'; |
| } |
| |
| const gForce = (intensity / 9.8).toFixed(1); |
| const dateStr = bump.timestamp ? new Date(bump.timestamp).toLocaleString() : 'Data sconosciuta'; |
| |
| |
| let typeLabel = 'Irregolarità'; |
| if (type === 'buca_profonda') typeLabel = 'Buca Profonda'; |
| else if (type === 'dosso') typeLabel = 'Dosso Stradale'; |
| else if (type === 'sussulto_medio') typeLabel = 'Sussulto Medio'; |
| |
| |
| const safeLat = parseFloat(bump.lat); |
| const safeLng = parseFloat(bump.lng); |
| |
| return ` |
| <div class="bg-white rounded-xl p-3 shadow-sm border border-gray-200 flex items-center gap-3"> |
| <div class="w-10 h-10 rounded-full ${colorClass} flex items-center justify-center shrink-0"> |
| <i data-lucide="${iconName}" class="w-5 h-5"></i> |
| </div> |
| <div class="flex-1 min-w-0"> |
| <p class="font-semibold text-gray-800 text-sm truncate"> |
| ${typeLabel} |
| </p> |
| <p class="text-xs text-gray-500"> |
| ${dateStr} • ${gForce}g |
| </p> |
| </div> |
| <button onclick="centerOnBump(${safeLat}, ${safeLng})" class="p-2 hover:bg-gray-100 rounded-lg text-gray-400 hover:text-blue-500"> |
| <i data-lucide="map-pin" class="w-4 h-4"></i> |
| </button> |
| </div> |
| `}).join(''); |
| |
| lucide.createIcons(); |
| } |
| |
| function centerOnBump(lat, lng) { |
| |
| const safeLat = parseFloat(lat); |
| const safeLng = parseFloat(lng); |
| if (isNaN(safeLat) || isNaN(safeLng)) { |
| console.error('Coordinate invalide:', lat, lng); |
| alert('Errore: coordinate del dissesto non valide'); |
| return; |
| } |
| map.setView([safeLat, safeLng], 18); |
| } |
| |
| function importData(input) { |
| const file = input.files[0]; |
| if (!file) return; |
| |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| try { |
| const imported = JSON.parse(e.target.result); |
| if (!Array.isArray(imported)) throw new Error('Formato non valido'); |
| |
| let added = 0; |
| let merged = 0; |
| |
| imported.forEach(imp => { |
| if (!imp || !imp.lat || !imp.lng || isNaN(imp.lat) || isNaN(imp.lng)) return; |
| |
| |
| const duplicate = detectedBumps.find(b => { |
| if (!b || !b.lat || !b.lng) return false; |
| return calculateDistance(b.lat, b.lng, imp.lat, imp.lng) < 8; |
| }); |
| |
| if (duplicate) { |
| if ((imp.intensity || 0) > (duplicate.intensity || 0)) { |
| duplicate.intensity = imp.intensity; |
| duplicate.type = imp.type; |
| } |
| merged++; |
| } else { |
| detectedBumps.push({ |
| id: Date.now() + Math.random(), |
| lat: parseFloat(imp.lat), |
| lng: parseFloat(imp.lng), |
| intensity: parseFloat(imp.intensity) || 15, |
| timestamp: imp.timestamp || new Date().toISOString(), |
| type: imp.type || 'dosso', |
| fromImport: true |
| }); |
| added++; |
| } |
| }); |
| |
| saveData(); |
| renderBumpsList(); |
| alert(`Importazione: ${added} nuovi, ${merged} aggiornati`); |
| } catch (err) { |
| alert('Errore importazione: ' + err.message); |
| } |
| }; |
| reader.readAsText(file); |
| input.value = ''; |
| } |
| |
| function exportData() { |
| if (detectedBumps.length === 0) { |
| alert('Nessun dato da esportare'); |
| return; |
| } |
| |
| const privacyOn = document.getElementById('privacy-toggle')?.checked; |
| let dataToExport = detectedBumps; |
| |
| if (privacyOn) { |
| |
| dataToExport = detectedBumps.map(b => ({ |
| ...b, |
| lat: b.lat + (Math.random() - 0.5) * 0.00018, |
| lng: b.lng + (Math.random() - 0.5) * 0.00018, |
| privacyFuzzed: true |
| })); |
| } |
| |
| const dataStr = JSON.stringify(dataToExport, null, 2); |
| const dataBlob = new Blob([dataStr], {type: 'application/json'}); |
| const url = URL.createObjectURL(dataBlob); |
| const link = document.createElement('a'); |
| link.href = url; |
| link.download = `roadbumps_${new Date().toISOString().split('T')[0]}.json`; |
| link.click(); |
| |
| |
| if (navigator.share) { |
| const file = new File([dataBlob], 'dissesti.json', { type: 'application/json' }); |
| navigator.share({ |
| title: 'Dati RoadBumps', |
| text: `Esportati ${detectedBumps.length} dissesti stradali`, |
| files: [file] |
| }).catch(console.error); |
| } |
| } |
| |
| function clearData() { |
| if (confirm('Sei sicuro di voler cancellare tutti i dati raccolti?')) { |
| detectedBumps = []; |
| localStorage.removeItem('roadBumps'); |
| |
| |
| map.eachLayer((layer) => { |
| if (layer instanceof L.Marker && layer !== userMarker) { |
| map.removeLayer(layer); |
| } |
| }); |
| |
| renderBumpsList(); |
| hideAlert(); |
| } |
| } |
| |
| |
| document.addEventListener('visibilitychange', () => { |
| if (document.hidden && isTracking) { |
| console.log('App in background - i sensori potrebbero essere limitati'); |
| |
| } |
| }); |
| |
| |
| window.addEventListener('beforeunload', (e) => { |
| if (isTracking) { |
| e.preventDefault(); |
| e.returnValue = 'Stai registrando dati. Sei sicuro di voler uscire?'; |
| } |
| }); |
| </script> |
| |
| </body> |
| </html> |