SamDNX Claude Opus 4.8 commited on
Commit
ed70eb7
·
1 Parent(s): 4ccb3e1

Reliability + accuracy + features: routing fallback, tolls, EV price, filters, trends, dark mode, URL state, social card

Browse files

Reliability:
- Routing tries FOSSGIS first, falls back to project-osrm; clearer error when
both are unavailable.
- Cache BAN geocoding (localStorage for cities, in-memory for autocomplete) and
charger views (session cache by rounded bbox+zoom).
- Service-worker update prompt: a "Nouvelle version — Recharger" toast applies
the waiting SW on demand (install no longer auto-skips waiting).

Accuracy:
- EV charging-price selector (home / mixte / fast-charge EUR/kWh) used in the cost.
- Smarter toll estimate: per-road EUR/km (free autoroutes -> 0, A14 premium),
excludes free lettered urban links (A6a/A6b); totals use the computed euro.

Features:
- Filter by enseigne (brand) with counts.
- "Voir les moins cheres": clickable list of the 12 cheapest in the selection.
- "Stations pres de moi": one-tap geolocation to the nearest cheapest station.
- Price trend per fuel (median + up/down vs previous build), persisted in
output/price_history.json.
- Dark mode following the OS setting (panels + inverted basemap).
- Shareable URL state (hash) restoring fuel, brand, price ceiling, route + vehicle.

Social card:
- generate_og.py renders a 1200x630 og-card.png; built at container start (Pillow
+ DejaVu font) into output/, not committed (HF blocks new binaries). OG/Twitter
use it with summary_large_image.

Bump service worker cache to v12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (9) hide show
  1. .gitignore +2 -0
  2. Dockerfile +5 -0
  3. build_map.py +43 -1
  4. generate_og.py +98 -0
  5. map_template.html +7 -7
  6. requirements.txt +1 -0
  7. static/app.js +322 -42
  8. static/styles.css +51 -0
  9. static/sw.js +8 -4
.gitignore CHANGED
@@ -5,3 +5,5 @@ __pycache__/
5
  *.pyc
6
  .DS_Store
7
  *.log
 
 
 
5
  *.pyc
6
  .DS_Store
7
  *.log
8
+ # Social card is generated at build time into output/, not committed (HF blocks new binaries)
9
+ static/og-card.png
Dockerfile CHANGED
@@ -1,6 +1,11 @@
1
  # Hugging Face Spaces (Docker SDK) — serves the PWA on port 7860.
2
  FROM python:3.11-slim
3
 
 
 
 
 
 
4
  # HF Spaces run as a non-root user with UID 1000.
5
  RUN useradd -m -u 1000 user
6
  USER user
 
1
  # Hugging Face Spaces (Docker SDK) — serves the PWA on port 7860.
2
  FROM python:3.11-slim
3
 
4
+ # A TrueType font so the build can render the social-share card (og-card.png).
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends fonts-dejavu-core \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
  # HF Spaces run as a non-root user with UID 1000.
10
  RUN useradd -m -u 1000 user
11
  USER user
build_map.py CHANGED
@@ -23,6 +23,7 @@ OUTPUT_DIR = os.path.join(BASE_DIR, "output")
23
  STATIC_DIR = os.path.join(BASE_DIR, "static")
24
  OUTPUT_HTML = os.path.join(OUTPUT_DIR, "index.html") # served at "/" (PWA start_url)
25
  CACHE_JSON = os.path.join(OUTPUT_DIR, "stations.json")
 
26
  PARIS = ZoneInfo("Europe/Paris")
27
  SITE_URL = "https://samdnx-prix-carburant.hf.space/" # canonical URL (sitemap / SEO)
28
 
@@ -59,6 +60,40 @@ def fuel_stats(stations: list[dict]) -> dict[str, dict]:
59
  return stats
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def compact_records(stations: list[dict]) -> list[list]:
63
  """Encode each station as a small positional array to shrink the payload."""
64
  records = []
@@ -93,11 +128,13 @@ def build_html(stations: list[dict]) -> str:
93
  except Exception as exc: # car list is optional; never block the map build
94
  print(f" (car list unavailable: {exc})")
95
  cars = []
 
96
  payload = {
97
  "fuels": FUEL_LABELS,
98
- "stats": fuel_stats(stations),
99
  "stations": compact_records(stations),
100
  "cars": cars,
 
101
  }
102
  data_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
