Files

262 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + ' · <a href="https://www.openstreetmap.org/copyright">Licence</a>' }).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();
}