Spaces:
Sleeping
Sleeping
| /* 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 () { | |
| ; | |
| // ===================== 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: '© <a href="https://www.ign.fr/">IGN-F/Géoportail</a> · ' + | |
| '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( | |
| '<a href="https://www.linkedin.com/in/samuel-adone" target="_blank" rel="noopener" ' + | |
| 'class="credit-link">Créé par Samuel Adone</a> · ' + | |
| '<a href="https://leafletjs.com" target="_blank" rel="noopener">Leaflet</a>' | |
| ); | |
| 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 += `<tr${hi}><td>${f}</td><td class="p">${eur(prices[i], 3)} €</td></tr>`; | |
| } | |
| }); | |
| const dir = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`; | |
| const title = brand ? `${name} · ${brand}` : name; | |
| const hw = s[HW] ? ' <span class="hw-badge">AUTOROUTE</span>' : ""; | |
| const open = s[OPEN24] ? ' <span class="svc-badge">24/24</span>' : ""; | |
| const dateFr = updated ? updated.split("-").reverse().join("/") : "—"; | |
| const svcList = s[SERVICES] || []; | |
| const svc = svcList.length | |
| ? `<div class="svc">${svcList.slice(0, 8).map(esc).join(" · ")}</div>` : ""; | |
| const key = favKey(s), fav = favorites.has(key); | |
| const favBtn = `<button class="fav-btn ${fav ? "on" : ""}" data-k="${key}" ` + | |
| `aria-pressed="${fav}">${fav ? "★ Favori" : "☆ Favori"}</button>`; | |
| return `<div class="popup"><b>${esc(title)}</b>${hw}${open}` + | |
| `<div class="addr">${esc(addr)}</div><table>${rows}</table>${svc}` + | |
| `<div class="upd">Relevé le ${dateFr}</div>` + | |
| `<div class="popup-actions">${favBtn}` + | |
| `<a href="${dir}" target="_blank">S'y rendre ↗</a></div></div>`; | |
| } | |
| // 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 = | |
| '<h4>Code postal / ville</h4>' + | |
| '<div class="search-row">' + | |
| '<input id="place" type="text" placeholder="ex. 75011 ou Lyon" autocomplete="off" ' + | |
| 'aria-label="Filtrer par code postal ou ville">' + | |
| '<button id="place-clear" class="btn" title="Effacer" aria-label="Effacer la recherche">✕</button></div>' + | |
| '<div id="place-hint" class="hint" aria-live="polite"></div>' + | |
| '<button id="near-me" class="btn near-me" type="button">📍 Stations près de moi</button>' + | |
| '<h4>Carburant</h4>'; | |
| FUELS.forEach((f, i) => { | |
| if (!STATS[f]) return; | |
| body += `<label><input type="radio" name="fuel" value="${i}" ${i === 0 ? "checked" : ""}>` + | |
| `${f} <span class="count">(${STATS[f].count})</span></label>`; | |
| }); | |
| body += '<label><input type="radio" name="fuel" value="ev">⚡ Électrique ' + | |
| '<span class="count">(bornes)</span></label>'; | |
| body += '<div id="price-filter"><h4>Prix maximum</h4>' + | |
| '<input id="price-slider" type="range" step="0.01" aria-label="Prix maximum">' + | |
| '<div class="slider-row"><span id="slider-out" class="slider-out"></span>' + | |
| '<button id="slider-reset" class="btn-link" type="button">tout afficher</button>' + | |
| '</div></div>'; | |
| body += '<div id="brand-filter"><h4>Enseigne</h4>' + | |
| '<select id="brand" class="route-sel" aria-label="Filtrer par enseigne">' + brandOptions() + '</select></div>'; | |
| body += '<div id="filters"><h4>Filtres</h4>' + | |
| '<label class="chk"><input type="checkbox" id="f-fav"> ★ Favoris seulement</label>' + | |
| '<label class="chk"><input type="checkbox" id="f-24"> Ouvert 24/24</label>' + | |
| '<select id="f-fresh" class="route-sel" aria-label="Fraîcheur des prix">' + | |
| '<option value="">Prix : toute fraîcheur</option>' + | |
| '<option value="2">Prix relevé ≤ 2 jours</option>' + | |
| '<option value="7">Prix relevé ≤ 7 jours</option>' + | |
| '<option value="14">Prix relevé ≤ 14 jours</option>' + | |
| '</select></div>'; | |
| body += '<div class="legend-bar"></div><div class="legend-scale">' + | |
| '<span id="lg-lo"></span><span id="lg-mid"></span><span id="lg-hi"></span></div>' + | |
| '<div id="lg-trend" class="lg-trend"></div>' + | |
| '<div id="ev-legend" class="ev-legend" style="display:none">' + | |
| '<div><span class="sw fast"></span>Rapide (≥ 50 kW)</div>' + | |
| '<div><span class="sw slow"></span>Normale</div>' + | |
| '<div id="ev-count" class="hint" aria-live="polite"></div></div>' + | |
| '<div id="route-status" class="route-status" aria-live="polite"></div>' + | |
| '<button id="list-toggle" class="btn-link" type="button" aria-expanded="false">Voir les stations ▾</button>' + | |
| '<select id="list-sort" class="route-sel" style="display:none" aria-label="Trier les stations">' + | |
| '<option value="price">Trier : prix</option>' + | |
| '<option value="fresh">Trier : fraîcheur</option>' + | |
| '<option value="name">Trier : nom</option></select>' + | |
| '<div id="cheapest-list" class="cheapest-list" style="display:none"></div>'; | |
| div.innerHTML = | |
| '<div class="panel-head" role="button" tabindex="0" aria-expanded="true" ' + | |
| 'aria-label="Replier ou déplier le panneau Carburant">' + | |
| '<span>⛽ Carburant</span><span class="p-caret">▾</span></div>' + | |
| `<div class="panel-body">${body}</div>`; | |
| 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)} € · <span class="${up ? "up" : "down"}">` + | |
| `${up ? "▲" : "▼"} ${eur(Math.abs(tr.delta), 3)} €</span> <span class="hint">vs dernier relevé</span>`; | |
| } else if (tr) { | |
| el.innerHTML = `médiane ${eur(tr.median, 3)} € · <span class="hint">stable</span>`; | |
| } 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 = '<button id="route-clear" class="btn">Effacer l\'itinéraire</button>'; | |
| if (count) { | |
| const p = cheapest[PRICES][currentFuel]; | |
| rs.innerHTML = `🛣️ Sur le trajet · <span class="cheapest">moins chère ${eur(p, 3)} €</span>` + | |
| `<br>${esc(cheapest[2])}<br>${clearBtn}`; | |
| } else { | |
| rs.innerHTML = `Aucune station ${esc(FUELS[currentFuel])} le long du trajet.<br>${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 '<option value="">Toutes les enseignes</option>' + | |
| arr.map(([b, n]) => `<option value="${esc(b)}">${esc(b)} (${n})</option>`).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 `<div class="cl-item" role="button" tabindex="0" data-lat="${s[0]}" data-lon="${s[1]}" ` + | |
| `aria-label="${esc(s[2])}, ${eur(s[PRICES][currentFuel], 3)} euros">` + | |
| `<span class="cl-price">${eur(s[PRICES][currentFuel], 3)} €</span>` + | |
| `<span class="cl-name">${star}${esc(s[2])}<small>${esc(s[CITY] || "")}</small></span></div>`; | |
| }).join("") | |
| : '<div class="hint">Aucune station.</div>'; | |
| } | |
| 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) => | |
| `<div class="item ${i === active ? "active" : ""}" data-i="${i}">` + | |
| `${esc(f.properties.label)}<span class="ctx">${esc(f.properties.context || "")}</span></div>` | |
| ).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 `<div class="ro-item ${i === activeRouteIdx ? "active" : ""}" data-i="${i}" ` + | |
| `role="button" tabindex="0">` + | |
| `<span class="ro-dot" style="background:${o.color}"></span>` + | |
| `<span class="ro-text"><b>${esc(o.label)}</b>` + | |
| `<small>${o.distanceKm.toFixed(0)} km · ${h ? h + " h " : ""}${m} min · ${toll}</small>` + | |
| `</span></div>`; | |
| }).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 = '<option value="">— Modèle électrique —</option>' + | |
| EVS.map((e, i) => `<option value="${i}">${e.name} (${e.range} km)</option>`).join("") + | |
| '<option value="custom">Autre (saisir l\'autonomie)…</option>'; | |
| const evPriceOptions = EV_PRICES.map((p) => | |
| `<option value="${p.v}" ${p.v === 0.40 ? "selected" : ""}>Recharge ${p.label}</option>`).join(""); | |
| div.innerHTML = | |
| '<div class="route-head" role="button" tabindex="0" aria-expanded="true" ' + | |
| 'aria-label="Replier ou déplier le panneau Itinéraire">' + | |
| '<span>🛣️ Itinéraire</span><span id="route-caret">▾</span></div>' + | |
| '<div class="route-body">' + | |
| '<div class="ac-wrap"><input id="dep" type="text" placeholder="Ville de départ" autocomplete="off" aria-label="Ville de départ"></div>' + | |
| '<div class="ac-wrap"><input id="arr" type="text" placeholder="Ville d\'arrivée" autocomplete="off" aria-label="Ville d\'arrivée"></div>' + | |
| '<div class="seg">' + | |
| '<label class="seg-on"><input type="radio" name="vmode" value="thermique" checked>Thermique</label>' + | |
| '<label><input type="radio" name="vmode" value="electrique">Électrique</label></div>' + | |
| '<div id="therm-block">' + | |
| '<div class="ac-wrap"><input id="car" type="text" placeholder="Modèle (ex. Clio, 308 HDi…)" ' + | |
| 'autocomplete="off" aria-label="Modèle de véhicule"></div></div>' + | |
| '<div id="ev-block" style="display:none">' + | |
| `<select id="ev" class="route-sel" aria-label="Modèle électrique">${evOptions}</select>` + | |
| '<input id="ev-range" type="number" min="80" max="800" step="10" placeholder="Autonomie (km)" ' + | |
| 'aria-label="Autonomie en km" style="display:none">' + | |
| `<select id="ev-price" class="route-sel" aria-label="Prix de recharge">${evPriceOptions}</select></div>` + | |
| '<div class="route-actions">' + | |
| '<button id="route-go" class="btn">Calculer</button>' + | |
| '<button id="route-reset" class="btn">Effacer</button></div>' + | |
| '<div id="route-msg" class="hint" aria-live="polite"></div>' + | |
| '<div id="route-options" class="route-options"></div>' + | |
| '<div id="cost-line" class="cost" aria-live="polite"></div></div>'; | |
| 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) => | |
| `<div class="item ${i === active ? "active" : ""}" data-i="${i}">${esc(c[0])}` + | |
| `<span class="ctx">${esc(c[1])} · ${eur(c[2], 1)} L/100 km</span></div>`).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 | |
| ? `<br><span class="hint">🅿️ Péage autoroute : ≈ ${eur(toll, 2)} € ` + | |
| `(estimation, ${eur(routeTollKm, 0)} km d'autoroute)</span>` | |
| : ""; | |
| const totalLine = (energy, label) => hasToll | |
| ? `<br>💰 <b>Total : ≈ ${eur(energy + toll, 2)} €</b>` + | |
| `<span class="hint"> (${label} + péage)</span>` | |
| : ""; | |
| // When a route exists but no vehicle is chosen, still show the toll on its own. | |
| const tollOnly = hasToll | |
| ? `🅿️ <b>≈ ${eur(toll, 2)} €</b> de péage` + | |
| `<br><span class="hint">estimation autoroute (${eur(routeTollKm, 0)} km)</span>` | |
| : ""; | |
| 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 = | |
| `⚡ <b>${stops} recharge${stops > 1 ? "s" : ""}</b> en route · autonomie ${ev.range} km` + | |
| `<br><span class="hint">${eur(kwh, 0)} kWh · ~${eur(energy, 0)} € (≈ ${eur(evPricePerKwh, 2)} €/kWh)</span>` + | |
| 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 = `⛽ <b>≈ ${eur(fuelCost, 2)} €</b> de ${esc(car.fuel)}` + | |
| `<br><span class="hint">${eur(liters, 1)} L · au moins cher du trajet (${eur(price, 3)} €/L)</span>`; | |
| if (hwPrice != null) { | |
| html += `<br><span class="hint">🛣️ sur autoroute : ≈ ${eur(liters * hwPrice, 2)} € ` + | |
| `(${eur(hwPrice, 3)} €/L)</span>`; | |
| } | |
| 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(`<div class="popup"><b>${esc(c.nom_station || "Borne de recharge")}</b>` + | |
| `<div class="addr">${esc(c.nom_operateur || "")}</div>${p ? eur(p, 0) + " kW" : ""}</div>`); | |
| } | |
| // 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 <b>Partager</b> <span class=\"ic\">⬆︎</span> puis « <b>Sur l'écran d'accueil</b> »."; | |
| } | |
| if (/FxiOS|Firefox/.test(ua)) { | |
| return "Menu <b>⋮</b> puis « <b>Installer</b> » ou « <b>Ajouter à l'écran d'accueil</b> »."; | |
| } | |
| if (/SamsungBrowser/.test(ua)) { | |
| return "Menu <b>≡</b> puis « <b>Ajouter la page à</b> » → <b>Écran d'accueil</b>."; | |
| } | |
| if (/Edg\//.test(ua)) { | |
| return "Menu <b>⋯</b> → <b>Applications</b> → « <b>Installer ce site comme une application</b> »."; | |
| } | |
| if (isAndroid) { // Chrome, Brave, etc. on Android | |
| return "Menu <b>⋮</b> puis « <b>Installer l'application</b> » / « <b>Ajouter à l'écran d'accueil</b> »."; | |
| } | |
| return "Cliquez sur l'icône <b>Installer</b> <span class=\"ic\">⊕</span> dans la barre d'adresse, " + | |
| "ou Menu <b>⋮</b> → « <b>Installer Prix Carburants…</b> »."; | |
| } | |
| 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 = | |
| '<div class="ip-icon">⛽</div>' + | |
| '<div class="ip-text"><b>Installer l\'application</b>' + | |
| `<span>${installInstructions()}</span></div>` + | |
| '<button class="install-btn" type="button" style="display:none">Installer</button>' + | |
| '<button class="install-close" type="button" aria-label="Fermer">✕</button>'; | |
| 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 = '<span>🔄 Nouvelle version disponible</span>' + | |
| '<button class="update-btn" type="button">Recharger</button>'; | |
| 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(); | |
| }); | |
| } | |
| })(); | |