103
  months_fr = ["janv.", "févr.", "mars", "avr.", "mai", "juin",
@@ -144,6 +181,11 @@ def generate(output_html: str = OUTPUT_HTML) -> str:
144
  os.makedirs(OUTPUT_DIR, exist_ok=True)
145
  copy_static()
146
  write_sitemap(datetime.now(PARIS).strftime("%Y-%m-%d"))
 
 
 
 
 
147
  stations = fetch_stations(cache_path=CACHE_JSON)
148
  html_doc = build_html(stations)
149
  with open(output_html, "w", encoding="utf-8") as fh:
 
23
  STATIC_DIR = os.path.join(BASE_DIR, "static")
24
  OUTPUT_HTML = os.path.join(OUTPUT_DIR, "index.html") # served at "/" (PWA start_url)
25
  CACHE_JSON = os.path.join(OUTPUT_DIR, "stations.json")
26
+ HISTORY_JSON = os.path.join(OUTPUT_DIR, "price_history.json") # national medians over time
27
  PARIS = ZoneInfo("Europe/Paris")
28
  SITE_URL = "https://samdnx-prix-carburant.hf.space/" # canonical URL (sitemap / SEO)
29
 
 
60
  return stats
61
 
62
 
63
+ def compute_trends(stats: dict[str, dict]) -> dict[str, dict]:
64
+ """National median per fuel, with the change vs the previous build.
65
+
66
+ A small history of medians is kept in output/price_history.json so the map
67
+ can show whether each fuel is trending up or down. On hosts with ephemeral
68
+ storage the history resets on a cold restart (deltas then start at 0).
69
+ """
70
+ today = datetime.now(PARIS).strftime("%Y-%m-%d")
71
+ medians = {f: stats[f]["median"] for f in stats}
72
+ history: list[dict] = []
73
+ if os.path.exists(HISTORY_JSON):
74
+ try:
75
+ with open(HISTORY_JSON, encoding="utf-8") as fh:
76
+ history = json.load(fh)
77
+ except Exception:
78
+ history = []
79
+
80
+ prev = history[-1]["medians"] if history else {}
81
+ trends = {f: {"median": m, "delta": round(m - prev[f], 3) if f in prev else 0.0}
82
+ for f, m in medians.items()}
83
+
84
+ if history and history[-1].get("date") == today:
85
+ history[-1]["medians"] = medians # same day: keep one entry
86
+ else:
87
+ history.append({"date": today, "medians": medians})
88
+ history = history[-30:]
89
+ try:
90
+ with open(HISTORY_JSON, "w", encoding="utf-8") as fh:
91
+ json.dump(history, fh)
92
+ except Exception:
93
+ pass
94
+ return trends
95
+
96
+
97
  def compact_records(stations: list[dict]) -> list[list]:
98
  """Encode each station as a small positional array to shrink the payload."""
99
  records = []
 
128
  except Exception as exc: # car list is optional; never block the map build
129
  print(f" (car list unavailable: {exc})")
130
  cars = []
131
+ stats = fuel_stats(stations)
132
  payload = {
133
  "fuels": FUEL_LABELS,
134
+ "stats": stats,
135
  "stations": compact_records(stations),
136
  "cars": cars,
137
+ "trends": compute_trends(stats),
138
  }
139
  data_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
140
  months_fr = ["janv.", "févr.", "mars", "avr.", "mai", "juin",
 
181
  os.makedirs(OUTPUT_DIR, exist_ok=True)
182
  copy_static()
183
  write_sitemap(datetime.now(PARIS).strftime("%Y-%m-%d"))
184
+ try: # social-share card (needs Pillow + a system font); never block the build
185
+ import generate_og
186
+ generate_og.save(os.path.join(OUTPUT_DIR, "og-card.png"))
187
+ except Exception as exc:
188
+ print(f" (social card unavailable: {exc})")
189
  stations = fetch_stations(cache_path=CACHE_JSON)
190
  html_doc = build_html(stations)
191
  with open(output_html, "w", encoding="utf-8") as fh:
generate_og.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate the social-share card (Open Graph / Twitter image), 1200x630.
2
+
3
+ Run once; the PNG is committed to static/ and copied to the site root at build
4
+ time. The container does not need Pillow at runtime. Re-run only if the wording
5
+ or brand colours change: python generate_og.py
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from PIL import Image, ImageDraw, ImageFont
12
+
13
+ from generate_icons import make_icon
14
+
15
+ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
16
+ W, H, SS = 1200, 630, 2 # final size + supersample factor for crisp text
17
+
18
+ INK = (33, 37, 41)
19
+ GREY = (90, 99, 110)
20
+ GREEN = (26, 152, 80)
21
+ # Price legend gradient (matches the map): cheap green -> pricey red.
22
+ LEGEND = [(26, 152, 80), (145, 207, 96), (254, 224, 139), (252, 141, 89), (215, 48, 39)]
23
+
24
+ # Font candidates per weight: Arial on macOS (local), DejaVu in the container.
25
+ FONTS = {
26
+ "bold": ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",
27
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
28
+ "/Library/Fonts/Arial Bold.ttf"],
29
+ "regular": ["/System/Library/Fonts/Supplemental/Arial.ttf",
30
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
31
+ "/Library/Fonts/Arial.ttf"],
32
+ }
33
+
34
+
35
+ def _font(weight: str, size: int) -> ImageFont.FreeTypeFont:
36
+ for path in FONTS[weight]:
37
+ if os.path.exists(path):
38
+ return ImageFont.truetype(path, size)
39
+ return ImageFont.load_default()
40
+
41
+
42
+ def _lerp(a, b, t):
43
+ return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))
44
+
45
+
46
+ def _gradient_bar(draw: ImageDraw.ImageDraw, x0, y0, x1, y1) -> None:
47
+ """Horizontal multi-stop gradient bar (the price scale)."""
48
+ span = x1 - x0
49
+ seg = span / (len(LEGEND) - 1)
50
+ for x in range(x0, x1):
51
+ f = (x - x0) / seg
52
+ i = min(int(f), len(LEGEND) - 2)
53
+ draw.line([(x, y0), (x, y1)], fill=_lerp(LEGEND[i], LEGEND[i + 1], f - i))
54
+
55
+
56
+ def build() -> Image.Image:
57
+ w, h = W * SS, H * SS
58
+ img = Image.new("RGB", (w, h), (255, 255, 255))
59
+ d = ImageDraw.Draw(img)
60
+
61
+ # Left: the brand icon tile.
62
+ icon = make_icon(360 * SS, rounded=True)
63
+ img.paste(icon, (70 * SS, (h - icon.height) // 2), icon)
64
+
65
+ tx = 470 * SS # text column
66
+ d.text((tx, 160 * SS), "Prix des carburants", font=_font("bold", 60 * SS), fill=INK)
67
+ d.text((tx, 240 * SS), "en France", font=_font("bold", 60 * SS), fill=GREEN)
68
+ d.text((tx, 350 * SS), "Carte en temps réel — gazole, SP95,",
69
+ font=_font("regular", 34 * SS), fill=GREY)
70
+ d.text((tx, 396 * SS), "SP98, E85, GPL & bornes de recharge",
71
+ font=_font("regular", 34 * SS), fill=GREY)
72
+
73
+ _gradient_bar(d, tx, 470 * SS, (W - 80) * SS, 492 * SS)
74
+ d.text((tx, 504 * SS), "moins cher", font=_font("regular", 26 * SS), fill=GREY)
75
+ pricey = "plus cher"
76
+ pf = _font("regular", 26 * SS)
77
+ d.text(((W - 80) * SS - d.textlength(pricey, font=pf), 504 * SS), pricey, font=pf, fill=GREY)
78
+
79
+ d.text((tx, 556 * SS), "samdnx-prix-carburant.hf.space",
80
+ font=_font("bold", 26 * SS), fill=GREEN)
81
+
82
+ return img.resize((W, H), Image.LANCZOS)
83
+
84
+
85
+ def save(path: str) -> None:
86
+ """Render the card and write it to path (used at build time)."""
87
+ build().save(path, optimize=True)
88
+
89
+
90
+ def main() -> None:
91
+ os.makedirs(STATIC_DIR, exist_ok=True)
92
+ out = os.path.join(STATIC_DIR, "og-card.png")
93
+ save(out)
94
+ print("wrote", out, f"({os.path.getsize(out) // 1024} KB)")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
map_template.html CHANGED
@@ -41,17 +41,17 @@
41
  <meta property="og:title" content="Prix des carburants en France — carte en temps réel">
42
  <meta property="og:description" content="Trouvez la station-service la moins chère près de chez vous ou sur votre trajet. Gazole, SP95, SP98, E10, E85, GPL et bornes de recharge, actualisés deux fois par jour.">
43
  <meta property="og:url" content="https://samdnx-prix-carburant.hf.space/">
44
- <meta property="og:image" content="https://samdnx-prix-carburant.hf.space/icon-512.png">
45
  <meta property="og:image:type" content="image/png">
46
- <meta property="og:image:width" content="512">
47
- <meta property="og:image:height" content="512">
48
- <meta property="og:image:alt" content="Carte des prix des carburants en France">
49
  <!-- Twitter / X -->
50
- <meta name="twitter:card" content="summary">
51
  <meta name="twitter:title" content="Prix des carburants en France — carte en temps réel">
52
  <meta name="twitter:description" content="La station-service la moins chère près de chez vous ou sur votre trajet : gazole, SP95, SP98, E85, GPL et bornes de recharge.">
53
- <meta name="twitter:image" content="https://samdnx-prix-carburant.hf.space/icon-512.png">
54
- <meta name="twitter:image:alt" content="Carte des prix des carburants en France">
55
  <!-- Structured data: helps search engines describe the app -->
56
  <script type="application/ld+json">
57
  {
 
41
  <meta property="og:title" content="Prix des carburants en France — carte en temps réel">
42
  <meta property="og:description" content="Trouvez la station-service la moins chère près de chez vous ou sur votre trajet. Gazole, SP95, SP98, E10, E85, GPL et bornes de recharge, actualisés deux fois par jour.">
43
  <meta property="og:url" content="https://samdnx-prix-carburant.hf.space/">
44
+ <meta property="og:image" content="https://samdnx-prix-carburant.hf.space/og-card.png">
45
  <meta property="og:image:type" content="image/png">
46
+ <meta property="og:image:width" content="1200">
47
+ <meta property="og:image:height" content="630">
48
+ <meta property="og:image:alt" content="Prix des carburants en France — carte en temps réel">
49
  <!-- Twitter / X -->
50
+ <meta name="twitter:card" content="summary_large_image">
51
  <meta name="twitter:title" content="Prix des carburants en France — carte en temps réel">
52
  <meta name="twitter:description" content="La station-service la moins chère près de chez vous ou sur votre trajet : gazole, SP95, SP98, E85, GPL et bornes de recharge.">
53
+ <meta name="twitter:image" content="https://samdnx-prix-carburant.hf.space/og-card.png">
54
+ <meta name="twitter:image:alt" content="Prix des carburants en France — carte en temps réel">
55
  <!-- Structured data: helps search engines describe the app -->
56
  <script type="application/ld+json">
57
  {
requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  requests>=2.31
2
  schedule>=1.2
 
 
1
  requests>=2.31
2
  schedule>=1.2
3
+ Pillow>=10.0
static/app.js CHANGED
@@ -16,11 +16,29 @@ const STATS = DATA.stats || {}; // { Gazole: {count, p5, median, p95}, ... }
16
  // Each station: [lat, lon, name, brand, addr, updated, [prices], cp, city, highway]
17
  const STATIONS = DATA.stations || [];
18
  const CARS = DATA.cars || []; // [[label, fuel, L/100km], ...] real ADEME models
 
 
19
  const PRICES = 6, CP = 7, CITY = 8, HW = 9; // station record indices (HW: 1 = autoroute)
20
  const CORRIDOR_KM = 6; // show stations within this distance of a route
21
- // Rough average French autoroute toll for a light vehicle (classe 1), €/km of
22
- // motorway driven. Tolls vary by concessionaire, so this is an estimate only.
23
- const TOLL_EUR_PER_KM = 0.09;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  // Green -> yellow -> red gradient stops (cheap -> expensive).
26
  const STOPS = [
@@ -70,6 +88,10 @@ let routeCoords = null; // L.LatLng[] of the active route, or null
70
  let routeStations = null; // stations near the active route (any fuel)
71
  let routeDistanceKm = null; // length of the active route, km
72
  let routeTollKm = 0; // motorway distance on the route (for toll estimate), km
 
 
 
 
73
  let vehicleMode = "thermique"; // "thermique" | "electrique"
74
  let thermCar = null; // { label, fuel, cons } chosen combustion model
75
  let stopMarkers = []; // recharge-stop markers on the map
@@ -93,6 +115,13 @@ function haversine(la1, lo1, la2, lo2) {
93
  Math.cos(la1 * toR) * Math.cos(la2 * toR) * Math.sin(dLo / 2) ** 2;
94
  return 2 * R * Math.asin(Math.sqrt(a));
95
  }
 
 
 
 
 
 
 
96
  function colorFor(t) {
97
  t = Math.max(0, Math.min(1, t));
98
  for (let i = 1; i < STOPS.length; i++) {
@@ -189,6 +218,9 @@ function currentSelection() {
189
  if (maxPrice != null) {
190
  list = list.filter(s => s[PRICES][currentFuel] <= maxPrice);
191
  }
 
 
 
192
  if (placeQuery) {
193
  const q = placeQuery.trim().toLowerCase();
194
  list = list.filter(s =>
@@ -235,6 +267,7 @@ function render(fit) {
235
  updateLegend(fuel);
236
  updateStatus(list.length, cheapest);
237
  updateCost();
 
238
  if (fit && list.length) {
239
  map.fitBounds(L.latLngBounds(list.map(s => [s[0], s[1]])).pad(0.15), { maxZoom: 14 });
240
  }
@@ -268,6 +301,7 @@ panel.onAdd = function () {
268
  '<input id="place" type="text" placeholder="ex. 75011 ou Lyon" autocomplete="off">' +
269
  '<button id="place-clear" class="btn" title="Effacer">✕</button></div>' +
270
  '<div id="place-hint" class="hint"></div>' +
 
271
  '<h4>Carburant</h4>';
272
  FUELS.forEach((f, i) => {
273
  if (!STATS[f]) return;
@@ -281,13 +315,18 @@ panel.onAdd = function () {
281
  '<div class="slider-row"><span id="slider-out" class="slider-out"></span>' +
282
  '<button id="slider-reset" class="btn-link" type="button">tout afficher</button>' +
283
  '</div></div>';
 
 
284
  body += '<div class="legend-bar"></div><div class="legend-scale">' +
285
  '<span id="lg-lo"></span><span id="lg-mid"></span><span id="lg-hi"></span></div>' +
 
286
  '<div id="ev-legend" class="ev-legend" style="display:none">' +
287
  '<div><span class="sw fast"></span>Rapide (≥ 50 kW)</div>' +
288
  '<div><span class="sw slow"></span>Normale</div>' +
289
  '<div id="ev-count" class="hint"></div></div>' +
290
- '<div id="route-status" class="route-status"></div>';
 
 
291
  div.innerHTML =
292
  '<div class="panel-head"><span>⛽ Carburant</span><span class="p-caret">▾</span></div>' +
293
  `<div class="panel-body">${body}</div>`;
@@ -298,6 +337,7 @@ panel.onAdd = function () {
298
  inp.addEventListener("change", e => {
299
  if (e.target.value === "ev") selectElectric();
300
  else selectFuel(+e.target.value, false);
 
301
  }));
302
 
303
  const slider = div.querySelector("#price-slider");
@@ -306,9 +346,25 @@ panel.onAdd = function () {
306
  maxPrice = (+slider.value >= +slider.max) ? null : +slider.value;
307
  updateSliderOut();
308
  render(false);
 
309
  });
310
  div.querySelector("#slider-reset").addEventListener("click", () => {
311
- maxPrice = null; slider.value = slider.max; updateSliderOut(); render(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  });
313
 
314
  const place = div.querySelector("#place");
@@ -330,6 +386,18 @@ function updateLegend(fuel) {
330
  document.getElementById("lg-lo").textContent = eur(st.p5, 2) + " €";
331
  document.getElementById("lg-mid").textContent = eur(st.median, 2) + " €";
332
  document.getElementById("lg-hi").textContent = eur(st.p95, 2) + " €";
 
 
 
 
 
 
 
 
 
 
 
 
333
  }
334
 
335
  // Rescale the price slider to the current fuel's actual min/max and reset to "all".
@@ -410,21 +478,91 @@ function updateStatus(count, cheapest) {
410
  if (rc) rc.addEventListener("click", clearRoute);
411
  }
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  // ===================== Geocoding + city autocomplete (BAN) =====================
 
414
  async function banSearch(q, limit) {
 
 
415
  const url = `${BAN_URL}?type=municipality&limit=${limit}&q=` + encodeURIComponent(q);
416
  const r = await fetch(url);
417
  if (!r.ok) return [];
418
  const j = await r.json();
419
- return j.features || [];
 
 
420
  }
421
 
422
- // Geocode a typed place name to coordinates (first BAN match).
423
  async function geocodeCity(q) {
 
 
 
424
  const f = await banSearch(q, 1);
425
  if (!f.length) return null;
426
  const c = f[0].geometry.coordinates; // [lon, lat]
427
- return { lat: c[1], lon: c[0] };
 
 
428
  }
429
 
430
  // Attach a city-suggestion dropdown to an input. onPick(coord|null) stores the
@@ -489,32 +627,49 @@ function routeMsg(text) {
489
  if (el) el.textContent = text || "";
490
  }
491
 
492
- // Driving route between two points via the public OSRM server. steps=true so we
493
- // can read each segment's road ref and estimate the tolled-motorway distance.
 
494
  async function routeBetween(a, b) {
495
- const url = `https://router.project-osrm.org/route/v1/driving/` +
496
- `${a.lon},${a.lat};${b.lon},${b.lat}?overview=full&geometries=geojson&steps=true`;
497
- const r = await fetch(url);
498
- if (!r.ok) return null;
499
- const j = await r.json();
500
- if (j.code !== "Ok" || !j.routes || !j.routes.length) return null;
501
- return j.routes[0];
 
 
 
502
  }
503
 
504
- // A step is on a motorway if its road ref looks like "A 6", "A6", "A 7;E 15"
505
- function isAutorouteRef(ref) {
506
- return typeof ref === "string" && /(^|\W)A\s?\d/.test(ref);
 
 
 
 
 
 
 
 
507
  }
508
 
509
- // Total motorway distance (km) on the route, summed from autoroute steps.
510
- function tollKmFromRoute(route) {
511
- let meters = 0;
 
512
  for (const leg of route.legs || []) {
513
  for (const step of leg.steps || []) {
514
- if (isAutorouteRef(step.ref)) meters += step.distance || 0;
 
 
 
 
515
  }
516
  }
517
- return meters / 1000;
518
  }
519
 
520
  function drawRoute(coords, a, b) {
@@ -548,19 +703,22 @@ async function computeRoute() {
548
  if (!b) { routeMsg("« " + arrV + " » introuvable."); return; }
549
  routeMsg("Calcul de l'itinéraire…");
550
  const route = await routeBetween(a, b);
551
- if (!route) { routeMsg("Itinéraire introuvable."); return; }
552
  const coords = route.geometry.coordinates.map((c) => L.latLng(c[1], c[0]));
553
  drawRoute(coords, a, b);
554
  routeCoords = coords;
555
  routeStations = nearRoute(STATIONS, coords); // cache once for filtering + cost
556
  routeDistanceKm = route.distance / 1000;
557
- routeTollKm = tollKmFromRoute(route);
 
 
558
  render(false);
559
  map.fitBounds(L.latLngBounds(coords).pad(0.1));
560
  const km = (route.distance / 1000).toFixed(0);
561
  const mins = Math.round(route.duration / 60), h = Math.floor(mins / 60), m = mins % 60;
562
  routeMsg(`${km} km · ~${h ? h + " h " : ""}${m} min`);
563
  planEvStops(); // place recharge stops if an EV is selected
 
564
  } catch (err) {
565
  console.error(err);
566
  routeMsg("Erreur réseau, réessayez.");
@@ -574,12 +732,14 @@ function clearRoute() {
574
  routeStations = null;
575
  routeDistanceKm = null;
576
  routeTollKm = 0;
 
577
  depCoord = arrCoord = null;
578
  const d = document.getElementById("dep"), a = document.getElementById("arr");
579
  if (d) d.value = "";
580
  if (a) a.value = "";
581
  routeMsg("");
582
  render(false);
 
583
  map.setView([46.6, 2.45], 6);
584
  }
585
 
@@ -592,6 +752,8 @@ routeControl.onAdd = function () {
592
  const evOptions = '<option value="">— Modèle électrique —</option>' +
593
  EVS.map((e, i) => `<option value="${i}">${e.name} (${e.range} km)</option>`).join("") +
594
  '<option value="custom">Autre (saisir l\'autonomie)…</option>';
 
 
595
  div.innerHTML =
596
  '<div class="route-head"><span>🛣️ Itinéraire</span><span id="route-caret">▾</span></div>' +
597
  '<div class="route-body">' +
@@ -604,7 +766,8 @@ routeControl.onAdd = function () {
604
  '<div class="ac-wrap"><input id="car" type="text" placeholder="Modèle (ex. Clio, 308 HDi…)" autocomplete="off"></div></div>' +
605
  '<div id="ev-block" style="display:none">' +
606
  `<select id="ev" class="route-sel">${evOptions}</select>` +
607
- '<input id="ev-range" type="number" min="80" max="800" step="10" placeholder="Autonomie (km)" style="display:none"></div>' +
 
608
  '<div class="route-actions">' +
609
  '<button id="route-go" class="btn">Calculer</button>' +
610
  '<button id="route-reset" class="btn">Effacer</button></div>' +
@@ -618,6 +781,10 @@ routeControl.onAdd = function () {
618
  r.addEventListener("change", onVehicleModeChange));
619
  div.querySelector("#ev").addEventListener("change", onEvChange);
620
  div.querySelector("#ev-range").addEventListener("input", onEvChange);
 
 
 
 
621
  attachCarAutocomplete(div.querySelector("#car"));
622
  attachAutocomplete(div.querySelector("#dep"), (c) => { depCoord = c; });
623
  attachAutocomplete(div.querySelector("#arr"), (c) => { arrCoord = c; });
@@ -634,6 +801,7 @@ function onVehicleModeChange(e) {
634
  if (vehicleMode === "thermique" && thermCar) selectFuel(FUELS.indexOf(thermCar.fuel), false);
635
  updateCost();
636
  planEvStops();
 
637
  }
638
 
639
  // Current EV selection { name, range, kwh }, or null.
@@ -652,6 +820,7 @@ function onEvChange() {
652
  document.getElementById("ev-range").style.display = sel.value === "custom" ? "block" : "none";
653
  updateCost();
654
  planEvStops();
 
655
  }
656
 
657
  // Autocomplete over the embedded ADEME car models ([label, fuel, L/100km]).
@@ -677,6 +846,7 @@ function attachCarAutocomplete(input) {
677
  selectFuel(FUELS.indexOf(c[1]), false);
678
  hide();
679
  updateCost();
 
680
  }
681
  input.addEventListener("input", () => {
682
  thermCar = null;
@@ -707,11 +877,11 @@ function updateCost() {
707
  if (!el) return;
708
  if (!routeDistanceKm) { el.innerHTML = ""; return; }
709
 
710
- const toll = routeTollKm * TOLL_EUR_PER_KM;
711
- const hasToll = routeTollKm > 0.5; // ignore a few hundred m of motorway slip roads
712
  const tollLine = hasToll
713
  ? `<br><span class="hint">🅿️ Péage autoroute : ≈ ${eur(toll, 2)} € ` +
714
- `(estimation, ${eur(routeTollKm, 0)} km)</span>`
715
  : "";
716
  const totalLine = (energy, label) => hasToll
717
  ? `<br>💰 <b>Total : ≈ ${eur(energy + toll, 2)} €</b>` +
@@ -729,10 +899,10 @@ function updateCost() {
729
  const usable = ev.range * 0.8;
730
  const stops = Math.max(0, Math.ceil(routeDistanceKm / usable) - 1);
731
  const kwh = routeDistanceKm / 100 * ev.kwh;
732
- const energy = kwh * 0.40;
733
  el.innerHTML =
734
  `⚡ <b>${stops} recharge${stops > 1 ? "s" : ""}</b> en route · autonomie ${ev.range} km` +
735
- `<br><span class="hint">${eur(kwh, 0)} kWh · ~${eur(energy, 0)} € (≈ 0,40 €/kWh)</span>` +
736
  tollLine + totalLine(energy, "recharge");
737
  return;
738
  }
@@ -783,12 +953,28 @@ function chargerCap() {
783
  if (z <= 10) return 800;
784
  return 400;
785
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  async function loadChargersInView() {
787
  if (!chargersOn) return;
788
  const b = map.getBounds();
 
 
 
 
789
  const where = bboxWhere(b.getSouth(), b.getWest(), b.getNorth(), b.getEast());
790
  const base = `${IRVE_URL}?limit=100&select=${IRVE_SELECT}&where=${encodeURIComponent(where)}`;
791
- const cap = chargerCap();
792
  const myseq = ++loadSeq; // ignore this result if a newer view starts loading
793
  chargerMsg("Chargement des bornes…");
794
  try {
@@ -815,12 +1001,9 @@ async function loadChargersInView() {
815
  const prev = bySpot.get(k);
816
  if (!prev || (c.puissance_nominale || 0) > (prev.puissance_nominale || 0)) bySpot.set(k, c);
817
  }
818
- const ms = [...bySpot.values()].map(chargerMarker);
819
- chargerCluster.clearLayers();
820
- chargerCluster.addLayers(ms);
821
- chargerMsg(!ms.length ? "Aucune borne dans cette zone"
822
- : total > all.length ? `${ms.length} bornes affichées (zoomez pour plus)`
823
- : `${ms.length} borne${ms.length > 1 ? "s" : ""}`);
824
  } catch (e) { chargerMsg("Bornes indisponibles."); }
825
  }
826
  function toggleChargers(on) {
@@ -996,6 +1179,70 @@ function showInstallPrompt() {
996
  });
997
  }
998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999
  // ===================== Init =====================
1000
  // On narrow screens, start with both panels collapsed so the map is clear.
1001
  if (window.matchMedia(MOBILE_MQ).matches) {
@@ -1005,13 +1252,46 @@ if (window.matchMedia(MOBILE_MQ).matches) {
1005
 
1006
  configureSlider();
1007
  render(false);
 
1008
 
1009
  // Give the map a moment to settle before surfacing the install tip.
1010
  setTimeout(showInstallPrompt, 2500);
1011
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
  if ("serviceWorker" in navigator) {
1013
- window.addEventListener("load", () =>
1014
- navigator.serviceWorker.register("/sw.js").catch(() => {}));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
  }
1016
 
1017
  })();
 
16
  // Each station: [lat, lon, name, brand, addr, updated, [prices], cp, city, highway]
17
  const STATIONS = DATA.stations || [];
18
  const CARS = DATA.cars || []; // [[label, fuel, L/100km], ...] real ADEME models
19
+ const TRENDS = DATA.trends || {}; // { fuel: {median, delta} } vs the previous build
20
+ const BRAND = 3; // station record index: brand/enseigne
21
  const PRICES = 6, CP = 7, CITY = 8, HW = 9; // station record indices (HW: 1 = autoroute)
22
  const CORRIDOR_KM = 6; // show stations within this distance of a route
23
+
24
+ // Routing: try a reliable host first, fall back to the public OSRM demo server.
25
+ const ROUTERS = [
26
+ "https://routing.openstreetmap.de/routed-car/route/v1/driving/", // FOSSGIS (stable)
27
+ "https://router.project-osrm.org/route/v1/driving/", // project-osrm demo
28
+ ];
29
+
30
+ // Motorway tolls (light vehicle, classe 1). Approximate €/km on a tolled
31
+ // autoroute; a few roads differ enough to special-case. Estimate only.
32
+ const TOLL_RATE = 0.092;
33
+ const TOLL_SPECIAL = { 14: 0.49 }; // A14: short, premium toll
34
+ const FREE_AUTOROUTES = new Set([20, 75, 84, 88]); // largely toll-free autoroutes
35
+
36
+ // EV charging price options (€/kWh) for the electric cost estimate.
37
+ const EV_PRICES = [
38
+ { v: 0.25, label: "à domicile (~0,25 €/kWh)" },
39
+ { v: 0.40, label: "mixte (~0,40 €/kWh)" },
40
+ { v: 0.59, label: "borne rapide (~0,59 €/kWh)" },
41
+ ];
42
 
43
  // Green -> yellow -> red gradient stops (cheap -> expensive).
44
  const STOPS = [
 
88
  let routeStations = null; // stations near the active route (any fuel)
89
  let routeDistanceKm = null; // length of the active route, km
90
  let routeTollKm = 0; // motorway distance on the route (for toll estimate), km
91
+ let routeTollEuro = 0; // estimated toll for the active route, €
92
+ let evPricePerKwh = 0.40; // electricity price used in the EV cost estimate
93
+ let brandFilter = ""; // selected station brand/enseigne, or "" for all
94
+ let listShown = false; // cheapest-stations list panel visible?
95
  let vehicleMode = "thermique"; // "thermique" | "electrique"
96
  let thermCar = null; // { label, fuel, cons } chosen combustion model
97
  let stopMarkers = []; // recharge-stop markers on the map
 
115
  Math.cos(la1 * toR) * Math.cos(la2 * toR) * Math.sin(dLo / 2) ** 2;
116
  return 2 * R * Math.asin(Math.sqrt(a));
117
  }
118
+ // Tiny localStorage cache (used for geocoding). Safe if storage is unavailable.
119
+ function cacheGet(key) {
120
+ try { return JSON.parse(localStorage.getItem(key)); } catch (e) { return null; }
121
+ }
122
+ function cacheSet(key, val) {
123
+ try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* ignore */ }
124
+ }
125
  function colorFor(t) {
126
  t = Math.max(0, Math.min(1, t));
127
  for (let i = 1; i < STOPS.length; i++) {
 
218
  if (maxPrice != null) {
219
  list = list.filter(s => s[PRICES][currentFuel] <= maxPrice);
220
  }
221
+ if (brandFilter) {
222
+ list = list.filter(s => (s[BRAND] || "").trim() === brandFilter);
223
+ }
224
  if (placeQuery) {
225
  const q = placeQuery.trim().toLowerCase();
226
  list = list.filter(s =>
 
267
  updateLegend(fuel);
268
  updateStatus(list.length, cheapest);
269
  updateCost();
270
+ if (listShown) renderCheapestList(list);
271
  if (fit && list.length) {
272
  map.fitBounds(L.latLngBounds(list.map(s => [s[0], s[1]])).pad(0.15), { maxZoom: 14 });
273
  }
 
301
  '<input id="place" type="text" placeholder="ex. 75011 ou Lyon" autocomplete="off">' +
302
  '<button id="place-clear" class="btn" title="Effacer">✕</button></div>' +
303
  '<div id="place-hint" class="hint"></div>' +
304
+ '<button id="near-me" class="btn near-me" type="button">📍 Stations près de moi</button>' +
305
  '<h4>Carburant</h4>';
306
  FUELS.forEach((f, i) => {
307
  if (!STATS[f]) return;
 
315
  '<div class="slider-row"><span id="slider-out" class="slider-out"></span>' +
316
  '<button id="slider-reset" class="btn-link" type="button">tout afficher</button>' +
317
  '</div></div>';
318
+ body += '<div id="brand-filter"><h4>Enseigne</h4>' +
319
+ '<select id="brand" class="route-sel">' + brandOptions() + '</select></div>';
320
  body += '<div class="legend-bar"></div><div class="legend-scale">' +
321
  '<span id="lg-lo"></span><span id="lg-mid"></span><span id="lg-hi"></span></div>' +
322
+ '<div id="lg-trend" class="lg-trend"></div>' +
323
  '<div id="ev-legend" class="ev-legend" style="display:none">' +
324
  '<div><span class="sw fast"></span>Rapide (≥ 50 kW)</div>' +
325
  '<div><span class="sw slow"></span>Normale</div>' +
326
  '<div id="ev-count" class="hint"></div></div>' +
327
+ '<div id="route-status" class="route-status"></div>' +
328
+ '<button id="list-toggle" class="btn-link" type="button">Voir les moins chères ▾</button>' +
329
+ '<div id="cheapest-list" class="cheapest-list" style="display:none"></div>';
330
  div.innerHTML =
331
  '<div class="panel-head"><span>⛽ Carburant</span><span class="p-caret">▾</span></div>' +
332
  `<div class="panel-body">${body}</div>`;
 
337
  inp.addEventListener("change", e => {
338
  if (e.target.value === "ev") selectElectric();
339
  else selectFuel(+e.target.value, false);
340
+ writeState();
341
  }));
342
 
343
  const slider = div.querySelector("#price-slider");
 
346
  maxPrice = (+slider.value >= +slider.max) ? null : +slider.value;
347
  updateSliderOut();
348
  render(false);
349
+ writeState();
350
  });
351
  div.querySelector("#slider-reset").addEventListener("click", () => {
352
+ maxPrice = null; slider.value = slider.max; updateSliderOut(); render(false); writeState();
353
+ });
354
+
355
+ div.querySelector("#brand").addEventListener("change", (e) => {
356
+ brandFilter = e.target.value; render(false); writeState();
357
+ });
358
+ div.querySelector("#near-me").addEventListener("click", nearMe);
359
+ div.querySelector("#list-toggle").addEventListener("click", toggleList);
360
+ div.querySelector("#cheapest-list").addEventListener("click", (e) => {
361
+ const it = e.target.closest(".cl-item");
362
+ if (!it) return;
363
+ const lat = +it.dataset.lat, lon = +it.dataset.lon;
364
+ map.setView([lat, lon], Math.max(map.getZoom(), 14));
365
+ const s = STATIONS.find(x => x[0] === lat && x[1] === lon);
366
+ if (s) L.popup({ maxWidth: 300 }).setLatLng([lat, lon])
367
+ .setContent(popupHtml(s, currentFuel)).openOn(map);
368
  });
369
 
370
  const place = div.querySelector("#place");
 
386
  document.getElementById("lg-lo").textContent = eur(st.p5, 2) + " €";
387
  document.getElementById("lg-mid").textContent = eur(st.median, 2) + " €";
388
  document.getElementById("lg-hi").textContent = eur(st.p95, 2) + " €";
389
+ const el = document.getElementById("lg-trend");
390
+ if (!el) return;
391
+ const tr = TRENDS[fuel];
392
+ if (tr && Math.abs(tr.delta) >= 0.001) {
393
+ const up = tr.delta > 0; // a price rise is "bad" (red), a drop is "good" (green)
394
+ el.innerHTML = `médiane ${eur(tr.median, 3)} € · <span class="${up ? "up" : "down"}">` +
395
+ `${up ? "▲" : "▼"} ${eur(Math.abs(tr.delta), 3)} €</span> <span class="hint">vs dernier relevé</span>`;
396
+ } else if (tr) {
397
+ el.innerHTML = `médiane ${eur(tr.median, 3)} € · <span class="hint">stable</span>`;
398
+ } else {
399
+ el.innerHTML = "";
400
+ }
401
  }
402
 
403
  // Rescale the price slider to the current fuel's actual min/max and reset to "all".
 
478
  if (rc) rc.addEventListener("click", clearRoute);
479
  }
480
 
481
+ // ===================== Brand filter · cheapest list · near me =====================
482
+ // Distinct station brands (enseignes) with counts, most common first.
483
+ function brandOptions() {
484
+ const counts = new Map();
485
+ for (const s of STATIONS) {
486
+ const b = (s[BRAND] || "").trim();
487
+ if (b) counts.set(b, (counts.get(b) || 0) + 1);
488
+ }
489
+ const arr = [...counts.entries()].sort((a, b) => b[1] - a[1]);
490
+ return '<option value="">Toutes les enseignes</option>' +
491
+ arr.map(([b, n]) => `<option value="${esc(b)}">${esc(b)} (${n})</option>`).join("");
492
+ }
493
+
494
+ // Fill the "cheapest stations" list (top 12 of the current selection).
495
+ function renderCheapestList(list) {
496
+ const box = document.getElementById("cheapest-list");
497
+ if (!box) return;
498
+ const top = (list || currentSelection()).slice()
499
+ .sort((a, b) => a[PRICES][currentFuel] - b[PRICES][currentFuel]).slice(0, 12);
500
+ box.innerHTML = top.length
501
+ ? top.map(s =>
502
+ `<div class="cl-item" data-lat="${s[0]}" data-lon="${s[1]}">` +
503
+ `<span class="cl-price">${eur(s[PRICES][currentFuel], 3)} €</span>` +
504
+ `<span class="cl-name">${esc(s[2])}<small>${esc(s[CITY] || "")}</small></span></div>`).join("")
505
+ : '<div class="hint">Aucune station.</div>';
506
+ }
507
+
508
+ function toggleList() {
509
+ listShown = !listShown;
510
+ const box = document.getElementById("cheapest-list");
511
+ const btn = document.getElementById("list-toggle");
512
+ if (box) box.style.display = listShown ? "block" : "none";
513
+ if (btn) btn.textContent = listShown ? "Masquer la liste ▴" : "Voir les moins chères ▾";
514
+ if (listShown) renderCheapestList();
515
+ }
516
+
517
+ // One-shot geolocation: centre on the user and open the cheapest nearby station.
518
+ function nearMe() {
519
+ if (!navigator.geolocation) { alert("Géolocalisation non disponible."); return; }
520
+ const btn = document.getElementById("near-me");
521
+ const reset = () => { if (btn) btn.textContent = "📍 Stations près de moi"; };
522
+ if (btn) btn.textContent = "Localisation��";
523
+ navigator.geolocation.getCurrentPosition((pos) => {
524
+ reset();
525
+ const lat = pos.coords.latitude, lon = pos.coords.longitude;
526
+ map.setView([lat, lon], 12);
527
+ let best = null, bd = Infinity;
528
+ for (const s of STATIONS) {
529
+ if (s[PRICES][currentFuel] == null) continue;
530
+ const d = haversine(lat, lon, s[0], s[1]);
531
+ if (d <= 25 && d < bd) { bd = d; best = s; }
532
+ }
533
+ if (best) L.popup({ maxWidth: 300 }).setLatLng([best[0], best[1]])
534
+ .setContent(popupHtml(best, currentFuel)).openOn(map);
535
+ }, () => {
536
+ reset();
537
+ alert("Localisation indisponible (autorisez l'accès, lien https requis).");
538
+ }, { enableHighAccuracy: true, timeout: 8000 });
539
+ }
540
+
541
  // ===================== Geocoding + city autocomplete (BAN) =====================
542
+ const banMemo = new Map(); // session cache for autocomplete queries
543
  async function banSearch(q, limit) {
544
+ const key = limit + ":" + q.trim().toLowerCase();
545
+ if (banMemo.has(key)) return banMemo.get(key);
546
  const url = `${BAN_URL}?type=municipality&limit=${limit}&q=` + encodeURIComponent(q);
547
  const r = await fetch(url);
548
  if (!r.ok) return [];
549
  const j = await r.json();
550
+ const feats = j.features || [];
551
+ banMemo.set(key, feats);
552
+ return feats;
553
  }
554
 
555
+ // Geocode a typed place name to coordinates (first BAN match), cached on disk.
556
  async function geocodeCity(q) {
557
+ const key = "geo:" + q.trim().toLowerCase();
558
+ const hit = cacheGet(key);
559
+ if (hit) return hit;
560
  const f = await banSearch(q, 1);
561
  if (!f.length) return null;
562
  const c = f[0].geometry.coordinates; // [lon, lat]
563
+ const coord = { lat: c[1], lon: c[0] };
564
+ cacheSet(key, coord);
565
+ return coord;
566
  }
567
 
568
  // Attach a city-suggestion dropdown to an input. onPick(coord|null) stores the
 
627
  if (el) el.textContent = text || "";
628
  }
629
 
630
+ // Driving route between two points. Tries each OSRM host in turn so a single
631
+ // provider being down or rate-limited doesn't break itineraries. steps=true so
632
+ // we can read each segment's road ref and estimate the tolled-motorway distance.
633
  async function routeBetween(a, b) {
634
+ const tail = `${a.lon},${a.lat};${b.lon},${b.lat}?overview=full&geometries=geojson&steps=true`;
635
+ for (const base of ROUTERS) {
636
+ try {
637
+ const r = await fetch(base + tail);
638
+ if (!r.ok) continue;
639
+ const j = await r.json();
640
+ if (j.code === "Ok" && j.routes && j.routes.length) return j.routes[0];
641
+ } catch (e) { /* host unreachable — try the next one */ }
642
+ }
643
+ return null;
644
  }
645
 
646
+ // Autoroute number from a step ref ("A 6" -> 6, "A 7;E 15" -> 7), or null.
647
+ // A trailing letter ("A 6a", "A 6b") marks an urban link that is normally free,
648
+ // so those are excluded from the toll.
649
+ function autorouteNumber(ref) {
650
+ const m = /(?:^|[^A-Za-z])A\s?(\d+)(?![\da-z])/i.exec(ref || "");
651
+ return m ? parseInt(m[1], 10) : null;
652
+ }
653
+ // €/km toll for a given autoroute number (0 for the mostly-free ones).
654
+ function tollRateFor(n) {
655
+ if (FREE_AUTOROUTES.has(n)) return 0;
656
+ return TOLL_SPECIAL[n] || TOLL_RATE;
657
  }
658
 
659
+ // Estimated toll for the route: { km on autoroutes, euro }. Sums each motorway
660
+ // step's length times that road's rate, so free sections don't inflate the cost.
661
+ function tollFromRoute(route) {
662
+ let km = 0, euro = 0;
663
  for (const leg of route.legs || []) {
664
  for (const step of leg.steps || []) {
665
+ const n = autorouteNumber(step.ref);
666
+ if (n == null) continue;
667
+ const d = (step.distance || 0) / 1000;
668
+ km += d;
669
+ euro += d * tollRateFor(n);
670
  }
671
  }
672
+ return { km, euro };
673
  }
674
 
675
  function drawRoute(coords, a, b) {
 
703
  if (!b) { routeMsg("« " + arrV + " » introuvable."); return; }
704
  routeMsg("Calcul de l'itinéraire…");
705
  const route = await routeBetween(a, b);
706
+ if (!route) { routeMsg("Itinéraire indisponible (service de routage occupé). Réessayez."); return; }
707
  const coords = route.geometry.coordinates.map((c) => L.latLng(c[1], c[0]));
708
  drawRoute(coords, a, b);
709
  routeCoords = coords;
710
  routeStations = nearRoute(STATIONS, coords); // cache once for filtering + cost
711
  routeDistanceKm = route.distance / 1000;
712
+ const t = tollFromRoute(route);
713
+ routeTollKm = t.km;
714
+ routeTollEuro = t.euro;
715
  render(false);
716
  map.fitBounds(L.latLngBounds(coords).pad(0.1));
717
  const km = (route.distance / 1000).toFixed(0);
718
  const mins = Math.round(route.duration / 60), h = Math.floor(mins / 60), m = mins % 60;
719
  routeMsg(`${km} km · ~${h ? h + " h " : ""}${m} min`);
720
  planEvStops(); // place recharge stops if an EV is selected
721
+ writeState();
722
  } catch (err) {
723
  console.error(err);
724
  routeMsg("Erreur réseau, réessayez.");
 
732
  routeStations = null;
733
  routeDistanceKm = null;
734
  routeTollKm = 0;
735
+ routeTollEuro = 0;
736
  depCoord = arrCoord = null;
737
  const d = document.getElementById("dep"), a = document.getElementById("arr");
738
  if (d) d.value = "";
739
  if (a) a.value = "";
740
  routeMsg("");
741
  render(false);
742
+ writeState();
743
  map.setView([46.6, 2.45], 6);
744
  }
745
 
 
752
  const evOptions = '<option value="">— Modèle électrique —</option>' +
753
  EVS.map((e, i) => `<option value="${i}">${e.name} (${e.range} km)</option>`).join("") +
754
  '<option value="custom">Autre (saisir l\'autonomie)…</option>';
755
+ const evPriceOptions = EV_PRICES.map((p) =>
756
+ `<option value="${p.v}" ${p.v === 0.40 ? "selected" : ""}>Recharge ${p.label}</option>`).join("");
757
  div.innerHTML =
758
  '<div class="route-head"><span>🛣️ Itinéraire</span><span id="route-caret">▾</span></div>' +
759
  '<div class="route-body">' +
 
766
  '<div class="ac-wrap"><input id="car" type="text" placeholder="Modèle (ex. Clio, 308 HDi…)" autocomplete="off"></div></div>' +
767
  '<div id="ev-block" style="display:none">' +
768
  `<select id="ev" class="route-sel">${evOptions}</select>` +
769
+ '<input id="ev-range" type="number" min="80" max="800" step="10" placeholder="Autonomie (km)" style="display:none">' +
770
+ `<select id="ev-price" class="route-sel">${evPriceOptions}</select></div>` +
771
  '<div class="route-actions">' +
772
  '<button id="route-go" class="btn">Calculer</button>' +
773
  '<button id="route-reset" class="btn">Effacer</button></div>' +
 
781
  r.addEventListener("change", onVehicleModeChange));
782
  div.querySelector("#ev").addEventListener("change", onEvChange);
783
  div.querySelector("#ev-range").addEventListener("input", onEvChange);
784
+ div.querySelector("#ev-price").addEventListener("change", (e) => {
785
+ evPricePerKwh = parseFloat(e.target.value) || 0.40;
786
+ updateCost(); writeState();
787
+ });
788
  attachCarAutocomplete(div.querySelector("#car"));
789
  attachAutocomplete(div.querySelector("#dep"), (c) => { depCoord = c; });
790
  attachAutocomplete(div.querySelector("#arr"), (c) => { arrCoord = c; });
 
801
  if (vehicleMode === "thermique" && thermCar) selectFuel(FUELS.indexOf(thermCar.fuel), false);
802
  updateCost();
803
  planEvStops();
804
+ writeState();
805
  }
806
 
807
  // Current EV selection { name, range, kwh }, or null.
 
820
  document.getElementById("ev-range").style.display = sel.value === "custom" ? "block" : "none";
821
  updateCost();
822
  planEvStops();
823
+ writeState();
824
  }
825
 
826
  // Autocomplete over the embedded ADEME car models ([label, fuel, L/100km]).
 
846
  selectFuel(FUELS.indexOf(c[1]), false);
847
  hide();
848
  updateCost();
849
+ writeState();
850
  }
851
  input.addEventListener("input", () => {
852
  thermCar = null;
 
877
  if (!el) return;
878
  if (!routeDistanceKm) { el.innerHTML = ""; return; }
879
 
880
+ const toll = routeTollEuro;
881
+ const hasToll = toll > 0.5; // only show a meaningful toll
882
  const tollLine = hasToll
883
  ? `<br><span class="hint">🅿️ Péage autoroute : ≈ ${eur(toll, 2)} € ` +
884
+ `(estimation, ${eur(routeTollKm, 0)} km d'autoroute)</span>`
885
  : "";
886
  const totalLine = (energy, label) => hasToll
887
  ? `<br>💰 <b>Total : ≈ ${eur(energy + toll, 2)} €</b>` +
 
899
  const usable = ev.range * 0.8;
900
  const stops = Math.max(0, Math.ceil(routeDistanceKm / usable) - 1);
901
  const kwh = routeDistanceKm / 100 * ev.kwh;
902
+ const energy = kwh * evPricePerKwh;
903
  el.innerHTML =
904
  `⚡ <b>${stops} recharge${stops > 1 ? "s" : ""}</b> en route · autonomie ${ev.range} km` +
905
+ `<br><span class="hint">${eur(kwh, 0)} kWh · ~${eur(energy, 0)} € (≈ ${eur(evPricePerKwh, 2)} €/kWh)</span>` +
906
  tollLine + totalLine(energy, "recharge");
907
  return;
908
  }
 
953
  if (z <= 10) return 800;
954
  return 400;
955
  }
956
+ const chargerMemo = new Map(); // session cache: rounded view -> {objs, total, fetched}
957
+ function viewKey(b, cap) {
958
+ const r = (x) => Math.round(x * 10) / 10; // ~0.1° buckets
959
+ return [r(b.getSouth()), r(b.getWest()), r(b.getNorth()), r(b.getEast()), cap].join(",");
960
+ }
961
+ function paintChargers(objs, total, fetched) {
962
+ const ms = objs.map(chargerMarker);
963
+ chargerCluster.clearLayers();
964
+ chargerCluster.addLayers(ms);
965
+ chargerMsg(!ms.length ? "Aucune borne dans cette zone"
966
+ : total > fetched ? `${ms.length} bornes affichées (zoomez pour plus)`
967
+ : `${ms.length} borne${ms.length > 1 ? "s" : ""}`);
968
+ }
969
  async function loadChargersInView() {
970
  if (!chargersOn) return;
971
  const b = map.getBounds();
972
+ const cap = chargerCap();
973
+ const key = viewKey(b, cap);
974
+ const cached = chargerMemo.get(key);
975
+ if (cached) { paintChargers(cached.objs, cached.total, cached.fetched); return; }
976
  const where = bboxWhere(b.getSouth(), b.getWest(), b.getNorth(), b.getEast());
977
  const base = `${IRVE_URL}?limit=100&select=${IRVE_SELECT}&where=${encodeURIComponent(where)}`;
 
978
  const myseq = ++loadSeq; // ignore this result if a newer view starts loading
979
  chargerMsg("Chargement des bornes…");
980
  try {
 
1001
  const prev = bySpot.get(k);
1002
  if (!prev || (c.puissance_nominale || 0) > (prev.puissance_nominale || 0)) bySpot.set(k, c);
1003
  }
1004
+ const objs = [...bySpot.values()];
1005
+ chargerMemo.set(key, { objs, total, fetched: all.length });
1006
+ paintChargers(objs, total, all.length);
 
 
 
1007
  } catch (e) { chargerMsg("Bornes indisponibles."); }
1008
  }
1009
  function toggleChargers(on) {
 
1179
  });
1180
  }
1181
 
1182
+ // ===================== Shareable URL state =====================
1183
+ // Encode the current selection in the URL hash so links/PWA restore the view.
1184
+ function writeState() {
1185
+ const p = new URLSearchParams();
1186
+ p.set("f", electricView ? "ev" : String(currentFuel));
1187
+ if (brandFilter) p.set("b", brandFilter);
1188
+ if (maxPrice != null) p.set("pmax", String(maxPrice));
1189
+ const val = (id) => (document.getElementById(id) || {}).value || "";
1190
+ if (val("dep")) p.set("dep", val("dep"));
1191
+ if (val("arr")) p.set("arr", val("arr"));
1192
+ if (vehicleMode === "electrique") {
1193
+ p.set("mode", "e");
1194
+ if (val("ev")) p.set("ev", val("ev"));
1195
+ if (val("ev") === "custom" && val("ev-range")) p.set("evr", val("ev-range"));
1196
+ p.set("kwh", String(evPricePerKwh));
1197
+ } else if (thermCar) {
1198
+ p.set("car", thermCar.label);
1199
+ }
1200
+ const str = p.toString();
1201
+ try { history.replaceState(null, "", str ? "#" + str : location.pathname + location.search); }
1202
+ catch (e) { /* ignore */ }
1203
+ }
1204
+
1205
+ // Restore state from the URL hash on load (best-effort; ignores unknown values).
1206
+ function restoreState() {
1207
+ const hash = location.hash.replace(/^#/, "");
1208
+ if (!hash) return;
1209
+ const p = new URLSearchParams(hash);
1210
+ const f = p.get("f");
1211
+ if (f === "ev") selectElectric();
1212
+ else if (f) selectFuel(+f, false);
1213
+
1214
+ if (p.get("mode") === "e") {
1215
+ const radio = document.querySelector('input[name="vmode"][value="electrique"]');
1216
+ if (radio) { radio.checked = true; onVehicleModeChange({ target: radio }); }
1217
+ const ev = document.getElementById("ev");
1218
+ if (ev && p.get("ev") != null) { ev.value = p.get("ev"); onEvChange(); }
1219
+ if (p.get("evr")) { const r = document.getElementById("ev-range"); if (r) { r.value = p.get("evr"); onEvChange(); } }
1220
+ if (p.get("kwh")) {
1221
+ evPricePerKwh = parseFloat(p.get("kwh")) || 0.40;
1222
+ const ps = document.getElementById("ev-price"); if (ps) ps.value = p.get("kwh");
1223
+ }
1224
+ } else if (p.get("car")) {
1225
+ const input = document.getElementById("car");
1226
+ if (input) input.value = p.get("car");
1227
+ const m = CARS.find(c => c[0] === p.get("car"));
1228
+ if (m) { thermCar = { label: m[0], fuel: m[1], cons: m[2] }; selectFuel(FUELS.indexOf(m[1]), false); }
1229
+ }
1230
+
1231
+ if (p.get("b")) {
1232
+ brandFilter = p.get("b");
1233
+ const sel = document.getElementById("brand"); if (sel) sel.value = brandFilter;
1234
+ }
1235
+ if (p.get("pmax") != null) { // after fuel/car selection (those reset the slider)
1236
+ maxPrice = parseFloat(p.get("pmax"));
1237
+ const slider = document.getElementById("price-slider"); if (slider) slider.value = maxPrice;
1238
+ updateSliderOut();
1239
+ }
1240
+ if (p.get("dep")) { const d = document.getElementById("dep"); if (d) d.value = p.get("dep"); }
1241
+ if (p.get("arr")) { const a = document.getElementById("arr"); if (a) a.value = p.get("arr"); }
1242
+ render(false);
1243
+ if (p.get("dep") && p.get("arr")) computeRoute();
1244
+ }
1245
+
1246
  // ===================== Init =====================
1247
  // On narrow screens, start with both panels collapsed so the map is clear.
1248
  if (window.matchMedia(MOBILE_MQ).matches) {
 
1252
 
1253
  configureSlider();
1254
  render(false);
1255
+ restoreState(); // apply any shared/saved view from the URL hash
1256
 
1257
  // Give the map a moment to settle before surfacing the install tip.
1258
  setTimeout(showInstallPrompt, 2500);
1259
 
1260
+ // "Nouvelle version" toast: when a new service worker is waiting, let the user
1261
+ // apply it on demand instead of silently serving stale assets.
1262
+ function showUpdatePrompt(reg) {
1263
+ if (document.querySelector(".update-prompt")) return;
1264
+ const el = document.createElement("div");
1265
+ el.className = "update-prompt";
1266
+ el.innerHTML = '<span>🔄 Nouvelle version disponible</span>' +
1267
+ '<button class="update-btn" type="button">Recharger</button>';
1268
+ document.body.appendChild(el);
1269
+ el.querySelector(".update-btn").addEventListener("click", () => {
1270
+ if (reg.waiting) reg.waiting.postMessage({ type: "SKIP_WAITING" });
1271
+ el.remove();
1272
+ });
1273
+ }
1274
+
1275
  if ("serviceWorker" in navigator) {
1276
+ window.addEventListener("load", async () => {
1277
+ try {
1278
+ const reg = await navigator.serviceWorker.register("/sw.js");
1279
+ if (reg.waiting) showUpdatePrompt(reg);
1280
+ reg.addEventListener("updatefound", () => {
1281
+ const sw = reg.installing;
1282
+ if (!sw) return;
1283
+ sw.addEventListener("statechange", () => {
1284
+ if (sw.state === "installed" && navigator.serviceWorker.controller) showUpdatePrompt(reg);
1285
+ });
1286
+ });
1287
+ } catch (e) { /* registration failed — app still works online */ }
1288
+ });
1289
+ let reloaded = false;
1290
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
1291
+ if (reloaded) return;
1292
+ reloaded = true;
1293
+ location.reload();
1294
+ });
1295
  }
1296
 
1297
  })();
static/styles.css CHANGED
@@ -115,6 +115,26 @@ html, body { height: 100%; margin: 0; font-family: -apple-system, "Segoe UI", Ro
115
  color: #1a73e8; font-size: 11px; text-decoration: underline;
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  /* Price legend */
119
  .legend-bar {
120
  height: 12px; border-radius: 3px; margin: 8px 0 3px;
@@ -173,6 +193,19 @@ html, body { height: 100%; margin: 0; font-family: -apple-system, "Segoe UI", Ro
173
  cursor: pointer; padding: 0 2px; line-height: 1;
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  /* Visible only when JavaScript is disabled (also useful for crawlers) */
177
  .noscript {
178
  position: absolute; inset: 0; z-index: 3000; overflow: auto;
@@ -208,3 +241,21 @@ html, body { height: 100%; margin: 0; font-family: -apple-system, "Segoe UI", Ro
208
  .panel label { padding: 4px 0; }
209
  .leaflet-touch .leaflet-bar a { width: 34px; height: 34px; line-height: 34px; }
210
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  color: #1a73e8; font-size: 11px; text-decoration: underline;
116
  }
117
 
118
+ /* "Près de moi" button + brand filter */
119
+ .btn.near-me { width: 100%; margin: 2px 0 4px; }
120
+ #brand-filter { margin-top: 4px; }
121
+
122
+ /* Median price trend vs the previous build */
123
+ .lg-trend { font-size: 12px; margin: 4px 0 2px; }
124
+ .lg-trend .up { color: #d73027; font-weight: 600; } /* price rose */
125
+ .lg-trend .down { color: #1a7a40; font-weight: 600; } /* price fell */
126
+
127
+ /* Cheapest-stations list */
128
+ .cheapest-list { margin-top: 6px; max-height: 240px; overflow-y: auto; }
129
+ .cl-item {
130
+ display: flex; align-items: baseline; gap: 8px; padding: 4px 2px;
131
+ border-top: 1px solid #eee; cursor: pointer;
132
+ }
133
+ .cl-item:hover { background: #eef4ff; }
134
+ .cl-price { color: #1a7a40; font-weight: 600; white-space: nowrap; }
135
+ .cl-name { line-height: 1.2; }
136
+ .cl-name small { display: block; color: #999; font-size: 11px; }
137
+
138
  /* Price legend */
139
  .legend-bar {
140
  height: 12px; border-radius: 3px; margin: 8px 0 3px;
 
193
  cursor: pointer; padding: 0 2px; line-height: 1;
194
  }
195
 
196
+ /* "New version available" toast */
197
+ .update-prompt {
198
+ position: fixed; left: 50%; transform: translateX(-50%);
199
+ top: calc(8px + env(safe-area-inset-top)); z-index: 2500;
200
+ display: flex; align-items: center; gap: 10px;
201
+ background: #1a73e8; color: #fff; border-radius: 10px;
202
+ padding: 8px 12px; font-size: 13px; box-shadow: 0 3px 14px rgba(0,0,0,.3);
203
+ }
204
+ .update-btn {
205
+ border: 0; background: #fff; color: #1a73e8; border-radius: 7px;
206
+ padding: 5px 10px; font-size: 13px; font-weight: 600; cursor: pointer;
207
+ }
208
+
209
  /* Visible only when JavaScript is disabled (also useful for crawlers) */
210
  .noscript {
211
  position: absolute; inset: 0; z-index: 3000; overflow: auto;
 
241
  .panel label { padding: 4px 0; }
242
  .leaflet-touch .leaflet-bar a { width: 34px; height: 34px; line-height: 34px; }
243
  }
244
+
245
+ /* Dark mode (follows the OS setting). Panels darken; the light IGN basemap is
246
+ * inverted to a dark map. Popups stay light for readability. */
247
+ @media (prefers-color-scheme: dark) {
248
+ .banner, .panel, .route-box, .ac-list, .install-prompt {
249
+ background: rgba(28,30,34,.97); color: #e8eaed;
250
+ }
251
+ .banner small, .panel .count, .panel .hint, .cl-name small, .ac-list .ctx { color: #9aa0a6; }
252
+ .panel h4 { color: #b6bcc4; }
253
+ .search-row input, .route-box input, .route-sel, #place {
254
+ background: #2a2d31; color: #e8eaed; border-color: #4a4d51;
255
+ }
256
+ .btn { background: #34373b; color: #e8eaed; border-color: #4a4d51; }
257
+ .btn:hover { background: #3e4146; }
258
+ .ac-list .item.active, .ac-list .item:hover, .cl-item:hover { background: #2d3340; }
259
+ .cl-item { border-top-color: #34373b; }
260
+ .leaflet-tile { filter: invert(1) hue-rotate(180deg) brightness(.92) contrast(.95); }
261
+ }
static/sw.js CHANGED
@@ -4,7 +4,7 @@
4
  * - The app shell, icons and CDN libraries are cached so the app boots offline.
5
  * - Map tiles are cached with a bounded LRU-ish cap for offline revisits.
6
  */
7
- const VERSION = "v11";
8
  const SHELL = "shell-" + VERSION;
9
  const RUNTIME = "runtime-" + VERSION;
10
  const TILES = "tiles-" + VERSION;
@@ -16,9 +16,13 @@ const APP_SHELL = [
16
  ];
17
 
18
  self.addEventListener("install", (event) => {
19
- event.waitUntil(
20
- caches.open(SHELL).then((c) => c.addAll(APP_SHELL)).then(() => self.skipWaiting())
21
- );
 
 
 
 
22
  });
23
 
24
  self.addEventListener("activate", (event) => {
 
4
  * - The app shell, icons and CDN libraries are cached so the app boots offline.
5
  * - Map tiles are cached with a bounded LRU-ish cap for offline revisits.
6
  */
7
+ const VERSION = "v12";
8
  const SHELL = "shell-" + VERSION;
9
  const RUNTIME = "runtime-" + VERSION;
10
  const TILES = "tiles-" + VERSION;
 
16
  ];
17
 
18
  self.addEventListener("install", (event) => {
19
+ // Pre-cache the shell but don't auto-activate: the page shows an update prompt
20
+ // and tells us to skip waiting, so prices/assets never change under the user.
21
+ event.waitUntil(caches.open(SHELL).then((c) => c.addAll(APP_SHELL)));
22
+ });
23
+
24
+ self.addEventListener("message", (event) => {
25
+ if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
26
  });
27
 
28
  self.addEventListener("activate", (event) => {