Spaces:
Sleeping
Reliability + accuracy + features: routing fallback, tolls, EV price, filters, trends, dark mode, URL state, social card
Browse filesReliability:
- 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>
- .gitignore +2 -0
- Dockerfile +5 -0
- build_map.py +43 -1
- generate_og.py +98 -0
- map_template.html +7 -7
- requirements.txt +1 -0
- static/app.js +322 -42
- static/styles.css +51 -0
- static/sw.js +8 -4
|
@@ -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
|
|
@@ -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
|
|
@@ -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":
|
| 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:
|
|
@@ -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()
|
|
@@ -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/
|
| 45 |
<meta property="og:image:type" content="image/png">
|
| 46 |
-
<meta property="og:image:width" content="
|
| 47 |
-
<meta property="og:image:height" content="
|
| 48 |
-
<meta property="og:image:alt" content="
|
| 49 |
<!-- Twitter / X -->
|
| 50 |
-
<meta name="twitter:card" content="
|
| 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/
|
| 54 |
-
<meta name="twitter:image:alt" content="
|
| 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 |
{
|
|
@@ -1,2 +1,3 @@
|
|
| 1 |
requests>=2.31
|
| 2 |
schedule>=1.2
|
|
|
|
|
|
| 1 |
requests>=2.31
|
| 2 |
schedule>=1.2
|
| 3 |
+
Pillow>=10.0
|
|
@@ -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 |
-
|
| 22 |
-
//
|
| 23 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 493 |
-
//
|
|
|
|
| 494 |
async function routeBetween(a, b) {
|
| 495 |
-
const
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
| 502 |
}
|
| 503 |
|
| 504 |
-
//
|
| 505 |
-
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
}
|
| 508 |
|
| 509 |
-
//
|
| 510 |
-
|
| 511 |
-
|
|
|
|
| 512 |
for (const leg of route.legs || []) {
|
| 513 |
for (const step of leg.steps || []) {
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
}
|
| 516 |
}
|
| 517 |
-
return
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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">
|
|
|
|
| 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 =
|
| 711 |
-
const hasToll =
|
| 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 *
|
| 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)} € (≈
|
| 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
|
| 819 |
-
|
| 820 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
})();
|
|
@@ -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 |
+
}
|
|
@@ -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 = "
|
| 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 |
-
|
| 20 |
-
|
| 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) => {
|