import { distance, instruction, nearbyZones, routeProgress, validateZones } from './geo.js'; export function initJourney({ storage, toast }) { const $ = s => document.querySelector(s); let position = null, watch = null, map = null, marker = null, routeLayer = null, targetMarker = null; let destination = null, route = null, routeGeneration = 0, searchGeneration = 0, weatherGeneration = 0; let weatherAt = 0, weatherRetryAt = 0, weatherPosition = null, weatherBusy = false, mapLoading = null, zones = [], audioContext; let lastSpoken = '', lastZone = '', lastZoneTime = 0; const fmtDistance = m => m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`; const readJSON = key => { try { return JSON.parse(storage.get(key, 'null')); } catch { return null; } }; async function api(path, params = {}) { const r = await fetch(`/api/${path}?${new URLSearchParams(params)}`, { signal: AbortSignal.timeout(12000) }); const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Service indisponible'); return d; } function weatherLabel(code) { if (code === 0) return ['☀', 'Ciel dégagé']; if (code <= 3) return ['⛅', 'Nuages et éclaircies']; if (code <= 48) return ['≋', 'Brouillard']; if (code >= 95) return ['ϟ', 'Orages']; if ([71,73,75,77,85,86].includes(code)) return ['❄', 'Neige']; return ['☂', 'Pluie ou bruine']; } function clearWeather(message) { ['#weather-temperature','#weather-min','#weather-max','#weather-wind'].forEach(id => $(id).textContent = '—'); $('#weather-source').textContent = 'INDISPONIBLE'; $('#weather-status').textContent = message; $('#weather-title').textContent = 'Météo indisponible'; $('#weather-icon').textContent = '◌'; } async function weather(force = false) { if (!position) return; if (!force && (weatherBusy || Date.now() < weatherRetryAt || (Date.now() - weatherAt < 15 * 60000 && weatherPosition && distance(position, weatherPosition) < 5000))) return; const id = ++weatherGeneration, at = { ...position }; weatherBusy = true; if (force || !weatherPosition || distance(at, weatherPosition) >= 5000) clearWeather('Actualisation…'); $('#weather-source').textContent = 'CHARGEMENT'; try { const d = await api('weather', { lat: at.lat, lon: at.lon }); if (id !== weatherGeneration) return; if (!position || distance(at, position) > 5000) { clearWeather('Position modifiée · nouvelle météo en attente'); weatherAt = 0; return; } const [icon, label] = weatherLabel(d.code); $('#weather-icon').textContent = icon; $('#weather-title').textContent = label; $('#weather-temperature').textContent = Math.round(d.temperature); $('#weather-min').textContent = `${Math.round(d.min)}°`; $('#weather-max').textContent = `${Math.round(d.max)}°`; $('#weather-wind').textContent = `${Math.round(d.wind)} km/h`; $('#weather-source').textContent = 'OPEN-METEO'; $('#weather-status').textContent = `${at.label} · modèle météo ${d.observedAt?.slice(11,16) || ''} (heure du lieu)`; weatherAt = Date.now(); weatherRetryAt = 0; weatherPosition = at; } catch (error) { if (id === weatherGeneration) { clearWeather(error.message); weatherAt = 0; weatherRetryAt = Date.now() + 60000; } } finally { if (id === weatherGeneration) weatherBusy = false; } } $('#weather-refresh').onclick = () => { if (!position) toast('Choisissez un lieu ou activez le GPS.'); else weather(true); }; async function ensureMap() { if (map) { map.invalidateSize(); return map; } if (mapLoading) return mapLoading; mapLoading = (async () => { if (!window.L) await new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = '/vendor/leaflet/leaflet.js'; script.onload = resolve; script.onerror = () => { script.remove(); reject(new Error('Bibliothèque cartographique indisponible')); }; document.head.append(script); }); const config = await api('map-config'); map = window.L.map('map-canvas', { zoomControl: true }).setView(position ? [position.lat, position.lon] : [46.6, 2.4], position ? 13 : 6); const attribution = document.createElement('span'); attribution.textContent = config.tileAttribution; const tiles = window.L.tileLayer(config.tileUrl, { maxZoom: 19, attribution: attribution.innerHTML + ' · Licence' }).addTo(map); tiles.on('tileerror', () => $('#map-status').textContent = 'Fond de carte indisponible : vérifiez la connexion.'); tiles.on('load', () => { $('#map-status').textContent = 'Carte © OpenStreetMap · Internet requis'; }); updateMarker(); drawRoute(); return map; })().catch(error => { $('#map-status').textContent = error.message; mapLoading = null; return null; }); return mapLoading; } function updateMarker() { if (!map) return; if (marker) { marker.remove(); marker = null; } if (position) { marker = window.L.circleMarker([position.lat, position.lon], { radius: 8, color: '#fff', weight: 3, fillColor: getComputedStyle(document.body).getPropertyValue('--accent').trim(), fillOpacity: 1 }).addTo(map); const label = document.createElement('span'); label.textContent = position.source === 'gps' ? `GPS ± ${Math.round(position.accuracy)} m` : 'Départ manuel'; marker.bindTooltip(label); } } function drawRoute() { if (!map) return; if (routeLayer) { routeLayer.remove(); routeLayer = null; } if (targetMarker) { targetMarker.remove(); targetMarker = null; } if (route) routeLayer = window.L.geoJSON(route.geometry, { style: { color: getComputedStyle(document.body).getPropertyValue('--accent').trim(), weight: 6 } }).addTo(map); if (destination) targetMarker = window.L.circleMarker([destination.lat, destination.lon], { radius: 8, color: '#1d3530', fillColor: '#fff', fillOpacity: 1 }).addTo(map); } document.addEventListener('caria-view', e => { if (e.detail === 'map') ensureMap(); }); document.addEventListener('caria-layout', () => { if(map) requestAnimationFrame(()=>map.invalidateSize()); }); document.addEventListener('caria-theme', () => { updateMarker(); drawRoute(); }); $('#map-center').onclick = async () => { const m = await ensureMap(); if (m && position) m.setView([position.lat, position.lon], 15); else if (!position) toast('Définissez d’abord un départ.'); }; function stopGPS() { gpsGeneration++; if (watch !== null) navigator.geolocation.clearWatch(watch); watch = null; $('#gps-stop').hidden = true; } function setPosition(value) { position = value; $('#position-status').textContent = value.source === 'gps' ? `GPS actif · précision ± ${Math.round(value.accuracy)} m` : `Départ manuel : ${value.label}`; updateMarker(); weather(); guidance(); checkAlerts(); } function manual(value) { stopGPS(); clearRoute(); setPosition({ ...value, source: 'manual', timestamp: Date.now() }); storage.set('position', JSON.stringify({ lat: value.lat, lon: value.lon, label: value.label })); weather(true); if (map) map.setView([value.lat, value.lon], 13); $('#place-use').value = 'destination'; } let gpsGeneration = 0; function locate() { if (!window.isSecureContext || !navigator.geolocation) { toast('GPS : ouvrez CARIA en HTTPS ou sur localhost. Le départ manuel reste disponible.'); return; } stopGPS(); const generation = ++gpsGeneration; $('#position-status').textContent = 'Recherche de la position GPS…'; watch = navigator.geolocation.watchPosition(p => { if (generation !== gpsGeneration) return; const first = position?.source !== 'gps'; setPosition({ lat: p.coords.latitude, lon: p.coords.longitude, accuracy: p.coords.accuracy, timestamp: p.timestamp, source: 'gps', label: 'Position GPS' }); $('#gps-stop').hidden = false; if (map && first) map.setView([position.lat, position.lon], 15); }, error => { if (generation !== gpsGeneration) return; $('#position-status').textContent = error.code === 1 ? 'GPS refusé. Choisissez un départ manuel.' : 'GPS indisponible. Choisissez un départ manuel ou réessayez.'; if (position?.source === 'gps') position.timestamp = 0; guidance(); checkAlerts(); if (error.code === 1) stopGPS(); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 2000 }); $('#gps-stop').hidden = false; } document.querySelectorAll('[data-locate]').forEach(button => button.onclick = locate); $('#gps-stop').onclick = () => { ++gpsGeneration; stopGPS(); position = null; updateMarker(); $('#position-status').textContent = 'GPS arrêté · choisissez un nouveau départ'; weatherGeneration++; weatherBusy = false; clearWeather('GPS arrêté · choisissez un lieu'); guidance(); checkAlerts(); }; $('#coordinates-form').onsubmit = e => { e.preventDefault(); const lat = Number($('#latitude').value), lon = Number($('#longitude').value); if (Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 85 && Math.abs(lon) <= 180) manual({ lat, lon, label: `${lat.toFixed(3)}, ${lon.toFixed(3)}` }); }; $('#place-form').onsubmit = async e => { e.preventDefault(); const id = ++searchGeneration, use = $('#place-use').value; const results = $('#place-results'); results.textContent = 'Recherche…'; try { const places = await api('search', { q: $('#place-query').value.trim() }); if (id !== searchGeneration) return; results.replaceChildren(); if (!places.length) results.textContent = 'Aucun lieu trouvé. Précisez la ville ou utilisez les coordonnées.'; for (const place of places) { const button = document.createElement('button'); button.textContent = place.label; button.onclick = () => { results.replaceChildren(); if (use === 'origin') manual(place); else if (!position) toast('Définissez un départ avant la destination.'); else { destination = place; document.dispatchEvent(new CustomEvent('caria-destination', { detail: place })); calculateRoute(); } }; results.append(button); } } catch (error) { if (id === searchGeneration) results.textContent = error.message; } }; function clearRoute() { window.speechSynthesis?.cancel(); routeGeneration++; route = null; destination = null; lastSpoken = ''; document.dispatchEvent(new CustomEvent('caria-destination', { detail: null })); $('#guidance').hidden = true; $('#route-steps').replaceChildren(); drawRoute(); } $('#route-clear').onclick = clearRoute; $('#route-recalculate').onclick = () => calculateRoute(); async function calculateRoute() { if (!position || !destination) return toast('Choisissez un départ et une destination.'); const id = ++routeGeneration, origin = { ...position }, target = { ...destination }; route = null; drawRoute(); $('#route-steps').replaceChildren(); $('#guidance').hidden = false; $('#next-instruction').textContent = 'Calcul de l’itinéraire…'; $('#route-summary').textContent = target.label; try { const data = await api('route', { fromLat: origin.lat, fromLon: origin.lon, toLat: target.lat, toLon: target.lon }); if (id !== routeGeneration) return; if (!position || distance(origin, position) > 200) throw new Error('Le départ a changé. Recalculez le trajet.'); route = data; lastSpoken = ''; drawRoute(); if (map && routeLayer) map.fitBounds(routeLayer.getBounds(), { padding: [25, 25] }); for (const step of route.steps) { const li = document.createElement('li'); li.textContent = `${instruction(step)} · ${fmtDistance(step.distance)}`; $('#route-steps').append(li); } guidance(); } catch (error) { if (id === routeGeneration) { $('#next-instruction').textContent = error.message; $('#route-summary').textContent = 'Itinéraire non disponible'; } } } function speak(text) { if (!('speechSynthesis' in window)) return; const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'fr-FR'; window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); } $('#voice').onchange = () => { if ($('#voice').checked) { if ('speechSynthesis' in window) speak('Guidage vocal activé'); else toast('La synthèse vocale n’est pas disponible.'); } else window.speechSynthesis?.cancel(); }; function guidance() { if (!route) return; const freshGPS = position?.source === 'gps' && Math.abs(Date.now() - position.timestamp) < 15000 && position.accuracy <= 100; let progress = 0, index = 0; if (freshGPS) { const p = routeProgress(route.geometry.coordinates, position); if (p.away > 100) { $('#next-instruction').textContent = 'Hors itinéraire · recalculez le trajet'; return; } progress = p.fraction * route.distance; let cumulative = 0; while (index < route.steps.length - 1 && cumulative + route.steps[index].distance < progress + 30) cumulative += route.steps[index++].distance; } const next = Math.min(index + 1, route.steps.length - 1); const text = instruction(route.steps[next]); $('#next-instruction').textContent = freshGPS ? text : position?.source === 'manual' ? 'Aperçu du trajet · activez le GPS pour le guidage' : 'Guidage en pause · GPS absent ou imprécis'; const remaining = Math.max(0, route.distance - progress); $('#route-summary').textContent = `${fmtDistance(remaining)} · environ ${Math.max(1, Math.round(route.duration * (route.distance ? remaining / route.distance : 0) / 60))} min · ${destination.label} · sans trafic temps réel`; [...$('#route-steps').children].forEach((li, i) => li.classList.toggle('current', i === next)); if (freshGPS && $('#voice').checked && lastSpoken !== `${next}:${text}`) { lastSpoken = `${next}:${text}`; speak(text); } } function zoneStatus() { const expired = zones.filter(z => Date.parse(z.expiresAt) <= Date.now()).length; $('#zones-status').textContent = zones.length ? `${zones.length} zones importées · ${expired} expirée(s). Source : ${[...new Set(zones.map(z => z.source))].join(', ')}` : 'Aucune zone chargée · aucune couverture radar'; } const savedZones = readJSON('zones'); if (savedZones) { try { zones = validateZones(savedZones); } catch { /* Invalid persisted data is ignored. */ } } zoneStatus(); $('#zones-file').onchange = async e => { const file = e.target.files[0]; if (!file) return; try { if (file.size > 1500000) throw new Error('Fichier trop volumineux (1,5 Mo maximum).'); const data = JSON.parse(await file.text()), validated = validateZones(data); if (!storage.set('zones', JSON.stringify(data))) throw new Error('Stockage local indisponible.'); zones = validated; zoneStatus(); checkAlerts(); toast('Zones importées. Choisissez le pays puis activez les alertes.'); } catch (error) { toast(error.message); } e.target.value = ''; }; $('#zones-clear').onclick = () => { zones = []; storage.set('zones', 'null'); $('#alerts-enabled').checked = false; zoneStatus(); checkAlerts(); }; function sound() { if (!audioContext) return; const oscillator = audioContext.createOscillator(), gain = audioContext.createGain(); oscillator.connect(gain); gain.connect(audioContext.destination); oscillator.frequency.value = 660; gain.gain.setValueAtTime(.12, audioContext.currentTime); gain.gain.exponentialRampToValueAtTime(.001, audioContext.currentTime + .35); oscillator.start(); oscillator.stop(audioContext.currentTime + .36); } $('#alert-sound').onchange = async () => { if ($('#alert-sound').checked) try { audioContext ||= new (window.AudioContext || window.webkitAudioContext)(); await audioContext.resume(); sound(); } catch { toast('Son indisponible dans ce navigateur.'); } }; function showAlert(title, description) { $('#zone-alert-title').textContent = title; $('#zone-alert-description').textContent = description; $('#zone-alert').hidden = false; } let testUntil = 0, dismissedZone = ''; $('#alert-test').onclick = () => { testUntil = Date.now() + 6000; showAlert('TEST · Zone fictive', 'Exemple d’avertissement, sans lien avec un radar ou votre position.'); if ($('#alert-sound').checked) sound(); }; $('#zone-alert-close').onclick = () => { testUntil = 0; dismissedZone = lastZone; $('#zone-alert').hidden = true; }; function checkAlerts() { if (Date.now() < testUntil) return; const candidates = $('#alerts-enabled').checked && $('#alert-country').value ? nearbyZones(zones, position, $('#alert-country').value) : []; const candidate = candidates[0]; if (!candidate) { $('#zone-alert').hidden = true; dismissedZone = ''; return; } const newAlert = candidate.id !== lastZone || Date.now() - lastZoneTime > 120000; if (newAlert || (dismissedZone !== candidate.id && $('#zone-alert').hidden)) { lastZone = candidate.id; lastZoneTime = Date.now(); showAlert(candidate.remaining ? 'Zone de vigilance à proximité' : 'Zone de vigilance', `${candidate.name} · ${candidate.country} · source : ${candidate.source}. Proximité géographique, sens de circulation non vérifié.`); if (newAlert && $('#alert-sound').checked) sound(); } } $('#alerts-enabled').onchange = () => { lastZone = ''; if ($('#alerts-enabled').checked && (!zones.length || !$('#alert-country').value)) { $('#alerts-enabled').checked = false; toast('Importez des zones et choisissez le pays d’utilisation.'); } else if ($('#alerts-enabled').checked && position?.source !== 'gps') toast('Alertes en attente d’un GPS précis.'); checkAlerts(); }; $('#alert-country').onchange = () => { $('#alerts-enabled').checked = false; lastZone = ''; checkAlerts(); toast('Pays modifié : réactivez les alertes après vérification des règles locales.'); }; setInterval(() => { guidance(); checkAlerts(); if (position?.source === 'gps' && Date.now() - position.timestamp > 15000) $('#position-status').textContent = 'Position GPS ancienne · guidage et alertes en pause'; }, 3000); setInterval(() => weather(), 60000); window.addEventListener('pagehide', () => { stopGPS(); window.speechSynthesis?.cancel(); }); const saved = readJSON('position'); if (saved && Number.isFinite(saved.lat) && Number.isFinite(saved.lon) && Math.abs(saved.lat) <= 85 && Math.abs(saved.lon) <= 180) setPosition({ ...saved, label: String(saved.label || 'Lieu enregistré'), source: 'manual', timestamp: Date.now() }); if (location.hash === '#map') ensureMap(); }