SamDNX Claude Opus 4.8 commited on
Commit
284e818
·
1 Parent(s): 01a6a9e

Deploy petrol map: interactive fuel-price PWA + Docker for HF Spaces

Browse files
.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 Carburant
3
- emoji: 🏃
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: '&copy; OpenStreetMap, &copy; 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
+ });