/* Petrol Map — application logic. * * index.html injects the per-build dataset as window.APP_DATA, loads Leaflet + * MarkerCluster, then this file. Everything runs inside one IIFE so nothing * leaks to the global scope. Sections below, in order: * data & constants · state · utilities · base map · fuel stations · * control panel · geocoding · routing · vehicles · EV chargers & stops · init */ (function () { "use strict"; // ===================== Data & constants ===================== const DATA = window.APP_DATA || {}; const FUELS = DATA.fuels || []; // ["Gazole", "SP95", ...] const STATS = DATA.stats || {}; // { Gazole: {count, p5, median, p95}, ... } // Each station: [lat, lon, name, brand, addr, updated, [prices], cp, city, highway] const STATIONS = DATA.stations || []; const CARS = DATA.cars || []; // [[label, fuel, L/100km], ...] real ADEME models const TRENDS = DATA.trends || {}; // { fuel: {median, delta} } vs the previous build const BRAND = 3, UPDATED = 5; // station record indices const PRICES = 6, CP = 7, CITY = 8, HW = 9; // (HW: 1 = autoroute) const OPEN24 = 10, SERVICES = 11; // 24/7 flag, services list const CORRIDOR_KM = 6; // show stations within this distance of a route // Routing: try a reliable host first, fall back to the public OSRM demo server. const ROUTERS = [ "https://routing.openstreetmap.de/routed-car/route/v1/driving/", // FOSSGIS (stable) "https://router.project-osrm.org/route/v1/driving/", // project-osrm demo ]; // FOSSGIS Valhalla — used for the toll-free ("sans péage") route (use_tolls:0). const VALHALLA_URL = "https://valhalla1.openstreetmap.de/route"; // Motorway tolls (light vehicle, classe 1). Approximate €/km on a tolled // autoroute; a few roads differ enough to special-case. Estimate only. const TOLL_RATE = 0.092; const TOLL_SPECIAL = { 14: 0.49 }; // A14: short, premium toll const FREE_AUTOROUTES = new Set([20, 75, 84, 88]); // largely toll-free autoroutes // EV charging price options (€/kWh) for the electric cost estimate. const EV_PRICES = [ { v: 0.25, label: "à domicile (~0,25 €/kWh)" }, { v: 0.40, label: "mixte (~0,40 €/kWh)" }, { v: 0.59, label: "borne rapide (~0,59 €/kWh)" }, ]; // Green -> yellow -> red gradient stops (cheap -> expensive). const STOPS = [ [0.00, [26, 152, 80]], [0.25, [145, 207, 96]], [0.50, [254, 224, 139]], [0.75, [252, 141, 89]], [1.00, [215, 48, 39]], ]; // Popular electric cars: WLTP range (km) and consumption (kWh/100 km). const EVS = [ { name: "Renault 5 E-Tech 52 kWh", range: 405, kwh: 14.0 }, { name: "Renault Mégane E-Tech 60 kWh", range: 450, kwh: 16.1 }, { name: "Renault Zoe R135 52 kWh", range: 395, kwh: 17.2 }, { name: "Peugeot e-208 51 kWh", range: 410, kwh: 13.0 }, { name: "Peugeot e-2008 54 kWh", range: 406, kwh: 14.5 }, { name: "Citroën ë-C4 50 kWh", range: 357, kwh: 15.0 }, { name: "Tesla Model 3 Propulsion", range: 513, kwh: 13.2 }, { name: "Tesla Model 3 Grande Autonomie", range: 629, kwh: 14.0 }, { name: "Tesla Model Y Propulsion", range: 455, kwh: 15.0 }, { name: "Tesla Model Y Grande Autonomie", range: 565, kwh: 15.6 }, { name: "Dacia Spring 27 kWh", range: 225, kwh: 13.9 }, { name: "Fiat 500e 42 kWh", range: 320, kwh: 14.3 }, { name: "Volkswagen ID.3 58 kWh", range: 420, kwh: 15.0 }, { name: "Volkswagen ID.4 77 kWh", range: 540, kwh: 16.0 }, { name: "Hyundai Kona 65 kWh", range: 514, kwh: 14.7 }, { name: "Kia EV6 77 kWh", range: 528, kwh: 16.5 }, { name: "BMW i4 eDrive40", range: 590, kwh: 16.0 }, { name: "MG4 64 kWh", range: 450, kwh: 16.0 }, { name: "Nissan Leaf 39 kWh", range: 270, kwh: 17.0 }, { name: "Mercedes EQA 250", range: 426, kwh: 15.7 }, ]; // National EV charging stations (IRVE) via the ODRE opendatasoft API. const IRVE_URL = "https://odre.opendatasoft.com/api/explore/v2.1/catalog/datasets/bornes-irve/records"; const IRVE_SELECT = "nom_station,nom_operateur,puissance_nominale,consolidated_longitude,consolidated_latitude"; // French commune search via the official Base Adresse Nationale (BAN). const BAN_URL = "https://api-adresse.data.gouv.fr/search/"; // Narrow-screen layout (slim top bar + collapsible chip panels). Keep in sync // with the @media breakpoint in styles.css. const MOBILE_MQ = "(max-width: 960px)"; // ===================== State ===================== let currentFuel = 0; let maxPrice = null; // price-slider ceiling (€/L); null = show all let electricView = false; // "Électrique" selected in the fuel panel -> show chargers let placeQuery = ""; let routeCoords = null; // L.LatLng[] of the active route, or null let routeStations = null; // stations near the active route (any fuel) let routeDistanceKm = null; // length of the active route, km let routeTollKm = 0; // motorway distance on the route (for toll estimate), km let routeTollEuro = 0; // estimated toll for the active route, € let evPricePerKwh = 0.40; // electricity price used in the EV cost estimate let brandFilter = ""; // selected station brand/enseigne, or "" for all let favOnly = false; // show only favourite stations let open24Only = false; // show only 24/7 stations let maxAgeDays = null; // price-freshness ceiling in days, or null for all let listShown = false; // cheapest-stations list panel visible? let listSort = "price"; // list ordering: "price" | "fresh" | "name" const favorites = new Set(cacheGet("favorites") || []); // keys "lat,lon", persisted let vehicleMode = "thermique"; // "thermique" | "electrique" let thermCar = null; // { label, fuel, cons } chosen combustion model let stopMarkers = []; // recharge-stop markers on the map let routeLines = [], depPin = null, arrPin = null; let routeOptions = [], activeRouteIdx = 0; // alternative routes + the selected one let depCoord = null, arrCoord = null; // coords from a picked city suggestion let chargersOn = false, chargerTimer = null, loadSeq = 0; let locMarker = null, locCircle = null, locating = false, firstFix = false; // ===================== Utilities ===================== function esc(t) { return String(t).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } // French number formatting (comma decimal): eur(2.349, 3) -> "2,349". function eur(x, dec) { return x.toLocaleString("fr-FR", { minimumFractionDigits: dec, maximumFractionDigits: dec }); } function haversine(la1, lo1, la2, lo2) { const R = 6371, toR = Math.PI / 180; const dLa = (la2 - la1) * toR, dLo = (lo2 - lo1) * toR; const a = Math.sin(dLa / 2) ** 2 + Math.cos(la1 * toR) * Math.cos(la2 * toR) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } // Tiny localStorage cache (used for geocoding). Safe if storage is unavailable. function cacheGet(key) { try { return JSON.parse(localStorage.getItem(key)); } catch (e) { return null; } } function cacheSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* ignore */ } } // Age of a "YYYY-MM-DD" report date in whole days (Infinity if missing/invalid). function ageDays(dateStr) { if (!dateStr) return Infinity; const t = Date.parse(dateStr + "T00:00:00"); return isNaN(t) ? Infinity : Math.floor((Date.now() - t) / 86400000); } function favKey(s) { return s[0] + "," + s[1]; } function colorFor(t) { t = Math.max(0, Math.min(1, t)); for (let i = 1; i < STOPS.length; i++) { if (t <= STOPS[i][0]) { const [t0, c0] = STOPS[i - 1], [t1, c1] = STOPS[i]; const f = (t - t0) / (t1 - t0); const c = c0.map((v, k) => Math.round(v + (c1[k] - v) * f)); return `rgb(${c[0]},${c[1]},${c[2]})`; } } return "rgb(215,48,39)"; } // ===================== Base map ===================== const map = L.map("map", { preferCanvas: true, zoomControl: false }).setView([46.6, 2.45], 6); L.control.zoom({ zoomInTitle: "Zoomer", zoomOutTitle: "Dézoomer" }).addTo(map); // "Locate me" button (real-time position), sits just under the zoom control. const locateControl = L.control({ position: "topleft" }); locateControl.onAdd = function () { const div = L.DomUtil.create("div", "leaflet-bar locate-ctrl"); const a = L.DomUtil.create("a", "", div); a.href = "#"; a.title = "Ma position"; a.setAttribute("role", "button"); a.setAttribute("aria-label", "Afficher ma position"); a.innerHTML = "📍"; L.DomEvent.on(a, "click", (e) => { L.DomEvent.stop(e); toggleLocate(); }); return div; }; locateControl.addTo(map); // Official IGN "Plan IGN v2" basemap — fully in French (Géoplateforme, no key). L.tileLayer( "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" + "&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM" + "&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png", { attribution: '© IGN-F/Géoportail · ' + 'données : data.economie.gouv.fr via opendatasoft', minZoom: 5, maxZoom: 19, } ).addTo(map); // Author credit lives in the map attribution: always visible, never overlaps. map.attributionControl.setPrefix( 'Créé par Samuel Adone · ' + 'Leaflet' ); const cluster = L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 45, disableClusteringAtZoom: 11, }); map.addLayer(cluster); // ===================== Fuel stations: filter & render ===================== function popupHtml(s, fuelIdx) { const [lat, lon, name, brand, addr, updated, prices] = s; let rows = ""; FUELS.forEach((f, i) => { if (prices[i] != null) { const hi = i === fuelIdx ? ' style="background:#fff3cd"' : ""; rows += `${f}${eur(prices[i], 3)} €`; } }); const dir = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`; const title = brand ? `${name} · ${brand}` : name; const hw = s[HW] ? ' AUTOROUTE' : ""; const open = s[OPEN24] ? ' 24/24' : ""; const dateFr = updated ? updated.split("-").reverse().join("/") : "—"; const svcList = s[SERVICES] || []; const svc = svcList.length ? `
${svcList.slice(0, 8).map(esc).join(" · ")}
` : ""; const key = favKey(s), fav = favorites.has(key); const favBtn = ``; return ``; } // Keep only stations within CORRIDOR_KM of the route polyline. function nearRoute(list, coords) { let minLat = 90, maxLat = -90, minLng = 180, maxLng = -180; for (const c of coords) { minLat = Math.min(minLat, c.lat); maxLat = Math.max(maxLat, c.lat); minLng = Math.min(minLng, c.lng); maxLng = Math.max(maxLng, c.lng); } const dLat = CORRIDOR_KM / 111, dLng = CORRIDOR_KM / 80; // ~deg padding near 45°N const step = Math.max(1, Math.floor(coords.length / 600)); // downsample for speed const pts = coords.filter((_, i) => i % step === 0); return list.filter(s => { const la = s[0], lo = s[1]; if (la < minLat - dLat || la > maxLat + dLat || lo < minLng - dLng || lo > maxLng + dLng) return false; for (const p of pts) if (haversine(la, lo, p.lat, p.lng) <= CORRIDOR_KM) return true; return false; }); } // Apply the active fuel + price ceiling + postal/city + route filters. function currentSelection() { let list = (routeCoords ? routeStations : STATIONS).filter(s => s[PRICES][currentFuel] != null); if (maxPrice != null) { list = list.filter(s => s[PRICES][currentFuel] <= maxPrice); } if (brandFilter) { list = list.filter(s => (s[BRAND] || "").trim() === brandFilter); } if (favOnly) { list = list.filter(s => favorites.has(favKey(s))); } if (open24Only) { list = list.filter(s => s[OPEN24]); } if (maxAgeDays != null) { list = list.filter(s => ageDays(s[UPDATED]) <= maxAgeDays); } if (placeQuery) { const q = placeQuery.trim().toLowerCase(); list = list.filter(s => String(s[CP]).toLowerCase().startsWith(q) || String(s[CITY]).toLowerCase().includes(q)); } return list; } // Cheapest price for a fuel among the stations near the active route. // highwayOnly restricts to autoroute stations (what you'd actually pay on the motorway). function cheapestAlongRoute(fuelIdx, highwayOnly) { if (!routeStations) return null; let best = null; for (const s of routeStations) { if (highwayOnly && !s[HW]) continue; const p = s[PRICES][fuelIdx]; if (p != null && (best === null || p < best)) best = p; } return best; } function render(fit) { const fuel = FUELS[currentFuel]; const st = STATS[fuel]; const span = Math.max(st.p95 - st.p5, 0.001); const list = currentSelection(); const markers = []; let cheapest = null; for (const s of list) { const price = s[PRICES][currentFuel]; const color = colorFor((price - st.p5) / span); const fav = favorites.has(favKey(s)); // favourites get a gold ring const m = L.circleMarker([s[0], s[1]], { radius: fav ? 7 : 6, color: fav ? "#f1c40f" : "rgba(40,40,40,.35)", weight: fav ? 3 : 1, fillColor: color, fillOpacity: 0.9, }); m.bindTooltip(`${esc(s[2])} — ${eur(price, 3)} €`); m.bindPopup(() => popupHtml(s, currentFuel), { maxWidth: 300 }); markers.push(m); if (!cheapest || price < cheapest[PRICES][currentFuel]) cheapest = s; } cluster.clearLayers(); cluster.addLayers(markers); updateLegend(fuel); updateStatus(list.length, cheapest); updateCost(); if (listShown) renderCheapestList(list); if (fit && list.length) { map.fitBounds(L.latLngBounds(list.map(s => [s[0], s[1]])).pad(0.15), { maxZoom: 14 }); } } // ===================== Control panel (search + fuel + legend) ===================== // Collapse helpers shared by the fuel panel and the itinerary box. function setCollapsed(el, collapsed) { if (!el) return; el.classList.toggle("collapsed", collapsed); const caret = el.querySelector(".p-caret, #route-caret"); if (caret) caret.textContent = collapsed ? "▸" : "▾"; const head = el.querySelector(".panel-head, .route-head"); if (head) head.setAttribute("aria-expanded", String(!collapsed)); } // Toggle one panel; on mobile, expanding it collapses the other (no room for both). function toggleCollapse(el, otherSelector) { const willExpand = el.classList.contains("collapsed"); setCollapsed(el, !willExpand); if (willExpand && window.matchMedia(MOBILE_MQ).matches) { setCollapsed(document.querySelector(otherSelector), true); } } const panel = L.control({ position: "topright" }); panel.onAdd = function () { const div = L.DomUtil.create("div", "panel"); L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); let body = '

Code postal / ville

' + '
' + '' + '
' + '
' + '' + '

Carburant

'; FUELS.forEach((f, i) => { if (!STATS[f]) return; body += ``; }); body += ''; body += '

Prix maximum

' + '' + '
' + '' + '
'; body += '

Enseigne

' + '
'; body += '

Filtres

' + '' + '' + '
'; body += '
' + '
' + '
' + '' + '
' + '' + '' + ''; div.innerHTML = '
' + '⛽ Carburant
' + `
${body}
`; const phead = div.querySelector(".panel-head"); phead.addEventListener("click", () => toggleCollapse(div, ".route-box")); phead.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleCollapse(div, ".route-box"); } }); div.querySelectorAll('input[name="fuel"]').forEach(inp => inp.addEventListener("change", e => { if (e.target.value === "ev") selectElectric(); else selectFuel(+e.target.value, false); writeState(); })); const slider = div.querySelector("#price-slider"); slider.addEventListener("input", () => { // At the very top of the range, drop the filter so the priciest stations show. maxPrice = (+slider.value >= +slider.max) ? null : +slider.value; updateSliderOut(); render(false); writeState(); }); div.querySelector("#slider-reset").addEventListener("click", () => { maxPrice = null; slider.value = slider.max; updateSliderOut(); render(false); writeState(); }); div.querySelector("#brand").addEventListener("change", (e) => { brandFilter = e.target.value; render(false); writeState(); }); div.querySelector("#near-me").addEventListener("click", nearMe); div.querySelector("#f-fav").addEventListener("change", (e) => { favOnly = e.target.checked; render(false); }); div.querySelector("#f-24").addEventListener("change", (e) => { open24Only = e.target.checked; render(false); }); div.querySelector("#f-fresh").addEventListener("change", (e) => { maxAgeDays = e.target.value ? +e.target.value : null; render(false); }); div.querySelector("#list-toggle").addEventListener("click", toggleList); div.querySelector("#list-sort").addEventListener("change", (e) => { listSort = e.target.value; renderCheapestList(); }); const openCheapest = (it) => { const lat = +it.dataset.lat, lon = +it.dataset.lon; map.setView([lat, lon], Math.max(map.getZoom(), 14)); const s = STATIONS.find(x => x[0] === lat && x[1] === lon); if (s) L.popup({ maxWidth: 300 }).setLatLng([lat, lon]) .setContent(popupHtml(s, currentFuel)).openOn(map); }; const clBox = div.querySelector("#cheapest-list"); clBox.addEventListener("click", (e) => { const it = e.target.closest(".cl-item"); if (it) openCheapest(it); }); clBox.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; const it = e.target.closest(".cl-item"); if (it) { e.preventDefault(); openCheapest(it); } }); const place = div.querySelector("#place"); let debounce; place.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(() => { placeQuery = place.value; render(!!place.value); }, 250); }); div.querySelector("#place-clear").addEventListener("click", () => { place.value = ""; placeQuery = ""; render(false); if (!routeCoords) map.setView([46.6, 2.45], 6); }); return div; }; panel.addTo(map); function updateLegend(fuel) { const st = STATS[fuel]; document.getElementById("lg-lo").textContent = eur(st.p5, 2) + " €"; document.getElementById("lg-mid").textContent = eur(st.median, 2) + " €"; document.getElementById("lg-hi").textContent = eur(st.p95, 2) + " €"; const el = document.getElementById("lg-trend"); if (!el) return; const tr = TRENDS[fuel]; if (tr && Math.abs(tr.delta) >= 0.001) { const up = tr.delta > 0; // a price rise is "bad" (red), a drop is "good" (green) el.innerHTML = `médiane ${eur(tr.median, 3)} € · ` + `${up ? "▲" : "▼"} ${eur(Math.abs(tr.delta), 3)} € vs dernier relevé`; } else if (tr) { el.innerHTML = `médiane ${eur(tr.median, 3)} € · stable`; } else { el.innerHTML = ""; } } // Rescale the price slider to the current fuel's actual min/max and reset to "all". function configureSlider() { const slider = document.getElementById("price-slider"); if (!slider) return; let lo = Infinity, hi = -Infinity; for (const s of STATIONS) { const p = s[PRICES][currentFuel]; if (p == null) continue; if (p < lo) lo = p; if (p > hi) hi = p; } if (!isFinite(lo)) { lo = 1; hi = 2; } slider.min = Math.floor(lo * 100) / 100; slider.max = Math.ceil(hi * 100) / 100; slider.value = slider.max; // start with no ceiling (all stations visible) maxPrice = null; updateSliderOut(); } // Refresh the "≤ X €/L" label (or "tous les prix" when no ceiling is set). function updateSliderOut() { const out = document.getElementById("slider-out"); if (out) out.textContent = maxPrice == null ? "tous les prix" : `≤ ${eur(maxPrice, 2)} €/L`; } // Set the active fuel and sync the radio buttons (leaving electric view if needed). function selectFuel(idx, fit) { if (idx < 0) return; if (electricView) { electricView = false; toggleChargers(false); map.addLayer(cluster); // bring fuel stations back togglePriceLegend(true); } currentFuel = idx; const radio = document.querySelector(`input[name="fuel"][value="${idx}"]`); if (radio) radio.checked = true; configureSlider(); // each fuel has its own price range; reset the ceiling render(fit); } // "Électrique" selected: hide fuel stations, show the charging-station layer. function selectElectric() { electricView = true; const radio = document.querySelector('input[name="fuel"][value="ev"]'); if (radio) radio.checked = true; map.removeLayer(cluster); togglePriceLegend(false); toggleChargers(true); // loads a clustered, nationwide sample at the current zoom } function togglePriceLegend(showPrice) { document.querySelectorAll(".legend-bar, .legend-scale").forEach(el => el.style.display = showPrice ? "" : "none"); const pf = document.getElementById("price-filter"); if (pf) pf.style.display = showPrice ? "" : "none"; const ev = document.getElementById("ev-legend"); if (ev) ev.style.display = showPrice ? "none" : "block"; } function updateStatus(count, cheapest) { const hint = document.getElementById("place-hint"); if (hint) hint.textContent = (placeQuery || routeCoords) ? `${count} stations affichées` : ""; const rs = document.getElementById("route-status"); if (!rs) return; if (!routeCoords) { rs.innerHTML = ""; return; } const clearBtn = ''; if (count) { const p = cheapest[PRICES][currentFuel]; rs.innerHTML = `🛣️ Sur le trajet · moins chère ${eur(p, 3)} €` + `
${esc(cheapest[2])}
${clearBtn}`; } else { rs.innerHTML = `Aucune station ${esc(FUELS[currentFuel])} le long du trajet.
${clearBtn}`; } const rc = document.getElementById("route-clear"); if (rc) rc.addEventListener("click", clearRoute); } // ===================== Brand filter · cheapest list · near me ===================== // Distinct station brands (enseignes) with counts, most common first. function brandOptions() { const counts = new Map(); for (const s of STATIONS) { const b = (s[BRAND] || "").trim(); if (b) counts.set(b, (counts.get(b) || 0) + 1); } const arr = [...counts.entries()].sort((a, b) => b[1] - a[1]); return '' + arr.map(([b, n]) => ``).join(""); } // Fill the station list (top 50 of the current selection, sorted by listSort). function renderCheapestList(list) { const box = document.getElementById("cheapest-list"); if (!box) return; let top = (list || currentSelection()).slice(); if (listSort === "name") top.sort((a, b) => String(a[2]).localeCompare(b[2])); else if (listSort === "fresh") top.sort((a, b) => String(b[UPDATED] || "").localeCompare(String(a[UPDATED] || ""))); else top.sort((a, b) => a[PRICES][currentFuel] - b[PRICES][currentFuel]); top = top.slice(0, 50); box.innerHTML = top.length ? top.map(s => { const star = favorites.has(favKey(s)) ? "★ " : ""; return `
` + `${eur(s[PRICES][currentFuel], 3)} €` + `${star}${esc(s[2])}${esc(s[CITY] || "")}
`; }).join("") : '
Aucune station.
'; } function toggleList() { listShown = !listShown; const box = document.getElementById("cheapest-list"); const btn = document.getElementById("list-toggle"); const sort = document.getElementById("list-sort"); if (box) box.style.display = listShown ? "block" : "none"; if (sort) sort.style.display = listShown ? "block" : "none"; if (btn) { btn.textContent = listShown ? "Masquer la liste ▴" : "Voir les stations ▾"; btn.setAttribute("aria-expanded", String(listShown)); } if (listShown) renderCheapestList(); } // One-shot geolocation: centre on the user and open the cheapest nearby station. function nearMe() { if (!navigator.geolocation) { alert("Géolocalisation non disponible."); return; } const btn = document.getElementById("near-me"); const reset = () => { if (btn) btn.textContent = "📍 Stations près de moi"; }; if (btn) btn.textContent = "Localisation…"; navigator.geolocation.getCurrentPosition((pos) => { reset(); const lat = pos.coords.latitude, lon = pos.coords.longitude; map.setView([lat, lon], 12); let best = null, bd = Infinity; for (const s of STATIONS) { if (s[PRICES][currentFuel] == null) continue; const d = haversine(lat, lon, s[0], s[1]); if (d <= 25 && d < bd) { bd = d; best = s; } } if (best) L.popup({ maxWidth: 300 }).setLatLng([best[0], best[1]]) .setContent(popupHtml(best, currentFuel)).openOn(map); }, () => { reset(); alert("Localisation indisponible (autorisez l'accès, lien https requis)."); }, { enableHighAccuracy: true, timeout: 8000 }); } // ===================== Geocoding + city autocomplete (BAN) ===================== const banMemo = new Map(); // session cache for autocomplete queries async function banSearch(q, limit) { const key = limit + ":" + q.trim().toLowerCase(); if (banMemo.has(key)) return banMemo.get(key); const url = `${BAN_URL}?type=municipality&limit=${limit}&q=` + encodeURIComponent(q); const r = await fetch(url); if (!r.ok) return []; const j = await r.json(); const feats = j.features || []; banMemo.set(key, feats); return feats; } // Geocode a typed place name to coordinates (first BAN match), cached on disk. async function geocodeCity(q) { const key = "geo:" + q.trim().toLowerCase(); const hit = cacheGet(key); if (hit) return hit; const f = await banSearch(q, 1); if (!f.length) return null; const c = f[0].geometry.coordinates; // [lon, lat] const coord = { lat: c[1], lon: c[0] }; cacheSet(key, coord); return coord; } // Attach a city-suggestion dropdown to an input. onPick(coord|null) stores the // chosen coordinates, or null when the user edits the text again. function attachAutocomplete(input, onPick) { const list = document.createElement("div"); list.className = "ac-list"; list.style.display = "none"; input.parentNode.appendChild(list); let items = [], active = -1, debounce; function hide() { list.style.display = "none"; active = -1; } function draw() { if (!items.length) { hide(); return; } list.innerHTML = items.map((f, i) => `
` + `${esc(f.properties.label)}${esc(f.properties.context || "")}
` ).join(""); list.style.display = "block"; } function choose(i) { const f = items[i]; if (!f) return; input.value = f.properties.label; const c = f.geometry.coordinates; // [lon, lat] onPick({ lat: c[1], lon: c[0] }); hide(); } input.addEventListener("input", () => { onPick(null); // typing invalidates any previously picked coordinate const q = input.value.trim(); clearTimeout(debounce); if (q.length < 2) { hide(); return; } debounce = setTimeout(async () => { try { items = await banSearch(q, 6); active = -1; draw(); } catch (e) { hide(); } }, 180); }); input.addEventListener("keydown", (e) => { const open = list.style.display !== "none"; if (e.key === "Enter") { if (open && active >= 0) choose(active); else computeRoute(); e.preventDefault(); } else if (open && e.key === "ArrowDown") { active = Math.min(active + 1, items.length - 1); draw(); e.preventDefault(); } else if (open && e.key === "ArrowUp") { active = Math.max(active - 1, 0); draw(); e.preventDefault(); } else if (e.key === "Escape") { hide(); } }); // mousedown (not click) so the pick fires before the input blurs list.addEventListener("mousedown", (e) => { const t = e.target.closest("[data-i]"); if (t) { e.preventDefault(); choose(+t.dataset.i); } }); input.addEventListener("blur", () => setTimeout(hide, 150)); } // ===================== Routing (OSRM) ===================== function routeMsg(text) { const el = document.getElementById("route-msg"); if (el) el.textContent = text || ""; } // OSRM routes (main + any alternatives) between two points. Tries each host so // one provider being down doesn't break itineraries. steps=true so we can read // each segment's road ref for the toll estimate; alternatives=3 for a 2nd way. async function fetchOsrmRoutes(a, b) { const tail = `${a.lon},${a.lat};${b.lon},${b.lat}` + `?overview=full&geometries=geojson&steps=true&alternatives=3`; for (const base of ROUTERS) { try { const r = await fetch(base + tail); if (!r.ok) continue; const j = await r.json(); if (j.code === "Ok" && j.routes && j.routes.length) return j.routes; } catch (e) { /* host unreachable — try the next one */ } } return null; } // Toll-free route via Valhalla (use_tolls:0) — the "sans péage / départementales" // option. Returns the trip object, or null. async function fetchTollFree(a, b) { const json = JSON.stringify({ locations: [{ lat: a.lat, lon: a.lon }, { lat: b.lat, lon: b.lon }], costing: "auto", costing_options: { auto: { use_tolls: 0 } }, }); try { const r = await fetch(`${VALHALLA_URL}?json=${encodeURIComponent(json)}`); if (!r.ok) return null; const j = await r.json(); return (j.trip && j.trip.status === 0) ? j.trip : null; } catch (e) { return null; } } // Decode a Google/Valhalla encoded polyline (Valhalla uses precision 6). function decodePolyline(str, precision) { let index = 0, lat = 0, lng = 0; const coords = [], factor = Math.pow(10, precision || 6); while (index < str.length) { let shift = 0, result = 0, byte; do { byte = str.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); lat += (result & 1) ? ~(result >> 1) : (result >> 1); shift = 0; result = 0; do { byte = str.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); lng += (result & 1) ? ~(result >> 1) : (result >> 1); coords.push(L.latLng(lat / factor, lng / factor)); } return coords; } // Build a route option from an OSRM route (toll cost from road refs). function optionFromOsrm(label, route, color, dash) { const t = tollFromRoute(route); return { label, color, dash, coords: route.geometry.coordinates.map((c) => L.latLng(c[1], c[0])), distanceKm: route.distance / 1000, durationMin: Math.round(route.duration / 60), tollKm: t.km, tollEuro: t.euro, }; } // Build a route option from a Valhalla trip (toll-free, so euro = 0). function optionFromValhalla(label, trip, color, dash) { const coords = []; for (const leg of trip.legs || []) decodePolyline(leg.shape, 6).forEach((c) => coords.push(c)); return { label, color, dash, coords, distanceKm: trip.summary.length, durationMin: Math.round(trip.summary.time / 60), tollKm: 0, tollEuro: 0, }; } // Autoroute number from a step ref ("A 6" -> 6, "A 7;E 15" -> 7), or null. // A trailing letter ("A 6a", "A 6b") marks an urban link that is normally free, // so those are excluded from the toll. function autorouteNumber(ref) { const m = /(?:^|[^A-Za-z])A\s?(\d+)(?![\da-z])/i.exec(ref || ""); return m ? parseInt(m[1], 10) : null; } // €/km toll for a given autoroute number (0 for the mostly-free ones). function tollRateFor(n) { if (FREE_AUTOROUTES.has(n)) return 0; return TOLL_SPECIAL[n] || TOLL_RATE; } // Estimated toll for the route: { km on autoroutes, euro }. Sums each motorway // step's length times that road's rate, so free sections don't inflate the cost. function tollFromRoute(route) { let km = 0, euro = 0; for (const leg of route.legs || []) { for (const step of leg.steps || []) { const n = autorouteNumber(step.ref); if (n == null) continue; const d = (step.distance || 0) / 1000; km += d; euro += d * tollRateFor(n); } } return { km, euro }; } // Draw every route option (active one solid+bold, others dashed) plus end pins. function drawRoutes() { clearRouteLayers(); routeOptions.forEach((o, i) => { const active = i === activeRouteIdx; const line = L.polyline(o.coords, { color: o.color, weight: active ? 6 : 4, opacity: active ? 0.9 : 0.55, dashArray: active ? null : (o.dash || "6 8"), }).addTo(map); line.on("click", () => setActiveRoute(i)); routeLines.push(line); }); const act = routeOptions[activeRouteIdx]; if (act && act.coords.length) { depPin = L.circleMarker(act.coords[0], { radius: 8, color: "#fff", weight: 2, fillColor: "#1a73e8", fillOpacity: 1 }) .addTo(map).bindTooltip("Départ"); arrPin = L.circleMarker(act.coords[act.coords.length - 1], { radius: 8, color: "#fff", weight: 2, fillColor: "#d73027", fillOpacity: 1 }) .addTo(map).bindTooltip("Arrivée"); } } function clearRouteLayers() { routeLines.forEach((l) => map.removeLayer(l)); routeLines = []; [depPin, arrPin].forEach((l) => { if (l) map.removeLayer(l); }); depPin = arrPin = null; } // Make option i the active route: it drives the along-route stations + cost. function setActiveRoute(i) { if (i < 0 || i >= routeOptions.length) return; activeRouteIdx = i; const o = routeOptions[i]; routeCoords = o.coords; routeStations = nearRoute(STATIONS, o.coords); routeDistanceKm = o.distanceKm; routeTollKm = o.tollKm; routeTollEuro = o.tollEuro; drawRoutes(); render(false); renderRouteOptions(); planEvStops(); writeState(); } // The clickable comparison of route options in the itinerary panel. function renderRouteOptions() { const el = document.getElementById("route-options"); if (!el) return; el.innerHTML = routeOptions.map((o, i) => { const h = Math.floor(o.durationMin / 60), m = o.durationMin % 60; const toll = o.tollEuro > 0.5 ? `🅿️ ≈ ${eur(o.tollEuro, 0)} €` : "sans péage"; return `
` + `` + `${esc(o.label)}` + `${o.distanceKm.toFixed(0)} km · ${h ? h + " h " : ""}${m} min · ${toll}` + `
`; }).join(""); } async function computeRoute() { const depV = (document.getElementById("dep").value || "").trim(); const arrV = (document.getElementById("arr").value || "").trim(); if (!depV || !arrV) { routeMsg("Renseignez les deux villes."); return; } routeMsg("Recherche des villes…"); try { // Use coordinates from a picked suggestion when available, else geocode text. const [a, b] = await Promise.all([ depCoord || geocodeCity(depV), arrCoord || geocodeCity(arrV), ]); if (!a) { routeMsg("« " + depV + " » introuvable."); return; } if (!b) { routeMsg("« " + arrV + " » introuvable."); return; } routeMsg("Calcul des itinéraires…"); // Autoroute route(s) from OSRM + a toll-free route from Valhalla, in parallel. const [osrm, tollFree] = await Promise.all([fetchOsrmRoutes(a, b), fetchTollFree(a, b)]); const opts = []; if (osrm && osrm.length) { opts.push(optionFromOsrm("Le plus rapide", osrm[0], "#1a73e8", null)); for (let k = 1; k < osrm.length; k++) { // first clearly-different alt = other autoroute if (Math.abs(osrm[k].distance - osrm[0].distance) > 1000) { opts.push(optionFromOsrm("Autre autoroute", osrm[k], "#e8710a", "10 8")); break; } } } if (tollFree) { const o = optionFromValhalla("Sans péage (départementales)", tollFree, "#1a9850", "2 7"); const main = opts[0]; // Add only if it really differs (else it's the same toll-free road). if (!main || main.tollEuro > 0.5 || Math.abs(o.distanceKm - main.distanceKm) > 1) { opts.push(o); } } if (!opts.length) { routeMsg("Itinéraire indisponible (service de routage occupé). Réessayez."); return; } routeOptions = opts; routeMsg(opts.length > 1 ? "Choisissez un itinéraire :" : ""); setActiveRoute(0); // draws, renders, builds the comparison list map.fitBounds(L.latLngBounds(opts[0].coords).pad(0.1)); } catch (err) { console.error(err); routeMsg("Erreur réseau, réessayez."); } } function clearRoute() { clearRouteLayers(); clearStops(); routeOptions = []; activeRouteIdx = 0; const ro = document.getElementById("route-options"); if (ro) ro.innerHTML = ""; routeCoords = null; routeStations = null; routeDistanceKm = null; routeTollKm = 0; routeTollEuro = 0; depCoord = arrCoord = null; const d = document.getElementById("dep"), a = document.getElementById("arr"); if (d) d.value = ""; if (a) a.value = ""; routeMsg(""); render(false); writeState(); map.setView([46.6, 2.45], 6); } // ===================== Vehicles (combustion + electric) ===================== const routeControl = L.control({ position: "topleft" }); routeControl.onAdd = function () { const div = L.DomUtil.create("div", "route-box"); L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); const evOptions = '' + EVS.map((e, i) => ``).join("") + ''; const evPriceOptions = EV_PRICES.map((p) => ``).join(""); div.innerHTML = '
' + '🛣️ Itinéraire
' + '
' + '
' + '
' + '
' + '' + '
' + '
' + '
' + '` + '
' + '' + '
' + '
' + '
' + '
'; const rhead = div.querySelector(".route-head"); rhead.addEventListener("click", () => toggleCollapse(div, ".panel")); rhead.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleCollapse(div, ".panel"); } }); div.querySelector("#route-go").addEventListener("click", computeRoute); div.querySelector("#route-reset").addEventListener("click", clearRoute); const ro = div.querySelector("#route-options"); ro.addEventListener("click", (e) => { const it = e.target.closest(".ro-item"); if (it) setActiveRoute(+it.dataset.i); }); ro.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; const it = e.target.closest(".ro-item"); if (it) { e.preventDefault(); setActiveRoute(+it.dataset.i); } }); div.querySelectorAll('input[name="vmode"]').forEach(r => r.addEventListener("change", onVehicleModeChange)); div.querySelector("#ev").addEventListener("change", onEvChange); div.querySelector("#ev-range").addEventListener("input", onEvChange); div.querySelector("#ev-price").addEventListener("change", (e) => { evPricePerKwh = parseFloat(e.target.value) || 0.40; updateCost(); writeState(); }); attachCarAutocomplete(div.querySelector("#car")); attachAutocomplete(div.querySelector("#dep"), (c) => { depCoord = c; }); attachAutocomplete(div.querySelector("#arr"), (c) => { arrCoord = c; }); return div; }; routeControl.addTo(map); function onVehicleModeChange(e) { vehicleMode = e.target.value; document.getElementById("therm-block").style.display = vehicleMode === "thermique" ? "" : "none"; document.getElementById("ev-block").style.display = vehicleMode === "electrique" ? "" : "none"; document.querySelectorAll(".seg label").forEach(l => l.classList.toggle("seg-on", l.querySelector("input").checked)); if (vehicleMode === "thermique" && thermCar) selectFuel(FUELS.indexOf(thermCar.fuel), false); updateCost(); planEvStops(); writeState(); } // Current EV selection { name, range, kwh }, or null. function currentEv() { const sel = document.getElementById("ev"); if (!sel || sel.value === "") return null; if (sel.value === "custom") { const r = parseFloat(document.getElementById("ev-range").value); return r > 0 ? { name: "Véhicule électrique", range: r, kwh: 16 } : null; } return EVS[+sel.value] || null; } function onEvChange() { const sel = document.getElementById("ev"); document.getElementById("ev-range").style.display = sel.value === "custom" ? "block" : "none"; updateCost(); planEvStops(); writeState(); } // Autocomplete over the embedded ADEME car models ([label, fuel, L/100km]). function attachCarAutocomplete(input) { const list = document.createElement("div"); list.className = "ac-list"; list.style.display = "none"; input.parentNode.appendChild(list); let items = [], active = -1; function hide() { list.style.display = "none"; active = -1; } function draw() { if (!items.length) { hide(); return; } list.innerHTML = items.map((c, i) => `
${esc(c[0])}` + `${esc(c[1])} · ${eur(c[2], 1)} L/100 km
`).join(""); list.style.display = "block"; } function choose(i) { const c = items[i]; if (!c) return; input.value = c[0]; thermCar = { label: c[0], fuel: c[1], cons: c[2] }; selectFuel(FUELS.indexOf(c[1]), false); hide(); updateCost(); writeState(); } input.addEventListener("input", () => { thermCar = null; const q = input.value.trim().toLowerCase(); if (q.length < 2) { hide(); updateCost(); return; } const terms = q.split(/\s+/); items = CARS.filter(c => { const l = c[0].toLowerCase(); return terms.every(t => l.includes(t)); }).slice(0, 8); active = -1; draw(); }); input.addEventListener("keydown", (e) => { const open = list.style.display !== "none"; if (e.key === "Enter") { if (open && active >= 0) choose(active); else computeRoute(); e.preventDefault(); } else if (open && e.key === "ArrowDown") { active = Math.min(active + 1, items.length - 1); draw(); e.preventDefault(); } else if (open && e.key === "ArrowUp") { active = Math.max(active - 1, 0); draw(); e.preventDefault(); } else if (e.key === "Escape") hide(); }); list.addEventListener("mousedown", (e) => { const t = e.target.closest("[data-i]"); if (t) { e.preventDefault(); choose(+t.dataset.i); } }); input.addEventListener("blur", () => setTimeout(hide, 150)); } // Journey fuel cost (combustion) or charging summary (electric), plus an // estimated motorway toll and the grand total (energy + toll) for the route. function updateCost() { const el = document.getElementById("cost-line"); if (!el) return; if (!routeDistanceKm) { el.innerHTML = ""; return; } const toll = routeTollEuro; const hasToll = toll > 0.5; // only show a meaningful toll const tollLine = hasToll ? `
🅿️ Péage autoroute : ≈ ${eur(toll, 2)} € ` + `(estimation, ${eur(routeTollKm, 0)} km d'autoroute)` : ""; const totalLine = (energy, label) => hasToll ? `
💰 Total : ≈ ${eur(energy + toll, 2)} €` + ` (${label} + péage)` : ""; // When a route exists but no vehicle is chosen, still show the toll on its own. const tollOnly = hasToll ? `🅿️ ≈ ${eur(toll, 2)} € de péage` + `
estimation autoroute (${eur(routeTollKm, 0)} km)` : ""; if (vehicleMode === "electrique") { const ev = currentEv(); if (!ev) { el.innerHTML = tollOnly; return; } const usable = ev.range * 0.8; const stops = Math.max(0, Math.ceil(routeDistanceKm / usable) - 1); const kwh = routeDistanceKm / 100 * ev.kwh; const energy = kwh * evPricePerKwh; el.innerHTML = `⚡ ${stops} recharge${stops > 1 ? "s" : ""} en route · autonomie ${ev.range} km` + `
${eur(kwh, 0)} kWh · ~${eur(energy, 0)} € (≈ ${eur(evPricePerKwh, 2)} €/kWh)` + tollLine + totalLine(energy, "recharge"); return; } const car = thermCar; if (!car) { el.innerHTML = tollOnly; return; } const liters = routeDistanceKm / 100 * car.cons; const fi = FUELS.indexOf(car.fuel); const price = cheapestAlongRoute(fi); if (price == null) { el.innerHTML = `~${eur(liters, 1)} L · aucune station ${esc(car.fuel)} sur le trajet.` + tollLine; return; } const fuelCost = liters * price; const hwPrice = cheapestAlongRoute(fi, true); // cheapest autoroute station on the route let html = `⛽ ≈ ${eur(fuelCost, 2)} € de ${esc(car.fuel)}` + `
${eur(liters, 1)} L · au moins cher du trajet (${eur(price, 3)} €/L)`; if (hwPrice != null) { html += `
🛣️ sur autoroute : ≈ ${eur(liters * hwPrice, 2)} € ` + `(${eur(hwPrice, 3)} €/L)`; } el.innerHTML = html + tollLine + totalLine(fuelCost, "carburant"); } // ===================== EV chargers (IRVE) + recharge stops ===================== const chargerCluster = L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 50, disableClusteringAtZoom: 14 }); const stopIcon = L.divIcon({ className: "ev-stop", html: "⚡", iconSize: [26, 26] }); function bboxWhere(s, w, n, e) { return `consolidated_latitude>${s} and consolidated_latitude<${n} and ` + `consolidated_longitude>${w} and consolidated_longitude<${e}`; } function chargerMsg(t) { const el = document.getElementById("ev-count"); if (el) el.textContent = t || ""; } function chargerMarker(c) { const p = c.puissance_nominale; return L.circleMarker([c.consolidated_latitude, c.consolidated_longitude], { radius: 5, color: "#fff", weight: 1, fillColor: p >= 50 ? "#7b1fa2" : "#2e86c1", fillOpacity: 0.9, }).bindPopup(``); } // How many chargers to pull for the current view. Lower zooms cover a far larger // area, so we fetch more points there to keep the clusters spread across France // (markercluster groups them just like the fuel stations). function chargerCap() { const z = map.getZoom(); if (z <= 7) return 1500; if (z <= 10) return 800; return 400; } const chargerMemo = new Map(); // session cache: rounded view -> {objs, total, fetched} function viewKey(b, cap) { const r = (x) => Math.round(x * 10) / 10; // ~0.1° buckets return [r(b.getSouth()), r(b.getWest()), r(b.getNorth()), r(b.getEast()), cap].join(","); } function paintChargers(objs, total, fetched) { const ms = objs.map(chargerMarker); chargerCluster.clearLayers(); chargerCluster.addLayers(ms); chargerMsg(!ms.length ? "Aucune borne dans cette zone" : total > fetched ? `${ms.length} bornes affichées (zoomez pour plus)` : `${ms.length} borne${ms.length > 1 ? "s" : ""}`); } async function loadChargersInView() { if (!chargersOn) return; const b = map.getBounds(); const cap = chargerCap(); const key = viewKey(b, cap); const cached = chargerMemo.get(key); if (cached) { paintChargers(cached.objs, cached.total, cached.fetched); return; } const where = bboxWhere(b.getSouth(), b.getWest(), b.getNorth(), b.getEast()); const base = `${IRVE_URL}?limit=100&select=${IRVE_SELECT}&where=${encodeURIComponent(where)}`; const myseq = ++loadSeq; // ignore this result if a newer view starts loading chargerMsg("Chargement des bornes…"); try { // First page reveals the total; fetch the remaining pages in parallel so a // large zoomed-out pull stays fast. The API caps each request at 100 rows. const first = await (await fetch(`${base}&offset=0`)).json(); if (myseq !== loadSeq) return; const total = first.total_count || 0; let all = first.results || []; const want = Math.min(total, cap); const offsets = []; for (let o = 100; o < want; o += 100) offsets.push(o); if (offsets.length) { const pages = await Promise.all( offsets.map(o => fetch(`${base}&offset=${o}`).then(r => r.json()).catch(() => null))); if (myseq !== loadSeq) return; for (const j of pages) if (j && j.results) all = all.concat(j.results); } // Several charge points share one location; keep one marker per spot (max power). const bySpot = new Map(); for (const c of all) { if (c.consolidated_latitude == null) continue; const k = c.consolidated_latitude + "," + c.consolidated_longitude; const prev = bySpot.get(k); if (!prev || (c.puissance_nominale || 0) > (prev.puissance_nominale || 0)) bySpot.set(k, c); } const objs = [...bySpot.values()]; chargerMemo.set(key, { objs, total, fetched: all.length }); paintChargers(objs, total, all.length); } catch (e) { chargerMsg("Bornes indisponibles."); } } function toggleChargers(on) { chargersOn = on; if (on) { map.addLayer(chargerCluster); loadChargersInView(); } else { map.removeLayer(chargerCluster); chargerMsg(""); } } map.on("moveend", () => { if (!chargersOn) return; clearTimeout(chargerTimer); chargerTimer = setTimeout(loadChargersInView, 350); }); function clearStops() { stopMarkers.forEach(m => map.removeLayer(m)); stopMarkers = []; } async function findNearestCharger(lat, lon) { const d = 0.25; // ~25 km search box around the target point const where = bboxWhere(lat - d, lon - d, lat + d, lon + d); const url = `${IRVE_URL}?limit=100&select=${IRVE_SELECT}&where=${encodeURIComponent(where)}`; try { const j = await (await fetch(url)).json(); let best = null, bd = Infinity; for (const c of (j.results || [])) { if (c.consolidated_latitude == null) continue; const dist = haversine(lat, lon, c.consolidated_latitude, c.consolidated_longitude); if (dist < bd) { bd = dist; best = { lat: c.consolidated_latitude, lon: c.consolidated_longitude, name: c.nom_station, power: c.puissance_nominale }; } } return best; } catch (e) { return null; } } async function planEvStops() { clearStops(); if (vehicleMode !== "electrique" || !routeCoords) return; const ev = currentEv(); if (!ev) return; const usable = ev.range * 0.8; // recharge before fully depleting const targets = []; let target = usable, acc = 0; for (let i = 1; i < routeCoords.length; i++) { acc += haversine(routeCoords[i - 1].lat, routeCoords[i - 1].lng, routeCoords[i].lat, routeCoords[i].lng); while (acc >= target) { targets.push(routeCoords[i]); target += usable; } } for (const p of targets) { const c = await findNearestCharger(p.lat, p.lng); const m = L.marker(c ? [c.lat, c.lon] : [p.lat, p.lng], { icon: stopIcon }).addTo(map); m.bindTooltip( c ? `Recharge : ${esc(c.name || "borne")}${c.power ? " · " + eur(c.power, 0) + " kW" : ""}` : "Recharge conseillée", { direction: "top" }); stopMarkers.push(m); } } // ===================== Geolocation (real-time position) ===================== function setLocateActive(on) { const a = document.querySelector(".locate-ctrl a"); if (a) a.classList.toggle("locating", on); } function stopLocate() { locating = false; setLocateActive(false); map.stopLocate(); if (locMarker) { map.removeLayer(locMarker); locMarker = null; } if (locCircle) { map.removeLayer(locCircle); locCircle = null; } } function toggleLocate() { if (locating) { stopLocate(); return; } locating = true; firstFix = true; setLocateActive(true); map.locate({ watch: true, enableHighAccuracy: true, maximumAge: 5000 }); } map.on("locationfound", (e) => { const r = Math.min(e.accuracy || 50, 2000); // accuracy radius, capped if (!locMarker) { locMarker = L.circleMarker(e.latlng, { radius: 7, color: "#fff", weight: 2, fillColor: "#1a73e8", fillOpacity: 1, }).addTo(map).bindTooltip("Ma position"); locCircle = L.circle(e.latlng, { radius: r, color: "#1a73e8", weight: 1, fillColor: "#1a73e8", fillOpacity: 0.12, }).addTo(map); } else { locMarker.setLatLng(e.latlng); locCircle.setLatLng(e.latlng).setRadius(r); } if (firstFix) { map.setView(e.latlng, Math.max(map.getZoom(), 14)); firstFix = false; } }); map.on("locationerror", () => { stopLocate(); alert("Localisation indisponible. Autorisez l'accès à votre position (lien https requis)."); }); // ===================== Install prompt (first visit) ===================== // One-time, browser-aware "add to home screen" tip. Chromium browsers also get a // real install button via the beforeinstallprompt event; others get instructions. let deferredInstall = null; window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredInstall = e; const btn = document.querySelector(".install-btn"); if (btn) btn.style.display = "inline-block"; // reveal if the banner is already up }); window.addEventListener("appinstalled", () => { localStorage.setItem("pm_install_dismissed", "1"); closeInstall(); }); function installInstructions() { const ua = navigator.userAgent; const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const isAndroid = /Android/.test(ua); if (isIOS) { return "Appuyez sur Partager ⬆︎ puis « Sur l'écran d'accueil »."; } if (/FxiOS|Firefox/.test(ua)) { return "Menu puis « Installer » ou « Ajouter à l'écran d'accueil »."; } if (/SamsungBrowser/.test(ua)) { return "Menu puis « Ajouter la page à » → Écran d'accueil."; } if (/Edg\//.test(ua)) { return "Menu Applications → « Installer ce site comme une application »."; } if (isAndroid) { // Chrome, Brave, etc. on Android return "Menu puis « Installer l'application » / « Ajouter à l'écran d'accueil »."; } return "Cliquez sur l'icône Installer dans la barre d'adresse, " + "ou Menu → « Installer Prix Carburants… »."; } function closeInstall() { const el = document.querySelector(".install-prompt"); if (el) el.remove(); } function showInstallPrompt() { const standalone = window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; if (standalone) return; // already installed if (localStorage.getItem("pm_install_dismissed")) return; // already dismissed if (document.querySelector(".install-prompt")) return; const el = document.createElement("div"); el.className = "install-prompt"; el.setAttribute("role", "dialog"); el.setAttribute("aria-label", "Installer l'application"); el.innerHTML = '
' + '
Installer l\'application' + `${installInstructions()}
` + '' + ''; document.body.appendChild(el); if (deferredInstall) el.querySelector(".install-btn").style.display = "inline-block"; el.querySelector(".install-btn").addEventListener("click", async () => { if (!deferredInstall) return; deferredInstall.prompt(); await deferredInstall.userChoice; deferredInstall = null; localStorage.setItem("pm_install_dismissed", "1"); closeInstall(); }); el.querySelector(".install-close").addEventListener("click", () => { localStorage.setItem("pm_install_dismissed", "1"); closeInstall(); }); } // ===================== Shareable URL state ===================== // Encode the current selection in the URL hash so links/PWA restore the view. function writeState() { const p = new URLSearchParams(); p.set("f", electricView ? "ev" : String(currentFuel)); if (brandFilter) p.set("b", brandFilter); if (maxPrice != null) p.set("pmax", String(maxPrice)); const val = (id) => (document.getElementById(id) || {}).value || ""; if (val("dep")) p.set("dep", val("dep")); if (val("arr")) p.set("arr", val("arr")); if (vehicleMode === "electrique") { p.set("mode", "e"); if (val("ev")) p.set("ev", val("ev")); if (val("ev") === "custom" && val("ev-range")) p.set("evr", val("ev-range")); p.set("kwh", String(evPricePerKwh)); } else if (thermCar) { p.set("car", thermCar.label); } const str = p.toString(); try { history.replaceState(null, "", str ? "#" + str : location.pathname + location.search); } catch (e) { /* ignore */ } } // Restore state from the URL hash on load (best-effort; ignores unknown values). function restoreState() { const hash = location.hash.replace(/^#/, ""); if (!hash) return; const p = new URLSearchParams(hash); const f = p.get("f"); if (f === "ev") selectElectric(); else if (f) selectFuel(+f, false); if (p.get("mode") === "e") { const radio = document.querySelector('input[name="vmode"][value="electrique"]'); if (radio) { radio.checked = true; onVehicleModeChange({ target: radio }); } const ev = document.getElementById("ev"); if (ev && p.get("ev") != null) { ev.value = p.get("ev"); onEvChange(); } if (p.get("evr")) { const r = document.getElementById("ev-range"); if (r) { r.value = p.get("evr"); onEvChange(); } } if (p.get("kwh")) { evPricePerKwh = parseFloat(p.get("kwh")) || 0.40; const ps = document.getElementById("ev-price"); if (ps) ps.value = p.get("kwh"); } } else if (p.get("car")) { const input = document.getElementById("car"); if (input) input.value = p.get("car"); const m = CARS.find(c => c[0] === p.get("car")); if (m) { thermCar = { label: m[0], fuel: m[1], cons: m[2] }; selectFuel(FUELS.indexOf(m[1]), false); } } if (p.get("b")) { brandFilter = p.get("b"); const sel = document.getElementById("brand"); if (sel) sel.value = brandFilter; } if (p.get("pmax") != null) { // after fuel/car selection (those reset the slider) maxPrice = parseFloat(p.get("pmax")); const slider = document.getElementById("price-slider"); if (slider) slider.value = maxPrice; updateSliderOut(); } if (p.get("dep")) { const d = document.getElementById("dep"); if (d) d.value = p.get("dep"); } if (p.get("arr")) { const a = document.getElementById("arr"); if (a) a.value = p.get("arr"); } render(false); if (p.get("dep") && p.get("arr")) computeRoute(); } // Favourite toggle inside station popups (delegated; popups are created lazily). document.addEventListener("click", (e) => { const btn = e.target.closest(".fav-btn"); if (!btn) return; const key = btn.dataset.k; if (favorites.has(key)) favorites.delete(key); else favorites.add(key); cacheSet("favorites", [...favorites]); const on = favorites.has(key); btn.classList.toggle("on", on); btn.textContent = on ? "★ Favori" : "☆ Favori"; btn.setAttribute("aria-pressed", String(on)); if (favOnly || listShown) render(false); // reflect add/remove in filtered views }); // ===================== Init ===================== // On narrow screens, start with both panels collapsed so the map is clear. if (window.matchMedia(MOBILE_MQ).matches) { setCollapsed(document.querySelector(".panel"), true); setCollapsed(document.querySelector(".route-box"), true); } configureSlider(); render(false); restoreState(); // apply any shared/saved view from the URL hash // Give the map a moment to settle before surfacing the install tip. setTimeout(showInstallPrompt, 2500); // "Nouvelle version" toast: when a new service worker is waiting, let the user // apply it on demand instead of silently serving stale assets. function showUpdatePrompt(reg) { if (document.querySelector(".update-prompt")) return; const el = document.createElement("div"); el.className = "update-prompt"; el.innerHTML = '🔄 Nouvelle version disponible' + ''; document.body.appendChild(el); el.querySelector(".update-btn").addEventListener("click", () => { if (reg.waiting) reg.waiting.postMessage({ type: "SKIP_WAITING" }); el.remove(); }); } if ("serviceWorker" in navigator) { window.addEventListener("load", async () => { try { const reg = await navigator.serviceWorker.register("/sw.js"); if (reg.waiting) showUpdatePrompt(reg); reg.addEventListener("updatefound", () => { const sw = reg.installing; if (!sw) return; sw.addEventListener("statechange", () => { if (sw.state === "installed" && navigator.serviceWorker.controller) showUpdatePrompt(reg); }); }); } catch (e) { /* registration failed — app still works online */ } }); let reloaded = false; navigator.serviceWorker.addEventListener("controllerchange", () => { if (reloaded) return; reloaded = true; location.reload(); }); } })();