Spaces:
Sleeping
Sleeping
Deploy petrol map: interactive fuel-price PWA + Docker for HF Spaces
Browse files- .dockerignore +8 -0
- .gitignore +7 -0
- Dockerfile +20 -0
- README.md +91 -5
- app.py +80 -0
- build_map.py +128 -0
- fuel_data.py +88 -0
- generate_icons.py +80 -0
- map_template.html +195 -0
- requirements.txt +2 -0
- scheduler.py +50 -0
- static/apple-touch-icon.png +0 -0
- static/icon-192.png +0 -0
- static/icon-512.png +0 -0
- static/icon-maskable-512.png +0 -0
- static/manifest.webmanifest +18 -0
- static/sw.js +98 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
output/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.git/
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
.DS_Store
|
| 8 |
+
*.log
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
output/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
.DS_Store
|
| 7 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 7 |
+
ENV HOME=/home/user \
|
| 8 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 9 |
+
PORT=7860 \
|
| 10 |
+
TZ=Europe/Paris
|
| 11 |
+
|
| 12 |
+
WORKDIR $HOME/app
|
| 13 |
+
|
| 14 |
+
COPY --chown=user requirements.txt .
|
| 15 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY --chown=user . .
|
| 18 |
+
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,96 @@
|
|
| 1 |
---
|
| 2 |
-
title: Prix
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Prix des Carburants France
|
| 3 |
+
emoji: ⛽
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# ⛽ Petrol Map — live French fuel prices (PWA)
|
| 13 |
+
|
| 14 |
+
An installable, mobile-friendly map of every petrol station in France,
|
| 15 |
+
colour-coded by price and refreshed twice a day from the official open-data feed.
|
| 16 |
+
|
| 17 |
+
**Data source:** [`prix_des_carburants_j_7`](https://public.opendatasoft.com/explore/dataset/prix_des_carburants_j_7)
|
| 18 |
+
on opendatasoft (the last 7 days of prices reported by ~9 800 stations).
|
| 19 |
+
|
| 20 |
+
## Features
|
| 21 |
+
|
| 22 |
+
- **Installable PWA** — add it to your phone's home screen and launch it like a
|
| 23 |
+
native app; works offline (shows the last cached prices) via a service worker.
|
| 24 |
+
- **Responsive** — full-screen map with touch-friendly controls and safe-area
|
| 25 |
+
handling for notched phones.
|
| 26 |
+
- **One layer per fuel** — Gazole, SP95, SP98, E10, E85, GPLc — via the selector.
|
| 27 |
+
- **Price colour scale** — green = cheaper, red = pricier, scaled to each fuel's
|
| 28 |
+
national 5th–95th percentile; the legend updates per fuel.
|
| 29 |
+
- **Marker clustering** keeps ~10 000 stations smooth; zoom in to split clusters.
|
| 30 |
+
- **Click a station** for all prices, address, last-reported date, and a
|
| 31 |
+
one-click *Directions* link. Plus place search and hover tooltips.
|
| 32 |
+
|
| 33 |
+
## Run locally
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
pip install -r requirements.txt
|
| 37 |
+
python app.py # builds, then serves http://localhost:7860
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Open <http://localhost:7860> — on a phone (same network or behind HTTPS) you can
|
| 41 |
+
then "Add to Home Screen" to install it.
|
| 42 |
+
|
| 43 |
+
Other entrypoints:
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
python build_map.py # build output/index.html once, no server
|
| 47 |
+
python scheduler.py # rebuild twice daily without serving (for static hosting)
|
| 48 |
+
python scheduler.py --once
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
> **PWA note:** install/offline features need a secure context — i.e. `https://`
|
| 52 |
+
> or `http://localhost`. Opening the file directly (`file://`) shows the map but
|
| 53 |
+
> not the installable app.
|
| 54 |
+
|
| 55 |
+
## Deploy to Hugging Face Spaces (Docker)
|
| 56 |
+
|
| 57 |
+
1. Create a new Space → **SDK: Docker**.
|
| 58 |
+
2. Push these files to the Space repo (the YAML header above configures it):
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
git init && git add . && git commit -m "Petrol map PWA"
|
| 62 |
+
git remote add origin https://huggingface.co/spaces/<user>/<space>
|
| 63 |
+
git push origin main
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
3. The Space builds the `Dockerfile`, serves on port `7860`, and rebuilds the
|
| 67 |
+
prices twice a day. The public Space URL is HTTPS, so the PWA is installable
|
| 68 |
+
straight from your phone.
|
| 69 |
+
|
| 70 |
+
Run the same container anywhere else:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
docker build -t petrol-map .
|
| 74 |
+
docker run -p 7860:7860 petrol-map # http://localhost:7860
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## Files
|
| 78 |
+
|
| 79 |
+
| File | Purpose |
|
| 80 |
+
|------|---------|
|
| 81 |
+
| `fuel_data.py` | Fetch + normalise the dataset from the opendatasoft export API |
|
| 82 |
+
| `build_map.py` | Render `output/index.html` and copy the PWA assets |
|
| 83 |
+
| `map_template.html` | Client-side map (Leaflet + clustering + geocoder, PWA-enabled) |
|
| 84 |
+
| `app.py` | Build + serve on `$PORT` + twice-daily refresh (container entrypoint) |
|
| 85 |
+
| `scheduler.py` | Twice-daily rebuild without serving |
|
| 86 |
+
| `static/` | `manifest.webmanifest`, `sw.js`, app icons |
|
| 87 |
+
| `generate_icons.py` | One-off icon generator (needs Pillow; not used at runtime) |
|
| 88 |
+
| `Dockerfile` | Container image for Hugging Face Spaces / anywhere |
|
| 89 |
+
| `output/` | Generated site (`index.html`, PWA assets, `stations.json` cache) |
|
| 90 |
+
|
| 91 |
+
## How the refresh works
|
| 92 |
+
|
| 93 |
+
The upstream dataset is updated continuously by the French administration.
|
| 94 |
+
`app.py` re-fetches the full feed and regenerates the page twice daily (07:00 and
|
| 95 |
+
19:00, container local time — set `TZ`), so the map always reflects a recent
|
| 96 |
+
snapshot. Change `REFRESH_TIMES` to refresh on a different cadence.
|
app.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Production entrypoint: build the map, serve it, refresh it twice a day.
|
| 2 |
+
|
| 3 |
+
Runs as the single process inside a container (e.g. a Hugging Face Docker
|
| 4 |
+
Space). It builds the map immediately on startup, serves the output/ directory
|
| 5 |
+
over HTTP on $PORT (default 7860), and rebuilds at 07:00 and 19:00 in a
|
| 6 |
+
background thread so the served prices stay fresh.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import http.server
|
| 11 |
+
import os
|
| 12 |
+
import socketserver
|
| 13 |
+
import threading
|
| 14 |
+
import time
|
| 15 |
+
import traceback
|
| 16 |
+
|
| 17 |
+
import schedule
|
| 18 |
+
|
| 19 |
+
from build_map import OUTPUT_DIR, generate
|
| 20 |
+
|
| 21 |
+
PORT = int(os.environ.get("PORT", "7860"))
|
| 22 |
+
REFRESH_TIMES = ["07:00", "19:00"] # twice a day, container local time
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def safe_generate() -> None:
|
| 26 |
+
"""Rebuild the map, logging failures instead of crashing the loop."""
|
| 27 |
+
try:
|
| 28 |
+
generate()
|
| 29 |
+
except Exception:
|
| 30 |
+
print("Map generation failed:")
|
| 31 |
+
traceback.print_exc()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
| 35 |
+
"""Serve output/ with correct MIME types and no-cache on the live files."""
|
| 36 |
+
|
| 37 |
+
extensions_map = {
|
| 38 |
+
**http.server.SimpleHTTPRequestHandler.extensions_map,
|
| 39 |
+
".webmanifest": "application/manifest+json",
|
| 40 |
+
".js": "text/javascript",
|
| 41 |
+
".json": "application/json",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
def __init__(self, *args, **kwargs):
|
| 45 |
+
super().__init__(*args, directory=OUTPUT_DIR, **kwargs)
|
| 46 |
+
|
| 47 |
+
def end_headers(self):
|
| 48 |
+
# The page and service worker must revalidate so updates propagate.
|
| 49 |
+
path = self.path.split("?")[0]
|
| 50 |
+
if path in ("/", "/index.html", "/sw.js"):
|
| 51 |
+
self.send_header("Cache-Control", "no-cache")
|
| 52 |
+
super().end_headers()
|
| 53 |
+
|
| 54 |
+
def log_message(self, *args): # keep container logs quiet
|
| 55 |
+
pass
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class Server(socketserver.ThreadingTCPServer):
|
| 59 |
+
allow_reuse_address = True
|
| 60 |
+
daemon_threads = True
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def scheduler_loop() -> None:
|
| 64 |
+
for when in REFRESH_TIMES:
|
| 65 |
+
schedule.every().day.at(when).do(safe_generate)
|
| 66 |
+
while True:
|
| 67 |
+
schedule.run_pending()
|
| 68 |
+
time.sleep(30)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main() -> None:
|
| 72 |
+
safe_generate() # ensure the map exists before we start serving
|
| 73 |
+
threading.Thread(target=scheduler_loop, daemon=True).start()
|
| 74 |
+
with Server(("0.0.0.0", PORT), Handler) as httpd:
|
| 75 |
+
print(f"Serving on http://0.0.0.0:{PORT} (refresh at {', '.join(REFRESH_TIMES)})")
|
| 76 |
+
httpd.serve_forever()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
build_map.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build an interactive fuel-price map of France as a self-contained HTML file.
|
| 2 |
+
|
| 3 |
+
Rather than emitting one marker object per station per fuel (which balloons the
|
| 4 |
+
file to tens of MB), the station data is embedded once as compact JSON and the
|
| 5 |
+
map is rendered client-side with Leaflet + marker clustering. A fuel selector
|
| 6 |
+
recolours and filters the points live: green = cheaper, red = pricier, scaled to
|
| 7 |
+
each fuel's national price distribution.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import math
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from zoneinfo import ZoneInfo
|
| 17 |
+
|
| 18 |
+
from fuel_data import FUELS, fetch_stations
|
| 19 |
+
|
| 20 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
| 22 |
+
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
| 23 |
+
OUTPUT_HTML = os.path.join(OUTPUT_DIR, "index.html") # served at "/" (PWA start_url)
|
| 24 |
+
CACHE_JSON = os.path.join(OUTPUT_DIR, "stations.json")
|
| 25 |
+
PARIS = ZoneInfo("Europe/Paris")
|
| 26 |
+
|
| 27 |
+
FUEL_LABELS = list(FUELS)
|
| 28 |
+
TEMPLATE_PATH = os.path.join(BASE_DIR, "map_template.html")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def percentile(sorted_vals: list[float], pct: float) -> float:
|
| 32 |
+
"""Linear-interpolation percentile of an already-sorted list."""
|
| 33 |
+
if not sorted_vals:
|
| 34 |
+
return 0.0
|
| 35 |
+
k = (len(sorted_vals) - 1) * pct / 100.0
|
| 36 |
+
lo, hi = math.floor(k), math.ceil(k)
|
| 37 |
+
if lo == hi:
|
| 38 |
+
return sorted_vals[int(k)]
|
| 39 |
+
return sorted_vals[lo] * (hi - k) + sorted_vals[hi] * (k - lo)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def fuel_stats(stations: list[dict]) -> dict[str, dict]:
|
| 43 |
+
"""Per-fuel count and 5th/50th/95th percentiles used for colour scaling."""
|
| 44 |
+
stats = {}
|
| 45 |
+
for label in FUEL_LABELS:
|
| 46 |
+
values = sorted(
|
| 47 |
+
s["prices"][label] for s in stations if s["prices"][label] is not None
|
| 48 |
+
)
|
| 49 |
+
if not values:
|
| 50 |
+
continue
|
| 51 |
+
stats[label] = {
|
| 52 |
+
"count": len(values),
|
| 53 |
+
"p5": round(percentile(values, 5), 3),
|
| 54 |
+
"median": round(percentile(values, 50), 3),
|
| 55 |
+
"p95": round(percentile(values, 95), 3),
|
| 56 |
+
}
|
| 57 |
+
return stats
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def compact_records(stations: list[dict]) -> list[list]:
|
| 61 |
+
"""Encode each station as a small positional array to shrink the payload."""
|
| 62 |
+
records = []
|
| 63 |
+
for s in stations:
|
| 64 |
+
addr_parts = [s["address"], f"{s['cp']} {s['city']}".strip()]
|
| 65 |
+
addr = ", ".join(p for p in addr_parts if p)
|
| 66 |
+
prices = [s["prices"][label] for label in FUEL_LABELS]
|
| 67 |
+
records.append(
|
| 68 |
+
[
|
| 69 |
+
round(s["lat"], 5),
|
| 70 |
+
round(s["lon"], 5),
|
| 71 |
+
s["name"],
|
| 72 |
+
s["brand"],
|
| 73 |
+
addr,
|
| 74 |
+
(s["update"] or "")[:10],
|
| 75 |
+
prices,
|
| 76 |
+
]
|
| 77 |
+
)
|
| 78 |
+
return records
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_html(stations: list[dict]) -> str:
|
| 82 |
+
"""Render the HTML document from the template and embedded data."""
|
| 83 |
+
with open(TEMPLATE_PATH, encoding="utf-8") as fh:
|
| 84 |
+
template = fh.read()
|
| 85 |
+
|
| 86 |
+
payload = {
|
| 87 |
+
"fuels": FUEL_LABELS,
|
| 88 |
+
"stats": fuel_stats(stations),
|
| 89 |
+
"stations": compact_records(stations),
|
| 90 |
+
}
|
| 91 |
+
data_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 92 |
+
stamp = datetime.now(PARIS).strftime("%d %b %Y, %H:%M")
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
template.replace("__DATA__", data_json)
|
| 96 |
+
.replace("__COUNT__", f"{len(stations):,}".replace(",", " "))
|
| 97 |
+
.replace("__TIMESTAMP__", stamp)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def copy_static() -> None:
|
| 102 |
+
"""Copy the PWA assets (manifest, service worker, icons) into output/."""
|
| 103 |
+
if not os.path.isdir(STATIC_DIR):
|
| 104 |
+
return
|
| 105 |
+
for name in os.listdir(STATIC_DIR):
|
| 106 |
+
src = os.path.join(STATIC_DIR, name)
|
| 107 |
+
if os.path.isfile(src):
|
| 108 |
+
shutil.copy2(src, os.path.join(OUTPUT_DIR, name))
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def generate(output_html: str = OUTPUT_HTML) -> str:
|
| 112 |
+
"""Fetch live data, build the map, write the HTML file, return its path."""
|
| 113 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 114 |
+
copy_static()
|
| 115 |
+
stations = fetch_stations(cache_path=CACHE_JSON)
|
| 116 |
+
html_doc = build_html(stations)
|
| 117 |
+
with open(output_html, "w", encoding="utf-8") as fh:
|
| 118 |
+
fh.write(html_doc)
|
| 119 |
+
size_mb = os.path.getsize(output_html) / 1e6
|
| 120 |
+
print(
|
| 121 |
+
f"[{datetime.now(PARIS):%Y-%m-%d %H:%M:%S}] "
|
| 122 |
+
f"Map generated: {output_html} ({len(stations)} stations, {size_mb:.1f} MB)"
|
| 123 |
+
)
|
| 124 |
+
return output_html
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
generate()
|
fuel_data.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetch and normalise French fuel-price data from the open-data portal.
|
| 2 |
+
|
| 3 |
+
Data source: https://public.opendatasoft.com/explore/dataset/prix_des_carburants_j_7
|
| 4 |
+
The dataset holds the prices reported by every petrol station in France over the
|
| 5 |
+
last 7 days, refreshed continuously upstream.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
DATASET = "prix_des_carburants_j_7"
|
| 16 |
+
EXPORT_URL = (
|
| 17 |
+
"https://public.opendatasoft.com/api/explore/v2.1/"
|
| 18 |
+
f"catalog/datasets/{DATASET}/exports/json"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Human label -> price field in the dataset (ordered: most common fuel first).
|
| 22 |
+
FUELS: dict[str, str] = {
|
| 23 |
+
"Gazole": "price_gazole",
|
| 24 |
+
"SP95": "price_sp95",
|
| 25 |
+
"SP98": "price_sp98",
|
| 26 |
+
"E10": "price_e10",
|
| 27 |
+
"E85": "price_e85",
|
| 28 |
+
"GPLc": "price_gplc",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def fetch_raw(timeout: int = 180) -> list[dict]:
|
| 33 |
+
"""Download every record from the dataset's bulk export endpoint."""
|
| 34 |
+
resp = requests.get(EXPORT_URL, params={"timezone": "Europe/Paris"}, timeout=timeout)
|
| 35 |
+
resp.raise_for_status()
|
| 36 |
+
return resp.json()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def normalise(raw: list[dict]) -> list[dict]:
|
| 40 |
+
"""Keep geolocated stations that report at least one price; tidy the fields."""
|
| 41 |
+
stations = []
|
| 42 |
+
for r in raw:
|
| 43 |
+
geo = r.get("geo_point") or {}
|
| 44 |
+
lat, lon = geo.get("lat"), geo.get("lon")
|
| 45 |
+
if lat is None or lon is None:
|
| 46 |
+
continue
|
| 47 |
+
|
| 48 |
+
prices = {label: r.get(field) for label, field in FUELS.items()}
|
| 49 |
+
if not any(v is not None for v in prices.values()):
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
stations.append(
|
| 53 |
+
{
|
| 54 |
+
"id": r.get("id"),
|
| 55 |
+
"name": (r.get("name") or "").strip() or "Station",
|
| 56 |
+
"brand": (r.get("brand") or "").strip(),
|
| 57 |
+
"address": (r.get("address") or "").strip(),
|
| 58 |
+
"city": (r.get("city") or "").strip(),
|
| 59 |
+
"cp": (r.get("cp") or "").strip(),
|
| 60 |
+
"lat": lat,
|
| 61 |
+
"lon": lon,
|
| 62 |
+
"prices": prices,
|
| 63 |
+
"update": r.get("update"),
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
return stations
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def fetch_stations(cache_path: str | None = None) -> list[dict]:
|
| 70 |
+
"""Fetch + normalise the live dataset, optionally caching the result to disk."""
|
| 71 |
+
stations = normalise(fetch_raw())
|
| 72 |
+
if cache_path:
|
| 73 |
+
os.makedirs(os.path.dirname(cache_path) or ".", exist_ok=True)
|
| 74 |
+
payload = {
|
| 75 |
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
| 76 |
+
"count": len(stations),
|
| 77 |
+
"stations": stations,
|
| 78 |
+
}
|
| 79 |
+
with open(cache_path, "w", encoding="utf-8") as fh:
|
| 80 |
+
json.dump(payload, fh, ensure_ascii=False)
|
| 81 |
+
return stations
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
data = fetch_stations()
|
| 86 |
+
print(f"Fetched {len(data)} geolocated stations.")
|
| 87 |
+
sample = data[0]
|
| 88 |
+
print("Example:", json.dumps(sample, ensure_ascii=False, indent=2))
|
generate_icons.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate PWA / app icons (run once; output committed to static/).
|
| 2 |
+
|
| 3 |
+
Draws a simple white fuel-drop on the theme-green background at the sizes
|
| 4 |
+
required for installable PWAs and iOS home-screen icons. Re-run only if the
|
| 5 |
+
brand colours change — the container does not need Pillow at runtime.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
from PIL import Image, ImageDraw
|
| 12 |
+
|
| 13 |
+
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
| 14 |
+
GREEN_TOP = (38, 174, 96) # theme green (lighter)
|
| 15 |
+
GREEN_BOT = (22, 122, 70) # theme green (darker)
|
| 16 |
+
WHITE = (255, 255, 255, 255)
|
| 17 |
+
SCALE = 4 # supersample then downscale for smooth edges
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _gradient_bg(size: int) -> Image.Image:
|
| 21 |
+
"""Vertical green gradient canvas."""
|
| 22 |
+
img = Image.new("RGBA", (size, size))
|
| 23 |
+
px = img.load()
|
| 24 |
+
for y in range(size):
|
| 25 |
+
t = y / max(size - 1, 1)
|
| 26 |
+
r = round(GREEN_TOP[0] + (GREEN_BOT[0] - GREEN_TOP[0]) * t)
|
| 27 |
+
g = round(GREEN_TOP[1] + (GREEN_BOT[1] - GREEN_TOP[1]) * t)
|
| 28 |
+
b = round(GREEN_TOP[2] + (GREEN_BOT[2] - GREEN_TOP[2]) * t)
|
| 29 |
+
for x in range(size):
|
| 30 |
+
px[x, y] = (r, g, b, 255)
|
| 31 |
+
return img
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _rounded_mask(size: int, radius: int) -> Image.Image:
|
| 35 |
+
mask = Image.new("L", (size, size), 0)
|
| 36 |
+
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius, fill=255)
|
| 37 |
+
return mask
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _draw_drop(draw: ImageDraw.ImageDraw, size: int, scale: float = 1.0) -> None:
|
| 41 |
+
"""A teardrop: round bottom (circle) + tapered top (triangle), centred."""
|
| 42 |
+
cx = size / 2
|
| 43 |
+
r = size * 0.20 * scale
|
| 44 |
+
cy = size * 0.60 # centre of the round part
|
| 45 |
+
apex_y = cy - r * (3.0 + 0.0) # tip well above the circle
|
| 46 |
+
apex_y = size * (0.60 - 0.20 * scale * 2.05)
|
| 47 |
+
# tangent-ish base corners just above the circle centre
|
| 48 |
+
bx = r * 0.92
|
| 49 |
+
by = cy - r * 0.45
|
| 50 |
+
draw.polygon([(cx, apex_y), (cx - bx, by), (cx + bx, by)], fill=WHITE)
|
| 51 |
+
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=WHITE)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def make_icon(size: int, *, maskable: bool = False, rounded: bool = True) -> Image.Image:
|
| 55 |
+
s = size * SCALE
|
| 56 |
+
bg = _gradient_bg(s)
|
| 57 |
+
# maskable icons must keep content inside the central "safe zone"
|
| 58 |
+
drop_scale = 0.78 if maskable else 1.0
|
| 59 |
+
_draw_drop(ImageDraw.Draw(bg), s, scale=drop_scale)
|
| 60 |
+
if rounded and not maskable:
|
| 61 |
+
bg.putalpha(_rounded_mask(s, radius=int(s * 0.22)))
|
| 62 |
+
return bg.resize((size, size), Image.LANCZOS)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def main() -> None:
|
| 66 |
+
os.makedirs(STATIC_DIR, exist_ok=True)
|
| 67 |
+
targets = {
|
| 68 |
+
"icon-192.png": dict(size=192, rounded=True),
|
| 69 |
+
"icon-512.png": dict(size=512, rounded=True),
|
| 70 |
+
"icon-maskable-512.png": dict(size=512, maskable=True),
|
| 71 |
+
"apple-touch-icon.png": dict(size=180, maskable=True), # full-bleed for iOS
|
| 72 |
+
}
|
| 73 |
+
for name, kw in targets.items():
|
| 74 |
+
size = kw.pop("size")
|
| 75 |
+
make_icon(size, **kw).save(os.path.join(STATIC_DIR, name))
|
| 76 |
+
print("wrote", name)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
map_template.html
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
| 6 |
+
<title>Prix des carburants en France</title>
|
| 7 |
+
<meta name="description" content="Carte interactive des prix des carburants en France, rafraîchie deux fois par jour.">
|
| 8 |
+
<meta name="theme-color" content="#1a9850">
|
| 9 |
+
<link rel="manifest" href="/manifest.webmanifest">
|
| 10 |
+
<link rel="icon" href="/icon-192.png">
|
| 11 |
+
<!-- iOS / Android home-screen support -->
|
| 12 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 13 |
+
<meta name="mobile-web-app-capable" content="yes">
|
| 14 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 15 |
+
<meta name="apple-mobile-web-app-title" content="Carburants">
|
| 16 |
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
| 17 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
| 18 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
|
| 19 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
|
| 20 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder@2.4.0/dist/Control.Geocoder.css">
|
| 21 |
+
<style>
|
| 22 |
+
html, body { height: 100%; margin: 0; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; }
|
| 23 |
+
#map { position: absolute; inset: 0; height: 100vh; height: 100dvh; width: 100%; }
|
| 24 |
+
.banner {
|
| 25 |
+
position: absolute; left: 50%; transform: translateX(-50%);
|
| 26 |
+
top: calc(8px + env(safe-area-inset-top));
|
| 27 |
+
z-index: 1000; background: rgba(255,255,255,.93); padding: 8px 18px;
|
| 28 |
+
border-radius: 10px; box-shadow: 0 1px 8px rgba(0,0,0,.25); text-align: center;
|
| 29 |
+
max-width: 92vw; box-sizing: border-box;
|
| 30 |
+
}
|
| 31 |
+
.banner b { font-size: 15px; }
|
| 32 |
+
.banner small { display: block; color: #666; font-size: 11px; margin-top: 2px; }
|
| 33 |
+
.panel {
|
| 34 |
+
background: rgba(255,255,255,.96); padding: 10px 12px; border-radius: 8px;
|
| 35 |
+
box-shadow: 0 1px 6px rgba(0,0,0,.3); font-size: 13px; line-height: 1.5; min-width: 150px;
|
| 36 |
+
}
|
| 37 |
+
.panel h4 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #555; }
|
| 38 |
+
.panel label { display: block; cursor: pointer; }
|
| 39 |
+
.panel input { vertical-align: middle; margin-right: 6px; }
|
| 40 |
+
.panel .count { color: #999; font-size: 11px; }
|
| 41 |
+
.legend-bar {
|
| 42 |
+
height: 12px; border-radius: 3px; margin: 8px 0 3px;
|
| 43 |
+
background: linear-gradient(to right, #1a9850, #91cf60, #fee08b, #fc8d59, #d73027);
|
| 44 |
+
}
|
| 45 |
+
.legend-scale { display: flex; justify-content: space-between; font-size: 11px; color: #555; }
|
| 46 |
+
.popup b { font-size: 14px; }
|
| 47 |
+
.popup .addr { color: #666; margin: 2px 0 6px; }
|
| 48 |
+
.popup table { border-collapse: collapse; }
|
| 49 |
+
.popup td { padding: 1px 8px 1px 0; }
|
| 50 |
+
.popup td.p { text-align: right; font-weight: 600; }
|
| 51 |
+
.popup .upd { margin-top: 6px; color: #999; font-size: 11px; }
|
| 52 |
+
/* Keep Leaflet controls clear of notches / rounded corners */
|
| 53 |
+
.leaflet-top { margin-top: env(safe-area-inset-top); }
|
| 54 |
+
.leaflet-left { margin-left: env(safe-area-inset-left); }
|
| 55 |
+
.leaflet-right { margin-right: env(safe-area-inset-right); }
|
| 56 |
+
.leaflet-bottom { margin-bottom: env(safe-area-inset-bottom); }
|
| 57 |
+
/* Mobile: smaller banner, bigger touch targets */
|
| 58 |
+
@media (max-width: 600px) {
|
| 59 |
+
.banner { padding: 6px 12px; }
|
| 60 |
+
.banner b { font-size: 13px; }
|
| 61 |
+
.banner small { font-size: 10px; }
|
| 62 |
+
.panel { font-size: 14px; padding: 9px 11px; min-width: 130px; }
|
| 63 |
+
.panel label { padding: 3px 0; }
|
| 64 |
+
.leaflet-touch .leaflet-bar a { width: 34px; height: 34px; line-height: 34px; }
|
| 65 |
+
}
|
| 66 |
+
</style>
|
| 67 |
+
</head>
|
| 68 |
+
<body>
|
| 69 |
+
<div id="map"></div>
|
| 70 |
+
<div class="banner">
|
| 71 |
+
<b>⛽ Prix des carburants en France</b>
|
| 72 |
+
<small>__COUNT__ stations · refreshed __TIMESTAMP__ (Europe/Paris)</small>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 76 |
+
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
| 77 |
+
<script src="https://unpkg.com/leaflet-control-geocoder@2.4.0/dist/Control.Geocoder.js"></script>
|
| 78 |
+
<script>
|
| 79 |
+
const DATA = __DATA__;
|
| 80 |
+
const FUELS = DATA.fuels; // ["Gazole", "SP95", ...]
|
| 81 |
+
const STATS = DATA.stats; // { Gazole: {count, p5, median, p95}, ... }
|
| 82 |
+
// Each station: [lat, lon, name, brand, addr, updated, [price per fuel...]]
|
| 83 |
+
const STATIONS = DATA.stations;
|
| 84 |
+
|
| 85 |
+
// Green -> yellow -> red gradient stops (cheap -> expensive).
|
| 86 |
+
const STOPS = [
|
| 87 |
+
[0.00, [26, 152, 80]], [0.25, [145, 207, 96]], [0.50, [254, 224, 139]],
|
| 88 |
+
[0.75, [252, 141, 89]], [1.00, [215, 48, 39]],
|
| 89 |
+
];
|
| 90 |
+
function colorFor(t) {
|
| 91 |
+
t = Math.max(0, Math.min(1, t));
|
| 92 |
+
for (let i = 1; i < STOPS.length; i++) {
|
| 93 |
+
if (t <= STOPS[i][0]) {
|
| 94 |
+
const [t0, c0] = STOPS[i - 1], [t1, c1] = STOPS[i];
|
| 95 |
+
const f = (t - t0) / (t1 - t0);
|
| 96 |
+
const c = c0.map((v, k) => Math.round(v + (c1[k] - v) * f));
|
| 97 |
+
return `rgb(${c[0]},${c[1]},${c[2]})`;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
return "rgb(215,48,39)";
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const map = L.map("map", { preferCanvas: true }).setView([46.6, 2.45], 6);
|
| 104 |
+
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
|
| 105 |
+
attribution: '© OpenStreetMap, © CARTO · data: data.economie.gouv.fr via opendatasoft',
|
| 106 |
+
subdomains: "abcd", maxZoom: 19,
|
| 107 |
+
}).addTo(map);
|
| 108 |
+
|
| 109 |
+
const cluster = L.markerClusterGroup({
|
| 110 |
+
chunkedLoading: true, maxClusterRadius: 45, disableClusteringAtZoom: 11,
|
| 111 |
+
});
|
| 112 |
+
map.addLayer(cluster);
|
| 113 |
+
|
| 114 |
+
L.Control.geocoder({ defaultMarkGeocode: true, collapsed: true, placeholder: "Search a place…" })
|
| 115 |
+
.addTo(map);
|
| 116 |
+
|
| 117 |
+
function popupHtml(s, fuelIdx) {
|
| 118 |
+
const [lat, lon, name, brand, addr, updated, prices] = s;
|
| 119 |
+
let rows = "";
|
| 120 |
+
FUELS.forEach((f, i) => {
|
| 121 |
+
if (prices[i] != null) {
|
| 122 |
+
const hi = i === fuelIdx ? ' style="background:#fff3cd"' : "";
|
| 123 |
+
rows += `<tr${hi}><td>${f}</td><td class="p">${prices[i].toFixed(3)} €</td></tr>`;
|
| 124 |
+
}
|
| 125 |
+
});
|
| 126 |
+
const dir = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`;
|
| 127 |
+
const title = brand ? `${name} · ${brand}` : name;
|
| 128 |
+
return `<div class="popup"><b>${esc(title)}</b>` +
|
| 129 |
+
`<div class="addr">${esc(addr)}</div><table>${rows}</table>` +
|
| 130 |
+
`<div class="upd">Reported ${updated || "—"}</div>` +
|
| 131 |
+
`<a href="${dir}" target="_blank">Directions ↗</a></div>`;
|
| 132 |
+
}
|
| 133 |
+
function esc(t) {
|
| 134 |
+
return String(t).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function render(fuelIdx) {
|
| 138 |
+
const fuel = FUELS[fuelIdx];
|
| 139 |
+
const st = STATS[fuel];
|
| 140 |
+
const span = Math.max(st.p95 - st.p5, 0.001);
|
| 141 |
+
const markers = [];
|
| 142 |
+
for (const s of STATIONS) {
|
| 143 |
+
const price = s[6][fuelIdx];
|
| 144 |
+
if (price == null) continue;
|
| 145 |
+
const color = colorFor((price - st.p5) / span);
|
| 146 |
+
const m = L.circleMarker([s[0], s[1]], {
|
| 147 |
+
radius: 6, color: "rgba(40,40,40,.35)", weight: 1,
|
| 148 |
+
fillColor: color, fillOpacity: 0.9,
|
| 149 |
+
});
|
| 150 |
+
m.bindTooltip(`${esc(s[2])} — ${price.toFixed(3)} €`);
|
| 151 |
+
m.bindPopup(() => popupHtml(s, fuelIdx), { maxWidth: 300 });
|
| 152 |
+
markers.push(m);
|
| 153 |
+
}
|
| 154 |
+
cluster.clearLayers();
|
| 155 |
+
cluster.addLayers(markers);
|
| 156 |
+
updateLegend(fuel);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// ---- Fuel selector + legend control (top-right) ----
|
| 160 |
+
const panel = L.control({ position: "topright" });
|
| 161 |
+
panel.onAdd = function () {
|
| 162 |
+
const div = L.DomUtil.create("div", "panel");
|
| 163 |
+
L.DomEvent.disableClickPropagation(div);
|
| 164 |
+
let html = "<h4>Carburant</h4>";
|
| 165 |
+
FUELS.forEach((f, i) => {
|
| 166 |
+
if (!STATS[f]) return;
|
| 167 |
+
html += `<label><input type="radio" name="fuel" value="${i}" ${i === 0 ? "checked" : ""}>` +
|
| 168 |
+
`${f} <span class="count">(${STATS[f].count})</span></label>`;
|
| 169 |
+
});
|
| 170 |
+
html += '<div class="legend-bar"></div><div class="legend-scale">' +
|
| 171 |
+
'<span id="lg-lo"></span><span id="lg-mid"></span><span id="lg-hi"></span></div>';
|
| 172 |
+
div.innerHTML = html;
|
| 173 |
+
div.querySelectorAll('input[name="fuel"]').forEach(inp =>
|
| 174 |
+
inp.addEventListener("change", e => render(+e.target.value)));
|
| 175 |
+
return div;
|
| 176 |
+
};
|
| 177 |
+
panel.addTo(map);
|
| 178 |
+
|
| 179 |
+
function updateLegend(fuel) {
|
| 180 |
+
const st = STATS[fuel];
|
| 181 |
+
document.getElementById("lg-lo").textContent = st.p5.toFixed(2) + " €";
|
| 182 |
+
document.getElementById("lg-mid").textContent = st.median.toFixed(2) + " €";
|
| 183 |
+
document.getElementById("lg-hi").textContent = st.p95.toFixed(2) + " €";
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
render(0);
|
| 187 |
+
|
| 188 |
+
// Register the service worker for offline use + installability (PWA).
|
| 189 |
+
if ("serviceWorker" in navigator) {
|
| 190 |
+
window.addEventListener("load", () =>
|
| 191 |
+
navigator.serviceWorker.register("/sw.js").catch(() => {}));
|
| 192 |
+
}
|
| 193 |
+
</script>
|
| 194 |
+
</body>
|
| 195 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
requests>=2.31
|
| 2 |
+
schedule>=1.2
|
scheduler.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rebuild the static map twice a day, without serving it.
|
| 2 |
+
|
| 3 |
+
Use this when you host the generated output/ directory elsewhere (static hosting,
|
| 4 |
+
a CDN, GitHub Pages, etc.). To build *and* serve from one process — e.g. for a
|
| 5 |
+
container or local preview — run `python app.py` instead.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scheduler.py # build now, then rebuild at 07:00 and 19:00
|
| 9 |
+
python scheduler.py --once # build a single time and exit
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import time
|
| 15 |
+
import traceback
|
| 16 |
+
|
| 17 |
+
import schedule
|
| 18 |
+
|
| 19 |
+
from build_map import generate
|
| 20 |
+
|
| 21 |
+
REFRESH_TIMES = ["07:00", "19:00"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def safe_generate() -> None:
|
| 25 |
+
try:
|
| 26 |
+
generate()
|
| 27 |
+
except Exception:
|
| 28 |
+
print("Map generation failed:")
|
| 29 |
+
traceback.print_exc()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
parser = argparse.ArgumentParser(description="Twice-daily fuel-price map rebuilder.")
|
| 34 |
+
parser.add_argument("--once", action="store_true", help="build once and exit")
|
| 35 |
+
args = parser.parse_args()
|
| 36 |
+
|
| 37 |
+
safe_generate() # always start with a fresh map
|
| 38 |
+
if args.once:
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
for when in REFRESH_TIMES:
|
| 42 |
+
schedule.every().day.at(when).do(safe_generate)
|
| 43 |
+
print(f"Scheduled rebuilds at {', '.join(REFRESH_TIMES)} (local time).")
|
| 44 |
+
while True:
|
| 45 |
+
schedule.run_pending()
|
| 46 |
+
time.sleep(30)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if __name__ == "__main__":
|
| 50 |
+
main()
|
static/apple-touch-icon.png
ADDED
|
|
static/icon-192.png
ADDED
|
|
static/icon-512.png
ADDED
|
|
static/icon-maskable-512.png
ADDED
|
|
static/manifest.webmanifest
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Prix des Carburants — France",
|
| 3 |
+
"short_name": "Carburants",
|
| 4 |
+
"description": "Carte interactive des prix des carburants en France, rafraîchie deux fois par jour.",
|
| 5 |
+
"lang": "fr",
|
| 6 |
+
"start_url": "/",
|
| 7 |
+
"scope": "/",
|
| 8 |
+
"display": "standalone",
|
| 9 |
+
"orientation": "any",
|
| 10 |
+
"background_color": "#f5f5f5",
|
| 11 |
+
"theme_color": "#1a9850",
|
| 12 |
+
"categories": ["navigation", "travel", "utilities"],
|
| 13 |
+
"icons": [
|
| 14 |
+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
| 15 |
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
| 16 |
+
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
| 17 |
+
]
|
| 18 |
+
}
|
static/sw.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Service worker: makes the map installable and usable offline.
|
| 2 |
+
* - App navigations are network-first (fresh prices when online), falling back
|
| 3 |
+
* to the last cached page when offline.
|
| 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 = "v1";
|
| 8 |
+
const SHELL = "shell-" + VERSION;
|
| 9 |
+
const RUNTIME = "runtime-" + VERSION;
|
| 10 |
+
const TILES = "tiles-" + VERSION;
|
| 11 |
+
const TILE_LIMIT = 300;
|
| 12 |
+
|
| 13 |
+
const APP_SHELL = [
|
| 14 |
+
"/", "/index.html", "/manifest.webmanifest",
|
| 15 |
+
"/icon-192.png", "/icon-512.png",
|
| 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) => {
|
| 25 |
+
event.waitUntil(
|
| 26 |
+
caches.keys().then((keys) =>
|
| 27 |
+
Promise.all(
|
| 28 |
+
keys.filter((k) => ![SHELL, RUNTIME, TILES].includes(k)).map((k) => caches.delete(k))
|
| 29 |
+
)
|
| 30 |
+
).then(() => self.clients.claim())
|
| 31 |
+
);
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
async function trimCache(name, max) {
|
| 35 |
+
const cache = await caches.open(name);
|
| 36 |
+
const keys = await cache.keys();
|
| 37 |
+
for (let i = 0; i < keys.length - max; i++) await cache.delete(keys[i]);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function cacheable(res) {
|
| 41 |
+
return res && (res.ok || res.type === "opaque");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
self.addEventListener("fetch", (event) => {
|
| 45 |
+
const req = event.request;
|
| 46 |
+
if (req.method !== "GET") return;
|
| 47 |
+
const url = new URL(req.url);
|
| 48 |
+
|
| 49 |
+
// App navigation: fresh page online, cached shell offline.
|
| 50 |
+
if (req.mode === "navigate") {
|
| 51 |
+
event.respondWith(
|
| 52 |
+
fetch(req)
|
| 53 |
+
.then((res) => {
|
| 54 |
+
const copy = res.clone();
|
| 55 |
+
caches.open(SHELL).then((c) => c.put("/index.html", copy));
|
| 56 |
+
return res;
|
| 57 |
+
})
|
| 58 |
+
.catch(() => caches.match("/index.html").then((r) => r || caches.match("/")))
|
| 59 |
+
);
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Map tiles: network-first, bounded cache for offline revisits.
|
| 64 |
+
if (/basemaps\.cartocdn\.com$/.test(url.hostname)) {
|
| 65 |
+
event.respondWith(
|
| 66 |
+
fetch(req)
|
| 67 |
+
.then((res) => {
|
| 68 |
+
const copy = res.clone();
|
| 69 |
+
caches.open(TILES).then((c) => {
|
| 70 |
+
c.put(req, copy);
|
| 71 |
+
trimCache(TILES, TILE_LIMIT);
|
| 72 |
+
});
|
| 73 |
+
return res;
|
| 74 |
+
})
|
| 75 |
+
.catch(() => caches.match(req))
|
| 76 |
+
);
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Same-origin assets + CDN libraries: cache-first, refresh in background.
|
| 81 |
+
if (url.origin === self.location.origin || url.hostname === "unpkg.com") {
|
| 82 |
+
event.respondWith(
|
| 83 |
+
caches.match(req).then((cached) => {
|
| 84 |
+
const network = fetch(req)
|
| 85 |
+
.then((res) => {
|
| 86 |
+
if (cacheable(res)) {
|
| 87 |
+
const copy = res.clone();
|
| 88 |
+
caches.open(RUNTIME).then((c) => c.put(req, copy));
|
| 89 |
+
}
|
| 90 |
+
return res;
|
| 91 |
+
})
|
| 92 |
+
.catch(() => cached);
|
| 93 |
+
return cached || network;
|
| 94 |
+
})
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
// Anything else (e.g. Nominatim geocoding) goes straight to the network.
|
| 98 |
+
});
|