Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement
This commit is contained in:
+261
@@ -0,0 +1,261 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user