SamDNX's picture
Propose alternative routes: another autoroute + a toll-free départementale way
cd2d39f
Raw
History Blame Contribute Delete
66.6 kB
/* 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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: '&copy; <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();
});
}
})();