new stack
This commit is contained in:
parent
6922e9d63b
commit
ac9737b125
10 changed files with 1476 additions and 1221 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -4,10 +4,16 @@ Guidance for agentic coding agents working in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**Essence** is a Quebec gas price heatmap — a minimal, self-contained web application with two source files:
|
**Essence** is a Quebec gas price heatmap — a minimal web application with the following source files:
|
||||||
|
|
||||||
- `main.go` — the entire Go backend (HTTP server, SQLite persistence, upstream polling)
|
- `main.go` — the entire Go backend (HTTP server, SQLite persistence, upstream polling, HTML template rendering)
|
||||||
- `static/index.html` — the entire frontend (Leaflet map, Chart.js charts, vanilla JS)
|
- `templates/` — Go `html/template` files rendered server-side
|
||||||
|
- `layout.html` — base layout (head, nav, htmx script, oat CSS); defines `content` and `scripts` blocks
|
||||||
|
- `map.html` — map page content + scripts blocks
|
||||||
|
- `stats.html` — stats page shell (region dropdown, `#stats-content` target div)
|
||||||
|
- `stats-content.html` — stats fragment (cards, Chart.js canvas, history table); swapped by htmx on region/range changes
|
||||||
|
- `static/map.js` — Leaflet map initialization, marker rendering, cluster logic, controls wiring
|
||||||
|
- `static/stats.js` — Chart.js initialization; re-runs after htmx swaps `#stats-content`
|
||||||
|
|
||||||
Keep this architecture. Do not split `main.go` into multiple files or introduce a frontend build step unless explicitly asked.
|
Keep this architecture. Do not split `main.go` into multiple files or introduce a frontend build step unless explicitly asked.
|
||||||
|
|
||||||
|
|
@ -179,19 +185,22 @@ type Station struct { ... }
|
||||||
func poller() { ... }
|
func poller() { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Code Style (`static/index.html`)
|
## Frontend Code Style
|
||||||
|
|
||||||
- **No framework, no build step.** Plain HTML, CSS, and JavaScript in a single file.
|
- **No framework, no build step.** Go `html/template` for HTML; plain JS in `static/`.
|
||||||
- **Inline** `<style>` and `<script>` blocks — no external local JS/CSS files.
|
- **htmx** for dynamic content: region/range changes on the stats page swap `#stats-content` via `hx-get`. Navigation between pages swaps `#content`.
|
||||||
- **CDN** dependencies only: Leaflet, leaflet.markercluster, Chart.js, `@knadh/oat` CSS.
|
- **CDN** dependencies only: htmx, Leaflet, leaflet.markercluster, Chart.js, `@knadh/oat` CSS.
|
||||||
- **`camelCase`** for all JS variables and function names.
|
- **`camelCase`** for all JS variables and function names.
|
||||||
- **`fetch()` with `.then()` promise chains** — not `async/await`.
|
- **`fetch()` with `.then()` promise chains** — not `async/await`.
|
||||||
- **CSS custom properties** (`--primary`, `--border`, etc.) from the `oat` design token system.
|
- **CSS custom properties** (`--primary`, `--border`, etc.) from the `oat` design token system.
|
||||||
- **French language** — all user-facing text must be in French (`lang="fr"` on `<html>`).
|
- **French language** — all user-facing text must be in French (`lang="fr"` on `<html>`).
|
||||||
|
- Station data for the map is inlined as `window.__stations` / `window.__deltas` by the server in `map.html`; no separate `/api/stations` fetch needed.
|
||||||
|
- Stats chart data is inlined as `window.__statsData` in the `stats-content.html` fragment; `window.__initStatsChart()` is called inline after each swap.
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
- Static assets are embedded into the binary at compile time via `//go:embed static/*`. No file serving from disk at runtime.
|
- Static assets are embedded into the binary at compile time via `//go:embed static/*`. No file serving from disk at runtime.
|
||||||
|
- Templates are embedded via `//go:embed templates/*` and parsed once at startup into a `*template.Template`.
|
||||||
- The SQLite driver is `modernc.org/sqlite` — a pure-Go, CGo-free implementation. Do not introduce CGo or replace this driver.
|
- The SQLite driver is `modernc.org/sqlite` — a pure-Go, CGo-free implementation. Do not introduce CGo or replace this driver.
|
||||||
- Deployment target is a NixOS systemd service defined in `nixos-module.nix`.
|
- Deployment target is a NixOS systemd service defined in `nixos-module.nix`.
|
||||||
- The module `github.com/polen/essence` targets Go 1.25+.
|
- The module `github.com/polen/essence` targets Go 1.25+.
|
||||||
|
|
|
||||||
BIN
essence_new
Executable file
BIN
essence_new
Executable file
Binary file not shown.
777
main.go
777
main.go
|
|
@ -6,11 +6,13 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -22,6 +24,9 @@ import (
|
||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
//go:embed templates/*
|
||||||
|
var templateFiles embed.FS
|
||||||
|
|
||||||
const (
|
const (
|
||||||
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
|
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
|
||||||
defaultPort = "8080"
|
defaultPort = "8080"
|
||||||
|
|
@ -85,6 +90,9 @@ var (
|
||||||
|
|
||||||
var db *sql.DB
|
var db *sql.DB
|
||||||
|
|
||||||
|
// tmpl is the parsed template set, loaded once at startup.
|
||||||
|
var tmpl *template.Template
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
|
|
@ -103,6 +111,11 @@ func main() {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
tmpl, err = loadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initial fetch, then background poll every 5 minutes.
|
// Initial fetch, then background poll every 5 minutes.
|
||||||
go poller()
|
go poller()
|
||||||
|
|
||||||
|
|
@ -111,20 +124,533 @@ func main() {
|
||||||
log.Fatalf("static files: %v", err)
|
log.Fatalf("static files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML page routes
|
||||||
|
http.HandleFunc("/", handleRoot)
|
||||||
|
http.HandleFunc("/map", handleMapPage)
|
||||||
|
http.HandleFunc("/stats", handleStatsPage)
|
||||||
|
http.HandleFunc("/stats/content", handleStatsContent)
|
||||||
|
|
||||||
|
// JSON API routes (kept for backwards-compatibility)
|
||||||
http.HandleFunc("/api/stations", handleStations)
|
http.HandleFunc("/api/stations", handleStations)
|
||||||
http.HandleFunc("/api/stats", handleStats)
|
http.HandleFunc("/api/stats", handleStats)
|
||||||
http.HandleFunc("/api/regions", handleRegions)
|
http.HandleFunc("/api/regions", handleRegions)
|
||||||
http.HandleFunc("/api/stats/region", handleRegionStats)
|
http.HandleFunc("/api/stats/region", handleRegionStats)
|
||||||
http.HandleFunc("/api/station-deltas", handleStationDeltas)
|
http.HandleFunc("/api/station-deltas", handleStationDeltas)
|
||||||
http.Handle("/", http.FileServer(http.FS(staticSub)))
|
|
||||||
|
// Static assets (map.js, stats.js, etc.)
|
||||||
|
http.Handle("/map.js", http.FileServer(http.FS(staticSub)))
|
||||||
|
http.Handle("/stats.js", http.FileServer(http.FS(staticSub)))
|
||||||
|
|
||||||
log.Printf("Listening on http://localhost:%s", port)
|
log.Printf("Listening on http://localhost:%s", port)
|
||||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadTemplates parses all templates from the embedded templates/ directory.
|
||||||
|
// It registers helper functions used in templates.
|
||||||
|
func loadTemplates() (*template.Template, error) {
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"fmtPrice": func(v float64) string {
|
||||||
|
if v <= 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f", v)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := template.New("").Funcs(funcMap).ParseFS(templateFiles, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse templates: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTemplate executes a named template with data, writing the result to w.
|
||||||
|
// On error it falls back to a plain HTTP 500.
|
||||||
|
func renderTemplate(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
log.Printf("template %q: %v", name, err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTMX returns true when the request was issued by htmx.
|
||||||
|
func isHTMX(r *http.Request) bool {
|
||||||
|
return r.Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page data structs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mapPageData holds everything needed to render the map page.
|
||||||
|
type mapPageData struct {
|
||||||
|
Page string
|
||||||
|
StationsJSON template.JS
|
||||||
|
DeltasJSON template.JS
|
||||||
|
Regions []string
|
||||||
|
LastUpdated string
|
||||||
|
StationCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsPageData holds everything needed to render the stats page shell.
|
||||||
|
type statsPageData struct {
|
||||||
|
Page string
|
||||||
|
Regions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyRow is one row in the history table, pre-formatted for the template.
|
||||||
|
type historyRow struct {
|
||||||
|
Date string
|
||||||
|
RegularAvg string
|
||||||
|
RegularMin string
|
||||||
|
RegularMax string
|
||||||
|
SuperAvg string
|
||||||
|
DieselAvg string
|
||||||
|
StationCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsContentData holds everything needed to render the stats-content fragment.
|
||||||
|
type statsContentData struct {
|
||||||
|
Days int
|
||||||
|
Snapshots []Snapshot
|
||||||
|
SnapshotsJSON template.JS
|
||||||
|
Last Snapshot
|
||||||
|
HistoryRows []historyRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML page handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handleRoot redirects bare "/" to the map page.
|
||||||
|
func handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/map", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMapPage renders the full map page (or just the content block for htmx).
|
||||||
|
func handleMapPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cacheMu.RLock()
|
||||||
|
resp := cachedResp
|
||||||
|
cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if resp == nil {
|
||||||
|
// Data not yet ready; render layout with a loading message.
|
||||||
|
renderTemplate(w, "layout.html", mapPageData{Page: "map"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sorted unique region list from in-memory station data.
|
||||||
|
regionSet := map[string]struct{}{}
|
||||||
|
for _, s := range resp.Stations {
|
||||||
|
if s.Region != "" {
|
||||||
|
regionSet[s.Region] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
regions := make([]string, 0, len(regionSet))
|
||||||
|
for r := range regionSet {
|
||||||
|
regions = append(regions, r)
|
||||||
|
}
|
||||||
|
sort.Strings(regions)
|
||||||
|
|
||||||
|
// Encode station and delta data for inline script injection.
|
||||||
|
stationsJSON, err := json.Marshal(resp.Stations)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "encoding stations", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas, err := buildDeltas()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("build deltas: %v", err)
|
||||||
|
deltas = map[string]StationDelta{}
|
||||||
|
}
|
||||||
|
deltasJSON, err := json.Marshal(deltas)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "encoding deltas", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdated := ""
|
||||||
|
if resp.LastUpdated != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, resp.LastUpdated); err == nil {
|
||||||
|
lastUpdated = "Dernière mise à jour: " + t.In(time.FixedZone("ET", -4*3600)).
|
||||||
|
Format("2 January 2006, 15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := mapPageData{
|
||||||
|
Page: "map",
|
||||||
|
StationsJSON: template.JS(stationsJSON),
|
||||||
|
DeltasJSON: template.JS(deltasJSON),
|
||||||
|
Regions: regions,
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
StationCount: len(resp.Stations),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHTMX(r) {
|
||||||
|
// Return only the page fragment (content + inline scripts), not the full layout.
|
||||||
|
renderTemplate(w, "map-content", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderTemplate(w, "layout.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatsPage renders the full stats page shell (or just the content block for htmx).
|
||||||
|
func handleStatsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
regions, err := queryRegions()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("query regions: %v", err)
|
||||||
|
regions = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := statsPageData{
|
||||||
|
Page: "stats",
|
||||||
|
Regions: regions,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHTMX(r) {
|
||||||
|
renderTemplate(w, "stats-content-shell", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderTemplate(w, "layout.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatsContent renders only the stats-content fragment (cards + chart + table).
|
||||||
|
// This is the htmx target for region and range changes.
|
||||||
|
func handleStatsContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
daysStr := r.URL.Query().Get("days")
|
||||||
|
days := 7
|
||||||
|
if daysStr != "" {
|
||||||
|
if d, err := strconv.Atoi(daysStr); err == nil && d >= 0 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
region := r.URL.Query().Get("region")
|
||||||
|
|
||||||
|
snapshots, err := querySnapshots(region, daysStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var last Snapshot
|
||||||
|
if len(snapshots) > 0 {
|
||||||
|
last = snapshots[len(snapshots)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build history rows (last 10, most-recent first).
|
||||||
|
rows := snapshots
|
||||||
|
if len(rows) > 10 {
|
||||||
|
rows = rows[len(rows)-10:]
|
||||||
|
}
|
||||||
|
history := make([]historyRow, 0, len(rows))
|
||||||
|
for i := len(rows) - 1; i >= 0; i-- {
|
||||||
|
s := rows[i]
|
||||||
|
d := "—"
|
||||||
|
if t, err := time.Parse(time.RFC3339, s.GeneratedAt); err == nil {
|
||||||
|
d = t.In(time.FixedZone("ET", -4*3600)).
|
||||||
|
Format("2 Jan 2006, 15:04")
|
||||||
|
}
|
||||||
|
history = append(history, historyRow{
|
||||||
|
Date: d,
|
||||||
|
RegularAvg: fmtPrice(s.RegularAvg),
|
||||||
|
RegularMin: fmtPrice(s.RegularMin),
|
||||||
|
RegularMax: fmtPrice(s.RegularMax),
|
||||||
|
SuperAvg: fmtPrice(s.SuperAvg),
|
||||||
|
DieselAvg: fmtPrice(s.DieselAvg),
|
||||||
|
StationCount: s.StationCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotsJSON, err := json.Marshal(snapshots)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "encoding snapshots", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTemplate(w, "stats-content.html", statsContentData{
|
||||||
|
Days: days,
|
||||||
|
Snapshots: snapshots,
|
||||||
|
SnapshotsJSON: template.JS(snapshotsJSON),
|
||||||
|
Last: last,
|
||||||
|
HistoryRows: history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtPrice formats a price value for display; returns "—" for zero/negative.
|
||||||
|
func fmtPrice(v float64) string {
|
||||||
|
if v <= 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f¢", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared DB helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// queryRegions returns sorted distinct region names.
|
||||||
|
func queryRegions() ([]string, error) {
|
||||||
|
rows, err := db.Query(`SELECT DISTINCT region FROM region_snapshots ORDER BY region ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var regions []string
|
||||||
|
for rows.Next() {
|
||||||
|
var region string
|
||||||
|
if err := rows.Scan(®ion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
regions = append(regions, region)
|
||||||
|
}
|
||||||
|
return regions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// querySnapshots fetches time-series snapshots for the given region (empty = global)
|
||||||
|
// and day window (daysStr "0" or "" means all).
|
||||||
|
func querySnapshots(region, daysStr string) ([]Snapshot, error) {
|
||||||
|
days := 7
|
||||||
|
if daysStr != "" {
|
||||||
|
if d, err := strconv.Atoi(daysStr); err == nil && d >= 0 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allTime := daysStr == "0"
|
||||||
|
|
||||||
|
var (
|
||||||
|
sqlRows *sql.Rows
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
if allTime {
|
||||||
|
sqlRows, err = db.Query(`
|
||||||
|
SELECT generated_at, regular_avg, regular_min, regular_max,
|
||||||
|
super_avg, diesel_avg, station_count
|
||||||
|
FROM region_snapshots
|
||||||
|
WHERE region = ?
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
`, region)
|
||||||
|
} else {
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
sqlRows, err = db.Query(`
|
||||||
|
SELECT generated_at, regular_avg, regular_min, regular_max,
|
||||||
|
super_avg, diesel_avg, station_count
|
||||||
|
FROM region_snapshots
|
||||||
|
WHERE region = ? AND generated_at >= ?
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
`, region, since)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer sqlRows.Close()
|
||||||
|
|
||||||
|
var snaps []Snapshot
|
||||||
|
for sqlRows.Next() {
|
||||||
|
var s Snapshot
|
||||||
|
if err := sqlRows.Scan(&s.GeneratedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
|
||||||
|
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snaps = append(snaps, s)
|
||||||
|
}
|
||||||
|
return snaps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global snapshots
|
||||||
|
if allTime {
|
||||||
|
sqlRows, err = db.Query(`
|
||||||
|
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
|
||||||
|
super_avg, diesel_avg, station_count
|
||||||
|
FROM snapshots
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
`)
|
||||||
|
} else {
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
sqlRows, err = db.Query(`
|
||||||
|
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
|
||||||
|
super_avg, diesel_avg, station_count
|
||||||
|
FROM snapshots
|
||||||
|
WHERE generated_at >= ?
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
`, since)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer sqlRows.Close()
|
||||||
|
|
||||||
|
var snaps []Snapshot
|
||||||
|
for sqlRows.Next() {
|
||||||
|
var s Snapshot
|
||||||
|
if err := sqlRows.Scan(&s.GeneratedAt, &s.FetchedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
|
||||||
|
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snaps = append(snaps, s)
|
||||||
|
}
|
||||||
|
return snaps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDeltas computes per-station price deltas from the station_prices table.
|
||||||
|
func buildDeltas() (map[string]StationDelta, error) {
|
||||||
|
since := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT address, generated_at, regular, super, diesel
|
||||||
|
FROM station_prices
|
||||||
|
WHERE generated_at >= ?
|
||||||
|
ORDER BY address ASC, generated_at ASC
|
||||||
|
`, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type priceRow struct {
|
||||||
|
generatedAt string
|
||||||
|
regular float64
|
||||||
|
super float64
|
||||||
|
diesel float64
|
||||||
|
}
|
||||||
|
type addrRows struct {
|
||||||
|
old *priceRow
|
||||||
|
cur *priceRow
|
||||||
|
}
|
||||||
|
byAddr := map[string]*addrRows{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var addr, genAt string
|
||||||
|
var reg, sup, die float64
|
||||||
|
if err := rows.Scan(&addr, &genAt, ®, &sup, &die); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ar, ok := byAddr[addr]
|
||||||
|
if !ok {
|
||||||
|
ar = &addrRows{}
|
||||||
|
byAddr[addr] = ar
|
||||||
|
}
|
||||||
|
row := &priceRow{generatedAt: genAt, regular: reg, super: sup, diesel: die}
|
||||||
|
if ar.old == nil {
|
||||||
|
ar.old = row
|
||||||
|
}
|
||||||
|
ar.cur = row
|
||||||
|
}
|
||||||
|
|
||||||
|
pctChange := func(cur, old float64) *float64 {
|
||||||
|
if old <= 0 || cur <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := (cur - old) / old * 100
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]StationDelta, len(byAddr))
|
||||||
|
for addr, ar := range byAddr {
|
||||||
|
if ar.old == nil || ar.cur == nil || ar.old.generatedAt == ar.cur.generatedAt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldT, err1 := time.Parse(time.RFC3339, ar.old.generatedAt)
|
||||||
|
curT, err2 := time.Parse(time.RFC3339, ar.cur.generatedAt)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[addr] = StationDelta{
|
||||||
|
Regular: pctChange(ar.cur.regular, ar.old.regular),
|
||||||
|
Super: pctChange(ar.cur.super, ar.old.super),
|
||||||
|
Diesel: pctChange(ar.cur.diesel, ar.old.diesel),
|
||||||
|
ElapsedHours: curT.Sub(oldT).Hours(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON API handlers (kept for backwards-compatibility) ──────────────────
|
||||||
|
|
||||||
|
func handleStations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cacheMu.RLock()
|
||||||
|
resp := cachedResp
|
||||||
|
cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if resp == nil {
|
||||||
|
http.Error(w, "data not yet available, please retry shortly", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
daysStr := r.URL.Query().Get("days")
|
||||||
|
snapshots, err := querySnapshots("", daysStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegions returns a sorted list of distinct region names from region_snapshots.
|
||||||
|
func handleRegions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
regions, err := queryRegions()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(regions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegionStats returns time-series snapshots for a specific region.
|
||||||
|
func handleRegionStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
region := r.URL.Query().Get("region")
|
||||||
|
if region == "" {
|
||||||
|
http.Error(w, "missing region parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
daysStr := r.URL.Query().Get("days")
|
||||||
|
snapshots, err := querySnapshots(region, daysStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StationDelta holds the price change percentage for a station over the available
|
||||||
|
// history window. Fields are pointers so that missing data serialises as JSON null.
|
||||||
|
// ElapsedHours is the actual time span between the oldest and newest snapshot used.
|
||||||
|
type StationDelta struct {
|
||||||
|
Regular *float64 `json:"regular"`
|
||||||
|
Super *float64 `json:"super"`
|
||||||
|
Diesel *float64 `json:"diesel"`
|
||||||
|
ElapsedHours float64 `json:"elapsedHours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStationDeltas returns a map of address → StationDelta.
|
||||||
|
func handleStationDeltas(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result, err := buildDeltas()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DB init ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// initDB opens (or creates) the SQLite database and ensures the schema exists.
|
// initDB opens (or creates) the SQLite database and ensures the schema exists.
|
||||||
func initDB(path string) (*sql.DB, error) {
|
func initDB(path string) (*sql.DB, error) {
|
||||||
d, err := sql.Open("sqlite", path)
|
d, err := sql.Open("sqlite", path+"?_busy_timeout=5000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +702,6 @@ func initDB(path string) (*sql.DB, error) {
|
||||||
return nil, fmt.Errorf("create station_prices table: %w", err)
|
return nil, fmt.Errorf("create station_prices table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep only the last 48 hours of per-station price history to bound table size.
|
|
||||||
_, err = d.Exec(`
|
_, err = d.Exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_station_prices_generated_at
|
CREATE INDEX IF NOT EXISTS idx_station_prices_generated_at
|
||||||
ON station_prices (generated_at)
|
ON station_prices (generated_at)
|
||||||
|
|
@ -188,6 +713,8 @@ func initDB(path string) (*sql.DB, error) {
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Poller ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// poller fetches immediately then every pollInterval.
|
// poller fetches immediately then every pollInterval.
|
||||||
func poller() {
|
func poller() {
|
||||||
fetchAndStore()
|
fetchAndStore()
|
||||||
|
|
@ -404,245 +931,7 @@ func computeRegionSnapshots(resp *StationsResponse) []RegionSnapshot {
|
||||||
return snaps
|
return snaps
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStations(w http.ResponseWriter, r *http.Request) {
|
// ── Upstream fetch ────────────────────────────────────────────────────────
|
||||||
cacheMu.RLock()
|
|
||||||
resp := cachedResp
|
|
||||||
cacheMu.RUnlock()
|
|
||||||
|
|
||||||
if resp == nil {
|
|
||||||
http.Error(w, "data not yet available, please retry shortly", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
daysStr := r.URL.Query().Get("days")
|
|
||||||
days := 7
|
|
||||||
if daysStr != "" {
|
|
||||||
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
|
||||||
days = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// days=0 means all data
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
if daysStr == "0" {
|
|
||||||
rows, err = db.Query(`
|
|
||||||
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
|
|
||||||
super_avg, diesel_avg, station_count
|
|
||||||
FROM snapshots
|
|
||||||
ORDER BY generated_at ASC
|
|
||||||
`)
|
|
||||||
} else {
|
|
||||||
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
|
|
||||||
rows, err = db.Query(`
|
|
||||||
SELECT generated_at, fetched_at, regular_avg, regular_min, regular_max,
|
|
||||||
super_avg, diesel_avg, station_count
|
|
||||||
FROM snapshots
|
|
||||||
WHERE generated_at >= ?
|
|
||||||
ORDER BY generated_at ASC
|
|
||||||
`, since)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
snapshots := []Snapshot{}
|
|
||||||
for rows.Next() {
|
|
||||||
var s Snapshot
|
|
||||||
if err := rows.Scan(&s.GeneratedAt, &s.FetchedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
|
|
||||||
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapshots = append(snapshots, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRegions returns a sorted list of distinct region names from region_snapshots.
|
|
||||||
func handleRegions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rows, err := db.Query(`SELECT DISTINCT region FROM region_snapshots ORDER BY region ASC`)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
regions := []string{}
|
|
||||||
for rows.Next() {
|
|
||||||
var region string
|
|
||||||
if err := rows.Scan(®ion); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
regions = append(regions, region)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(regions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRegionStats returns time-series snapshots for a specific region.
|
|
||||||
func handleRegionStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
region := r.URL.Query().Get("region")
|
|
||||||
if region == "" {
|
|
||||||
http.Error(w, "missing region parameter", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
daysStr := r.URL.Query().Get("days")
|
|
||||||
days := 7
|
|
||||||
if daysStr != "" {
|
|
||||||
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
|
||||||
days = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
if daysStr == "0" {
|
|
||||||
rows, err = db.Query(`
|
|
||||||
SELECT generated_at, regular_avg, regular_min, regular_max,
|
|
||||||
super_avg, diesel_avg, station_count
|
|
||||||
FROM region_snapshots
|
|
||||||
WHERE region = ?
|
|
||||||
ORDER BY generated_at ASC
|
|
||||||
`, region)
|
|
||||||
} else {
|
|
||||||
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
|
|
||||||
rows, err = db.Query(`
|
|
||||||
SELECT generated_at, regular_avg, regular_min, regular_max,
|
|
||||||
super_avg, diesel_avg, station_count
|
|
||||||
FROM region_snapshots
|
|
||||||
WHERE region = ? AND generated_at >= ?
|
|
||||||
ORDER BY generated_at ASC
|
|
||||||
`, region, since)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
snapshots := []Snapshot{}
|
|
||||||
for rows.Next() {
|
|
||||||
var s Snapshot
|
|
||||||
if err := rows.Scan(&s.GeneratedAt, &s.RegularAvg, &s.RegularMin, &s.RegularMax,
|
|
||||||
&s.SuperAvg, &s.DieselAvg, &s.StationCount); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapshots = append(snapshots, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StationDelta holds the price change percentage for a station over the available
|
|
||||||
// history window. Fields are pointers so that missing data serialises as JSON null.
|
|
||||||
// ElapsedHours is the actual time span between the oldest and newest snapshot used.
|
|
||||||
type StationDelta struct {
|
|
||||||
Regular *float64 `json:"regular"`
|
|
||||||
Super *float64 `json:"super"`
|
|
||||||
Diesel *float64 `json:"diesel"`
|
|
||||||
ElapsedHours float64 `json:"elapsedHours"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStationDeltas returns a map of address → StationDelta for all stations
|
|
||||||
// that have at least two distinct price records within the 48h retention window.
|
|
||||||
// The delta is computed between the oldest and newest available snapshot, and
|
|
||||||
// ElapsedHours carries the actual time span so the frontend can label it honestly.
|
|
||||||
func handleStationDeltas(w http.ResponseWriter, r *http.Request) {
|
|
||||||
since := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
|
|
||||||
|
|
||||||
// Fetch all rows in the retention window, oldest first.
|
|
||||||
rows, err := db.Query(`
|
|
||||||
SELECT address, generated_at, regular, super, diesel
|
|
||||||
FROM station_prices
|
|
||||||
WHERE generated_at >= ?
|
|
||||||
ORDER BY address ASC, generated_at ASC
|
|
||||||
`, since)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db query: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type priceRow struct {
|
|
||||||
generatedAt string
|
|
||||||
regular float64
|
|
||||||
super float64
|
|
||||||
diesel float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type addrRows struct {
|
|
||||||
old *priceRow // first (oldest) row seen for this address
|
|
||||||
cur *priceRow // last (newest) row seen for this address
|
|
||||||
}
|
|
||||||
byAddr := map[string]*addrRows{}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var addr, genAt string
|
|
||||||
var reg, sup, die float64
|
|
||||||
if err := rows.Scan(&addr, &genAt, ®, &sup, &die); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("db scan: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ar, ok := byAddr[addr]
|
|
||||||
if !ok {
|
|
||||||
ar = &addrRows{}
|
|
||||||
byAddr[addr] = ar
|
|
||||||
}
|
|
||||||
row := &priceRow{generatedAt: genAt, regular: reg, super: sup, diesel: die}
|
|
||||||
// First row seen becomes the baseline; every row updates current.
|
|
||||||
if ar.old == nil {
|
|
||||||
ar.old = row
|
|
||||||
}
|
|
||||||
ar.cur = row
|
|
||||||
}
|
|
||||||
|
|
||||||
pctChange := func(cur, old float64) *float64 {
|
|
||||||
if old <= 0 || cur <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v := (cur - old) / old * 100
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make(map[string]StationDelta, len(byAddr))
|
|
||||||
for addr, ar := range byAddr {
|
|
||||||
// Need at least two distinct snapshots to compute a meaningful delta.
|
|
||||||
if ar.old == nil || ar.cur == nil || ar.old.generatedAt == ar.cur.generatedAt {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
oldT, err1 := time.Parse(time.RFC3339, ar.old.generatedAt)
|
|
||||||
curT, err2 := time.Parse(time.RFC3339, ar.cur.generatedAt)
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[addr] = StationDelta{
|
|
||||||
Regular: pctChange(ar.cur.regular, ar.old.regular),
|
|
||||||
Super: pctChange(ar.cur.super, ar.old.super),
|
|
||||||
Diesel: pctChange(ar.cur.diesel, ar.old.diesel),
|
|
||||||
ElapsedHours: curT.Sub(oldT).Hours(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
||||||
json.NewEncoder(w).Encode(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchAndParse() (*StationsResponse, error) {
|
func fetchAndParse() (*StationsResponse, error) {
|
||||||
log.Println("Fetching GeoJSON data from upstream...")
|
log.Println("Fetching GeoJSON data from upstream...")
|
||||||
|
|
@ -665,7 +954,6 @@ func fetchAndParse() (*StationsResponse, error) {
|
||||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle gzip if the response is compressed
|
|
||||||
var reader io.Reader = resp.Body
|
var reader io.Reader = resp.Body
|
||||||
if resp.Header.Get("Content-Encoding") == "gzip" || strings.HasSuffix(geojsonURL, ".gz") {
|
if resp.Header.Get("Content-Encoding") == "gzip" || strings.HasSuffix(geojsonURL, ".gz") {
|
||||||
gz, err := gzip.NewReader(resp.Body)
|
gz, err := gzip.NewReader(resp.Body)
|
||||||
|
|
@ -681,7 +969,6 @@ func fetchAndParse() (*StationsResponse, error) {
|
||||||
return nil, fmt.Errorf("decoding geojson: %w", err)
|
return nil, fmt.Errorf("decoding geojson: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse features
|
|
||||||
var features []struct {
|
var features []struct {
|
||||||
Geometry struct {
|
Geometry struct {
|
||||||
Coordinates [2]float64 `json:"coordinates"`
|
Coordinates [2]float64 `json:"coordinates"`
|
||||||
|
|
@ -767,7 +1054,7 @@ func parsePrice(s string) float64 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
s = strings.TrimSuffix(s, "¢")
|
s = strings.TrimSuffix(s, "¢")
|
||||||
s = strings.TrimSuffix(s, "\u00a2") // cent sign
|
s = strings.TrimSuffix(s, "\u00a2")
|
||||||
|
|
||||||
var v float64
|
var v float64
|
||||||
_, err := fmt.Sscanf(s, "%f", &v)
|
_, err := fmt.Sscanf(s, "%f", &v)
|
||||||
|
|
|
||||||
|
|
@ -1,969 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Essence Québec</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@knadh/oat/oat.min.css">
|
|
||||||
<style>
|
|
||||||
/* ── Theme: Gouvernement du Québec blue ─────────────── */
|
|
||||||
:root {
|
|
||||||
--primary: rgb(9 87 151); /* #095797 */
|
|
||||||
--primary-foreground: rgb(255 255 255);
|
|
||||||
--ring: rgb(9 87 151);
|
|
||||||
}
|
|
||||||
/* Dark mode: slightly lighter so it stays accessible on dark backgrounds */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--primary: rgb(41 121 193);
|
|
||||||
--primary-foreground: rgb(255 255 255);
|
|
||||||
--ring: rgb(41 121 193);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Layout ─────────────────────────────────────────── */
|
|
||||||
html, body { height: 100%; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
/* ── Top nav ─────────────────────────────────────────── */
|
|
||||||
#topnav {
|
|
||||||
display: flex; align-items: center; gap: 0;
|
|
||||||
height: 44px; padding: 0 16px;
|
|
||||||
background: var(--card);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
z-index: 2000;
|
|
||||||
}
|
|
||||||
#topnav .brand {
|
|
||||||
font-weight: 700; font-size: 15px; letter-spacing: -0.2px;
|
|
||||||
margin-right: 24px; color: var(--foreground);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
#topnav nav { display: flex; gap: 2px; }
|
|
||||||
#topnav nav a {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
padding: 5px 12px; border-radius: 6px;
|
|
||||||
text-decoration: none; color: var(--muted-foreground);
|
|
||||||
font-size: 13px; font-weight: 500;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
#topnav nav a:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
#topnav nav a[aria-current="page"] {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
}
|
|
||||||
#topnav nav a svg { flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* ── App shell ───────────────────────────────────────── */
|
|
||||||
#app {
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
#content {
|
|
||||||
flex: 1; min-height: 0;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Map page ────────────────────────────────────────── */
|
|
||||||
#page-map { display: flex; flex-direction: column; flex: 1; position: relative; }
|
|
||||||
#map { flex: 1; min-height: 0; }
|
|
||||||
|
|
||||||
/* Slider + fuel panel */
|
|
||||||
#slider-panel {
|
|
||||||
position: absolute; top: 12px; left: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
z-index: 1000; font-size: 13px; min-width: 280px;
|
|
||||||
}
|
|
||||||
#slider-panel .fuel-btns { display: flex; gap: 6px; margin-bottom: 10px; }
|
|
||||||
#region-select {
|
|
||||||
width: 100%; margin-bottom: 10px;
|
|
||||||
padding: 5px 8px; font-size: 12px;
|
|
||||||
border: 1px solid var(--border); border-radius: 4px;
|
|
||||||
background: var(--secondary); color: var(--secondary-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#slider-panel label { display: block; margin-bottom: 6px; font-weight: 600; }
|
|
||||||
#price-slider { width: 100%; cursor: pointer; }
|
|
||||||
#slider-value {
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
margin-top: 4px; font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
#visible-count { margin-top: 6px; font-size: 11px; color: var(--muted-foreground); }
|
|
||||||
|
|
||||||
/* Map overlay panels — sit above the Leaflet tiles */
|
|
||||||
.map-panel {
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legend */
|
|
||||||
#legend {
|
|
||||||
position: absolute; bottom: 20px; right: 20px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
z-index: 1000; font-size: 13px; min-width: 180px;
|
|
||||||
}
|
|
||||||
#legend h3 { margin-bottom: 8px; font-size: 14px; }
|
|
||||||
.legend-gradient {
|
|
||||||
height: 16px; border-radius: 4px;
|
|
||||||
background: linear-gradient(to right, #16a34a, #65a30d, #ca8a04, #ea580c, #dc2626);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.legend-labels {
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
font-size: 11px; color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
#map-stats { margin-top: 8px; font-size: 11px; color: var(--muted-foreground); }
|
|
||||||
|
|
||||||
#last-updated {
|
|
||||||
position: absolute; bottom: 8px; left: 12px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
z-index: 1000; font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin-icon { filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35)); }
|
|
||||||
|
|
||||||
.cluster-info-tip {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px; height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--muted-foreground);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 10px; font-weight: 700;
|
|
||||||
cursor: default;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.cluster-info-tip:hover .cluster-info-tooltip { display: block; }
|
|
||||||
.cluster-info-tooltip {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
left: 22px; top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 12px; font-weight: 400;
|
|
||||||
white-space: nowrap;
|
|
||||||
z-index: 2000;
|
|
||||||
pointer-events: none;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fuel-btn {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--secondary);
|
|
||||||
color: var(--secondary-foreground);
|
|
||||||
cursor: pointer; font-size: 12px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.fuel-btn:hover { background: var(--muted); }
|
|
||||||
.fuel-btn.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading overlay ─────────────────────────────────── */
|
|
||||||
#loading {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: var(--background);
|
|
||||||
opacity: 0.92;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
z-index: 10000; font-size: 1.1em;
|
|
||||||
color: var(--foreground);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Stats page ──────────────────────────────────────── */
|
|
||||||
#page-stats { display: none; padding: 24px; overflow-y: auto; flex: 1; flex-direction: column; }
|
|
||||||
#page-stats h2 { margin-bottom: 20px; }
|
|
||||||
|
|
||||||
.stat-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
.stat-cards .card { margin: 0; }
|
|
||||||
.stat-cards .card header { padding-bottom: 4px; }
|
|
||||||
.stat-cards .card header p { font-size: 12px; color: var(--muted-foreground); margin: 0; }
|
|
||||||
.stat-value { font-size: 28px; font-weight: 700; margin: 4px 0 0; }
|
|
||||||
.stat-sub { font-size: 12px; color: var(--muted-foreground); margin-top: 2px; }
|
|
||||||
|
|
||||||
.chart-wrap {
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
.chart-wrap h3 { margin-bottom: 4px; }
|
|
||||||
.chart-wrap .chart-subtitle {
|
|
||||||
font-size: 12px; color: var(--muted-foreground); margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-btns { display: flex; gap: 6px; margin-bottom: 16px; }
|
|
||||||
.range-btn {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--secondary);
|
|
||||||
color: var(--secondary-foreground);
|
|
||||||
cursor: pointer; font-size: 12px;
|
|
||||||
}
|
|
||||||
.range-btn:hover { background: var(--muted); }
|
|
||||||
.range-btn.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#stats-chart-canvas { width: 100% !important; max-height: 320px; }
|
|
||||||
|
|
||||||
.no-data { color: var(--muted-foreground); font-size: 14px; padding: 24px 0; text-align: center; }
|
|
||||||
|
|
||||||
.table-wrap { overflow-x: auto; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
|
||||||
<div id="loading">
|
|
||||||
<div aria-busy="true"></div>
|
|
||||||
<span>Chargement des données...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="app">
|
|
||||||
|
|
||||||
<!-- ── Top nav ── -->
|
|
||||||
<header id="topnav">
|
|
||||||
<span class="brand">Essence QC</span>
|
|
||||||
<nav>
|
|
||||||
<a href="#" id="nav-map" aria-current="page" onclick="showPage('map');return false;">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/>
|
|
||||||
<line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/>
|
|
||||||
</svg>
|
|
||||||
Carte
|
|
||||||
</a>
|
|
||||||
<a href="#" id="nav-stats" onclick="showPage('stats');return false;">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
|
||||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
|
||||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
Statistiques
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ── Content area ── -->
|
|
||||||
<div id="content">
|
|
||||||
|
|
||||||
<!-- ── MAP PAGE ── -->
|
|
||||||
<div id="page-map">
|
|
||||||
<div id="map"></div>
|
|
||||||
|
|
||||||
<div id="slider-panel" class="map-panel">
|
|
||||||
<div class="fuel-btns">
|
|
||||||
<button class="fuel-btn active" data-fuel="regular">Régulier</button>
|
|
||||||
<button class="fuel-btn" data-fuel="super">Super</button>
|
|
||||||
<button class="fuel-btn" data-fuel="diesel">Diesel</button>
|
|
||||||
</div>
|
|
||||||
<select id="region-select">
|
|
||||||
<option value="">Toutes les régions</option>
|
|
||||||
</select>
|
|
||||||
<div style="display:flex; align-items:center; gap:6px; margin-bottom:10px;">
|
|
||||||
<div class="fuel-btns" id="cluster-btns" style="margin-bottom:0;">
|
|
||||||
<button class="fuel-btn cluster-btn" data-mode="min">Min</button>
|
|
||||||
<button class="fuel-btn cluster-btn active" data-mode="avg">Moy</button>
|
|
||||||
<button class="fuel-btn cluster-btn" data-mode="max">Max</button>
|
|
||||||
</div>
|
|
||||||
<div class="cluster-info-tip">
|
|
||||||
?
|
|
||||||
<div class="cluster-info-tooltip">
|
|
||||||
Prix affiché sur chaque groupe de stations :<br><br>
|
|
||||||
<strong>Min</strong> — prix le plus bas du groupe<br>
|
|
||||||
<strong>Moy</strong> — prix moyen du groupe<br>
|
|
||||||
<strong>Max</strong> — prix le plus élevé du groupe<br><br>
|
|
||||||
La couleur reflète également la valeur affichée.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label for="price-slider">Prix max: <span id="slider-label">-</span></label>
|
|
||||||
<input type="range" id="price-slider" min="0" max="300" step="0.5" value="300">
|
|
||||||
<div id="slider-value">
|
|
||||||
<span id="slider-min">-</span>
|
|
||||||
<span id="slider-max">-</span>
|
|
||||||
</div>
|
|
||||||
<div id="visible-count"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="last-updated" class="map-panel"></div>
|
|
||||||
|
|
||||||
<div id="legend" class="map-panel">
|
|
||||||
<h3 id="legend-title">Prix régulier (¢/L)</h3>
|
|
||||||
<div class="legend-gradient"></div>
|
|
||||||
<div class="legend-labels">
|
|
||||||
<span id="min-price">-</span>
|
|
||||||
<span id="max-price">-</span>
|
|
||||||
</div>
|
|
||||||
<div id="map-stats"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── STATS PAGE ── -->
|
|
||||||
<div id="page-stats">
|
|
||||||
<h2>Statistiques des prix</h2>
|
|
||||||
|
|
||||||
<!-- Region filter -->
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
<select id="stats-region-select" style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--secondary-foreground); cursor: pointer; min-width: 220px;">
|
|
||||||
<option value="">Toutes les régions</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="stat-cards">
|
|
||||||
<article class="card">
|
|
||||||
<header><p>Régulier — Moyenne</p></header>
|
|
||||||
<p class="stat-value" id="sc-reg-avg">—</p>
|
|
||||||
<p class="stat-sub">¢/L</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<header><p>Régulier — Min</p></header>
|
|
||||||
<p class="stat-value" id="sc-reg-min">—</p>
|
|
||||||
<p class="stat-sub">¢/L</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<header><p>Régulier — Max</p></header>
|
|
||||||
<p class="stat-value" id="sc-reg-max">—</p>
|
|
||||||
<p class="stat-sub">¢/L</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<header><p>Super — Moyenne</p></header>
|
|
||||||
<p class="stat-value" id="sc-sup-avg">—</p>
|
|
||||||
<p class="stat-sub">¢/L</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<header><p>Diesel — Moyenne</p></header>
|
|
||||||
<p class="stat-value" id="sc-die-avg">—</p>
|
|
||||||
<p class="stat-sub">¢/L</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<header><p>Stations</p></header>
|
|
||||||
<p class="stat-value" id="sc-count">—</p>
|
|
||||||
<p class="stat-sub">dernière mise à jour</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chart -->
|
|
||||||
<div class="chart-wrap">
|
|
||||||
<h3>Évolution des prix</h3>
|
|
||||||
<p class="chart-subtitle">Prix moyen par carburant (¢/L)</p>
|
|
||||||
<div class="range-btns">
|
|
||||||
<button class="range-btn active" data-days="7">7 jours</button>
|
|
||||||
<button class="range-btn" data-days="30">30 jours</button>
|
|
||||||
<button class="range-btn" data-days="0">Tout</button>
|
|
||||||
</div>
|
|
||||||
<canvas id="stats-chart-canvas"></canvas>
|
|
||||||
<p class="no-data" id="chart-no-data" style="display:none;">Pas encore assez de données historiques.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- History table -->
|
|
||||||
<div class="chart-wrap">
|
|
||||||
<h3>Historique récent</h3>
|
|
||||||
<p class="chart-subtitle">10 dernières mises à jour</p>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Régulier moy.</th>
|
|
||||||
<th>Régulier min</th>
|
|
||||||
<th>Régulier max</th>
|
|
||||||
<th>Super moy.</th>
|
|
||||||
<th>Diesel moy.</th>
|
|
||||||
<th>Stations</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="history-tbody">
|
|
||||||
<tr><td colspan="7" style="text-align:center;color:var(--muted-foreground);">Chargement...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- #content -->
|
|
||||||
</div><!-- #app -->
|
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
|
||||||
<script src="https://unpkg.com/@knadh/oat/oat.min.js" defer></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// ── Page navigation ──────────────────────────────────────
|
|
||||||
let mapInitialized = false;
|
|
||||||
let statsChart = null;
|
|
||||||
let currentDays = 7;
|
|
||||||
let stationDeltas = {}; // address → {regular, super, diesel} pct change or null
|
|
||||||
|
|
||||||
function showPage(name) {
|
|
||||||
document.getElementById('page-map').style.display = name === 'map' ? 'flex' : 'none';
|
|
||||||
document.getElementById('page-stats').style.display = name === 'stats' ? 'flex' : 'none';
|
|
||||||
|
|
||||||
document.getElementById('nav-map').removeAttribute('aria-current');
|
|
||||||
document.getElementById('nav-stats').removeAttribute('aria-current');
|
|
||||||
document.getElementById('nav-' + name).setAttribute('aria-current', 'page');
|
|
||||||
|
|
||||||
if (name === 'map' && mapInitialized) {
|
|
||||||
setTimeout(() => map.invalidateSize(), 50);
|
|
||||||
}
|
|
||||||
if (name === 'stats') {
|
|
||||||
loadStatsRegions();
|
|
||||||
loadStats(currentDays);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Map setup ─────────────────────────────────────────────
|
|
||||||
const map = L.map('map', { zoomControl: false }).setView([46.8, -71.2], 7);
|
|
||||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a> | Données: <a href="https://regieessencequebec.ca">Régie de l\'énergie du Québec</a>',
|
|
||||||
maxZoom: 18,
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
let currentFuel = 'regular';
|
|
||||||
let currentRegion = '';
|
|
||||||
let clusterMode = 'avg'; // 'min' | 'avg' | 'max'
|
|
||||||
let allStations = [];
|
|
||||||
let allMarkers = [];
|
|
||||||
let visibleSet = new Set();
|
|
||||||
let minPrice = 0, maxPrice = 300;
|
|
||||||
let sliderTimer = null;
|
|
||||||
let clusterGroup = L.markerClusterGroup({
|
|
||||||
maxClusterRadius: 50,
|
|
||||||
spiderfyOnMaxZoom: true,
|
|
||||||
showCoverageOnHover: false,
|
|
||||||
zoomToBoundsOnClick: true,
|
|
||||||
disableClusteringAtZoom: 14,
|
|
||||||
iconCreateFunction: function(cluster) {
|
|
||||||
const children = cluster.getAllChildMarkers();
|
|
||||||
const prices = children.map(m => m.fuelPrices[currentFuel]).filter(p => p > 0);
|
|
||||||
const count = cluster.getChildCount();
|
|
||||||
let fill, label;
|
|
||||||
if (prices.length > 0) {
|
|
||||||
let displayPrice;
|
|
||||||
if (clusterMode === 'min') displayPrice = Math.min(...prices);
|
|
||||||
else if (clusterMode === 'max') displayPrice = Math.max(...prices);
|
|
||||||
else displayPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
|
|
||||||
fill = priceColor(displayPrice, minPrice, maxPrice);
|
|
||||||
label = displayPrice.toFixed(1);
|
|
||||||
} else {
|
|
||||||
fill = '#9ca3af';
|
|
||||||
label = count;
|
|
||||||
}
|
|
||||||
const size = count < 10 ? 44 : count < 50 ? 52 : 60;
|
|
||||||
const r = size / 2;
|
|
||||||
const fs = count < 10 ? 11 : count < 50 ? 10 : 9;
|
|
||||||
const svg = `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
|
||||||
<circle cx="${r}" cy="${r}" r="${r - 2}" fill="${fill}" stroke="#fff" stroke-width="2.5"/>
|
|
||||||
<text x="${r}" y="${r + fs * 0.38}" text-anchor="middle" fill="#fff" font-size="${fs}" font-weight="700" font-family="sans-serif">${label}</text>
|
|
||||||
</svg>`;
|
|
||||||
return L.divIcon({ html: svg, className: '', iconSize: [size, size], iconAnchor: [r, r] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all([
|
|
||||||
fetch('/api/stations').then(r => r.json()),
|
|
||||||
fetch('/api/station-deltas').then(r => r.json()).catch(() => ({})),
|
|
||||||
])
|
|
||||||
.then(([data, deltas]) => {
|
|
||||||
document.getElementById('loading').style.display = 'none';
|
|
||||||
mapInitialized = true;
|
|
||||||
allStations = data.stations;
|
|
||||||
stationDeltas = deltas || {};
|
|
||||||
|
|
||||||
// Populate region dropdown from data
|
|
||||||
const regions = [...new Set(allStations.map(s => s.region).filter(Boolean))].sort();
|
|
||||||
const sel = document.getElementById('region-select');
|
|
||||||
regions.forEach(r => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = r; opt.textContent = r;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
sel.addEventListener('change', () => {
|
|
||||||
currentRegion = sel.value;
|
|
||||||
if (allStations.length) rebuildMarkers(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.lastUpdated) {
|
|
||||||
const d = new Date(data.lastUpdated);
|
|
||||||
document.getElementById('last-updated').textContent =
|
|
||||||
'Dernière mise à jour: ' + d.toLocaleString('fr-CA', {
|
|
||||||
year: 'numeric', month: 'long', day: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('map-stats').textContent = allStations.length + ' stations';
|
|
||||||
|
|
||||||
// Pre-build all markers once — reused for every slider update.
|
|
||||||
prebuildMarkers();
|
|
||||||
map.addLayer(clusterGroup);
|
|
||||||
document.getElementById('legend').style.display = 'block';
|
|
||||||
|
|
||||||
const slider = document.getElementById('price-slider');
|
|
||||||
slider.addEventListener('input', () => {
|
|
||||||
const val = parseFloat(slider.value);
|
|
||||||
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
|
|
||||||
// Update label immediately; defer the heavy map work.
|
|
||||||
clearTimeout(sliderTimer);
|
|
||||||
sliderTimer = setTimeout(() => applyPriceFilter(val), 80);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
loading.innerHTML = '<span style="color:var(--destructive,#ef4444)">Erreur: ' + err.message + '</span>';
|
|
||||||
setTimeout(() => { loading.style.display = 'none'; }, 4000);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.cluster-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
if (btn.dataset.mode === clusterMode) return;
|
|
||||||
clusterMode = btn.dataset.mode;
|
|
||||||
document.querySelectorAll('.cluster-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === clusterMode));
|
|
||||||
if (allStations.length) clusterGroup.refreshClusters();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
if (btn.dataset.fuel === currentFuel) return;
|
|
||||||
currentFuel = btn.dataset.fuel;
|
|
||||||
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(b => b.classList.toggle('active', b.dataset.fuel === currentFuel));
|
|
||||||
if (allStations.length) rebuildMarkers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add/remove only the markers that changed relative to the current filter.
|
|
||||||
function applyPriceFilter(priceMax, fitMap) {
|
|
||||||
const toAdd = [];
|
|
||||||
const toRemove = [];
|
|
||||||
|
|
||||||
allStations.forEach((s, i) => {
|
|
||||||
const price = s[currentFuel];
|
|
||||||
const inRegion = !currentRegion || s.region === currentRegion;
|
|
||||||
// Show grey markers (no data for this fuel) always within region; filter only priced ones
|
|
||||||
const visible = inRegion && (price <= 0 || price <= priceMax);
|
|
||||||
if (visible && !visibleSet.has(i)) {
|
|
||||||
toAdd.push(allMarkers[i]);
|
|
||||||
visibleSet.add(i);
|
|
||||||
} else if (!visible && visibleSet.has(i)) {
|
|
||||||
toRemove.push(allMarkers[i]);
|
|
||||||
visibleSet.delete(i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (toRemove.length) clusterGroup.removeLayers(toRemove);
|
|
||||||
if (toAdd.length) clusterGroup.addLayers(toAdd);
|
|
||||||
|
|
||||||
document.getElementById('visible-count').textContent =
|
|
||||||
visibleSet.size + ' / ' + allStations.length + ' stations';
|
|
||||||
|
|
||||||
if (fitMap && currentRegion) {
|
|
||||||
const latlngs = allStations
|
|
||||||
.filter(s => s.region === currentRegion)
|
|
||||||
.map(s => [s.lat, s.lng]);
|
|
||||||
if (latlngs.length > 0) {
|
|
||||||
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40], maxZoom: 13 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build all markers once at load time. Color is fixed by price; only
|
|
||||||
// visibility changes after this point.
|
|
||||||
function prebuildMarkers() {
|
|
||||||
rebuildMarkers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebuildMarkers(fitMap) {
|
|
||||||
// Clear existing markers from cluster
|
|
||||||
clusterGroup.clearLayers();
|
|
||||||
visibleSet.clear();
|
|
||||||
|
|
||||||
// Recompute min/max for the active fuel, scoped to the selected region
|
|
||||||
const scopedStations = currentRegion
|
|
||||||
? allStations.filter(s => s.region === currentRegion)
|
|
||||||
: allStations;
|
|
||||||
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
|
|
||||||
minPrice = prices.length ? Math.min(...prices) : 0;
|
|
||||||
maxPrice = prices.length ? Math.max(...prices) : 300;
|
|
||||||
|
|
||||||
// Update slider range
|
|
||||||
const slider = document.getElementById('price-slider');
|
|
||||||
slider.min = Math.floor(minPrice);
|
|
||||||
slider.max = Math.ceil(maxPrice);
|
|
||||||
slider.value = Math.ceil(maxPrice);
|
|
||||||
document.getElementById('slider-min').textContent = Math.floor(minPrice) + '¢';
|
|
||||||
document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢';
|
|
||||||
document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L';
|
|
||||||
|
|
||||||
// Update legend
|
|
||||||
const fuelLabel = { regular: 'Régulier', super: 'Super', diesel: 'Diesel' }[currentFuel];
|
|
||||||
document.getElementById('legend-title').textContent = `Prix ${fuelLabel.toLowerCase()} (¢/L)`;
|
|
||||||
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
|
|
||||||
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
|
||||||
|
|
||||||
const GREY = '#9ca3af';
|
|
||||||
allMarkers = allStations.map(s => {
|
|
||||||
const price = s[currentFuel];
|
|
||||||
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
|
|
||||||
const label = price > 0 ? price.toFixed(1) : '—';
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: circleSvg(fill, label), className: '',
|
|
||||||
iconSize: [36, 36], iconAnchor: [18, 18], popupAnchor: [0, -20],
|
|
||||||
});
|
|
||||||
const marker = L.marker([s.lat, s.lng], { icon });
|
|
||||||
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
|
|
||||||
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`;
|
|
||||||
const d = stationDeltas[s.address] || {};
|
|
||||||
const eh = d.elapsedHours != null ? d.elapsedHours : null;
|
|
||||||
let popup = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
|
|
||||||
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
|
|
||||||
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
|
|
||||||
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
|
|
||||||
marker.bindPopup(popup);
|
|
||||||
return marker;
|
|
||||||
});
|
|
||||||
|
|
||||||
applyPriceFilter(parseFloat(slider.value), fitMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
function circleSvg(fill, label) {
|
|
||||||
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
|
|
||||||
<circle cx="18" cy="18" r="17" fill="${fill}" stroke="#fff" stroke-width="2"/>
|
|
||||||
<text x="18" y="22" text-anchor="middle" fill="#fff" font-size="10" font-weight="700" font-family="sans-serif">${label}</text>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function priceColor(price, min, max) {
|
|
||||||
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
|
|
||||||
// green (cheap) → yellow → red (expensive)
|
|
||||||
const colors = [
|
|
||||||
[22,163,74],[101,163,13],[202,138,4],[234,88,12],[220,38,38],
|
|
||||||
];
|
|
||||||
const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
|
|
||||||
const frac = t * (colors.length - 1) - idx;
|
|
||||||
const c0 = colors[idx], c1 = colors[idx + 1];
|
|
||||||
return `rgb(${Math.round(c0[0]+(c1[0]-c0[0])*frac)},${Math.round(c0[1]+(c1[1]-c0[1])*frac)},${Math.round(c0[2]+(c1[2]-c0[2])*frac)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Brand colours ─────────────────────────────────────────
|
|
||||||
// Real brand identity colours where known; others get a
|
|
||||||
// deterministic colour picked from a curated distinct-hue palette.
|
|
||||||
const BRAND_COLORS = {
|
|
||||||
'ultramar': '#0057a8',
|
|
||||||
'petro-canada': '#e4002b',
|
|
||||||
'esso': '#003087',
|
|
||||||
'shell': '#dd1d21',
|
|
||||||
'pioneer': '#f47920',
|
|
||||||
'couche-tard': '#c8102e',
|
|
||||||
'circle k': '#c8102e',
|
|
||||||
'crevier': '#e3000f',
|
|
||||||
'irving': '#006747',
|
|
||||||
'gilbert': '#00796b',
|
|
||||||
'sonic': '#7b2d8b',
|
|
||||||
'dépanneur': '#475569',
|
|
||||||
'indépendant': '#334155',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 12-colour fallback palette for unknown brands (evenly spaced hues,
|
|
||||||
// all with sufficient contrast against white).
|
|
||||||
const FALLBACK_PALETTE = [
|
|
||||||
'#b45309','#0369a1','#065f46','#7c3aed',
|
|
||||||
'#be185d','#0f766e','#b91c1c','#1d4ed8',
|
|
||||||
'#15803d','#a21caf','#c2410c','#0e7490',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Return the background hex colour for a brand name.
|
|
||||||
function brandColor(brand) {
|
|
||||||
const key = (brand || '').toLowerCase().trim();
|
|
||||||
if (BRAND_COLORS[key]) return BRAND_COLORS[key];
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
|
|
||||||
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
// WCAG relative luminance of a hex colour string.
|
|
||||||
function luminance(hex) {
|
|
||||||
const r = parseInt(hex.slice(1,3), 16) / 255;
|
|
||||||
const g = parseInt(hex.slice(3,5), 16) / 255;
|
|
||||||
const b = parseInt(hex.slice(5,7), 16) / 255;
|
|
||||||
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
||||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pick white or black foreground for best contrast against bg.
|
|
||||||
function contrastFg(bgHex) {
|
|
||||||
const L = luminance(bgHex);
|
|
||||||
const onWhite = (L + 0.05) / (0.05); // contrast vs white
|
|
||||||
const onBlack = (1.05) / (L + 0.05); // contrast vs black
|
|
||||||
return onBlack > onWhite ? '#ffffff' : '#000000';
|
|
||||||
}
|
|
||||||
|
|
||||||
function brandBadgeHtml(brand) {
|
|
||||||
if (!brand) return '';
|
|
||||||
const bg = brandColor(brand);
|
|
||||||
const fg = contrastFg(bg);
|
|
||||||
return `<span class="badge" style="--primary:${bg};--primary-foreground:${fg}">${brand}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns an inline HTML snippet showing the 24h price change.
|
|
||||||
// pct is a number (percentage) or null/undefined when no history is available.
|
|
||||||
function priceDeltaHtml(pct, elapsedHours) {
|
|
||||||
if (pct == null) return '';
|
|
||||||
if (Math.abs(pct) < 0.05) return ''; // treat sub-0.05% as unchanged
|
|
||||||
const up = pct > 0;
|
|
||||||
const color = up ? '#dc2626' : '#16a34a'; // red up, green down
|
|
||||||
const arrow = up
|
|
||||||
? '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,8 5,2 8,8"/></svg>'
|
|
||||||
: '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,2 5,8 8,2"/></svg>';
|
|
||||||
const sign = up ? '+' : '';
|
|
||||||
let timeLabel;
|
|
||||||
if (elapsedHours == null) {
|
|
||||||
timeLabel = '';
|
|
||||||
} else if (elapsedHours < 1) {
|
|
||||||
timeLabel = ' (< 1h)';
|
|
||||||
} else {
|
|
||||||
const h = Math.min(24, Math.round(elapsedHours));
|
|
||||||
timeLabel = ` (${h}h)`;
|
|
||||||
}
|
|
||||||
return ` <span style="color:${color};font-size:11px;white-space:nowrap;">${arrow}${sign}${pct.toFixed(2)}%${timeLabel}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Statistics page ───────────────────────────────────────
|
|
||||||
let currentStatsRegion = '';
|
|
||||||
let statsRegionsLoaded = false;
|
|
||||||
|
|
||||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
currentDays = parseInt(btn.dataset.days);
|
|
||||||
loadStats(currentDays);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadStatsRegions() {
|
|
||||||
if (statsRegionsLoaded) return;
|
|
||||||
fetch('/api/regions')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(regions => {
|
|
||||||
statsRegionsLoaded = true;
|
|
||||||
const sel = document.getElementById('stats-region-select');
|
|
||||||
regions.forEach(region => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = region;
|
|
||||||
opt.textContent = region;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(err => console.error('regions error:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('stats-region-select').addEventListener('change', function() {
|
|
||||||
currentStatsRegion = this.value;
|
|
||||||
loadStats(currentDays);
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadStats(days) {
|
|
||||||
const region = currentStatsRegion;
|
|
||||||
const url = region
|
|
||||||
? '/api/stats/region?region=' + encodeURIComponent(region) + '&days=' + days
|
|
||||||
: '/api/stats?days=' + days;
|
|
||||||
fetch(url)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
const snaps = data.snapshots || [];
|
|
||||||
renderStatCards(snaps);
|
|
||||||
renderChart(snaps);
|
|
||||||
renderHistoryTable(snaps);
|
|
||||||
})
|
|
||||||
.catch(err => console.error('stats error:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(v) {
|
|
||||||
return v > 0 ? v.toFixed(1) : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Like fmt() but appends the unit; shows '—' (no unit) for missing values.
|
|
||||||
function fmtUnit(v, unit) {
|
|
||||||
return v > 0 ? v.toFixed(1) + unit : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatCards(snaps) {
|
|
||||||
// Use the latest snapshot for the summary cards
|
|
||||||
if (!snaps.length) return;
|
|
||||||
const last = snaps[snaps.length - 1];
|
|
||||||
document.getElementById('sc-reg-avg').textContent = fmt(last.regularAvg);
|
|
||||||
document.getElementById('sc-reg-min').textContent = fmt(last.regularMin);
|
|
||||||
document.getElementById('sc-reg-max').textContent = fmt(last.regularMax);
|
|
||||||
document.getElementById('sc-sup-avg').textContent = fmt(last.superAvg);
|
|
||||||
document.getElementById('sc-die-avg').textContent = fmt(last.dieselAvg);
|
|
||||||
document.getElementById('sc-count').textContent = last.stationCount || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read a CSS variable from the root element (works for both light and dark).
|
|
||||||
function cssVar(name) {
|
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChart(snaps) {
|
|
||||||
const canvas = document.getElementById('stats-chart-canvas');
|
|
||||||
const noData = document.getElementById('chart-no-data');
|
|
||||||
|
|
||||||
if (!snaps.length) {
|
|
||||||
canvas.style.display = 'none';
|
|
||||||
noData.style.display = 'block';
|
|
||||||
if (statsChart) { statsChart.destroy(); statsChart = null; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.style.display = 'block';
|
|
||||||
noData.style.display = 'none';
|
|
||||||
|
|
||||||
// Resolve theme-aware colours at render time.
|
|
||||||
const textColor = cssVar('--foreground') || (darkMode() ? '#e4e4e7' : '#18181b');
|
|
||||||
const gridColor = cssVar('--border') || (darkMode() ? '#3f3f46' : '#e4e4e7');
|
|
||||||
const tooltipBg = cssVar('--card') || (darkMode() ? '#18181b' : '#ffffff');
|
|
||||||
const tooltipText = cssVar('--card-foreground') || textColor;
|
|
||||||
|
|
||||||
const labels = snaps.map(s => {
|
|
||||||
const d = new Date(s.generatedAt);
|
|
||||||
return d.toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const pt = snaps.length > 60 ? 0 : 3;
|
|
||||||
const datasets = [
|
|
||||||
{
|
|
||||||
label: 'Régulier',
|
|
||||||
data: snaps.map(s => s.regularAvg > 0 ? +s.regularAvg.toFixed(2) : null),
|
|
||||||
borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)',
|
|
||||||
tension: 0.3, fill: false, pointRadius: pt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Super',
|
|
||||||
data: snaps.map(s => s.superAvg > 0 ? +s.superAvg.toFixed(2) : null),
|
|
||||||
borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)',
|
|
||||||
tension: 0.3, fill: false, pointRadius: pt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Diesel',
|
|
||||||
data: snaps.map(s => s.dieselAvg > 0 ? +s.dieselAvg.toFixed(2) : null),
|
|
||||||
borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)',
|
|
||||||
tension: 0.3, fill: false, pointRadius: pt,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const chartOpts = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: true,
|
|
||||||
interaction: { mode: 'index', intersect: false },
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'top',
|
|
||||||
labels: { color: textColor },
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: tooltipBg,
|
|
||||||
titleColor: tooltipText,
|
|
||||||
bodyColor: tooltipText,
|
|
||||||
borderColor: gridColor,
|
|
||||||
borderWidth: 1,
|
|
||||||
callbacks: {
|
|
||||||
label: ctx => ctx.dataset.label + ': ' + (ctx.parsed.y != null ? ctx.parsed.y.toFixed(1) + '¢/L' : '—'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: { maxTicksLimit: 10, maxRotation: 30, color: textColor },
|
|
||||||
grid: { color: gridColor },
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
title: { display: true, text: '¢/L', color: textColor },
|
|
||||||
ticks: { callback: v => v + '¢', color: textColor },
|
|
||||||
grid: { color: gridColor },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (statsChart) {
|
|
||||||
statsChart.data.labels = labels;
|
|
||||||
statsChart.data.datasets = datasets;
|
|
||||||
// Reapply theme-sensitive options in case theme changed since creation.
|
|
||||||
statsChart.options = chartOpts;
|
|
||||||
statsChart.update();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
statsChart = new Chart(canvas, { type: 'line', data: { labels, datasets }, options: chartOpts });
|
|
||||||
}
|
|
||||||
|
|
||||||
function darkMode() {
|
|
||||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render chart when system theme changes so colours update immediately.
|
|
||||||
if (window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
||||||
if (statsChart) loadStats(currentDays);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHistoryTable(snaps) {
|
|
||||||
const tbody = document.getElementById('history-tbody');
|
|
||||||
// Show last 10, most recent first
|
|
||||||
const rows = snaps.slice(-10).reverse();
|
|
||||||
if (!rows.length) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--muted-foreground);">Aucune donnée</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.innerHTML = rows.map(s => {
|
|
||||||
const d = new Date(s.generatedAt).toLocaleString('fr-CA', {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit'
|
|
||||||
});
|
|
||||||
return `<tr>
|
|
||||||
<td>${d}</td>
|
|
||||||
<td>${fmtUnit(s.regularAvg, '¢')}</td>
|
|
||||||
<td>${fmtUnit(s.regularMin, '¢')}</td>
|
|
||||||
<td>${fmtUnit(s.regularMax, '¢')}</td>
|
|
||||||
<td>${fmtUnit(s.superAvg, '¢')}</td>
|
|
||||||
<td>${fmtUnit(s.dieselAvg, '¢')}</td>
|
|
||||||
<td>${s.stationCount}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
287
static/map.js
Normal file
287
static/map.js
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
// map.js — Leaflet map logic for Essence QC
|
||||||
|
// Reads window.__stations and window.__deltas injected by the server.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let currentFuel = 'regular';
|
||||||
|
let currentRegion = '';
|
||||||
|
let clusterMode = 'avg'; // 'min' | 'avg' | 'max'
|
||||||
|
let allStations = window.__stations || [];
|
||||||
|
let allMarkers = [];
|
||||||
|
let visibleSet = new Set();
|
||||||
|
let minPrice = 0, maxPrice = 300;
|
||||||
|
let sliderTimer = null;
|
||||||
|
const stationDeltas = window.__deltas || {};
|
||||||
|
|
||||||
|
// ── Map setup (deferred to init section below) ─────────────
|
||||||
|
let map;
|
||||||
|
let clusterGroup;
|
||||||
|
|
||||||
|
// ── Colour helpers ─────────────────────────────────────────
|
||||||
|
function priceColor(price, min, max) {
|
||||||
|
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
|
||||||
|
const colors = [
|
||||||
|
[22, 163, 74], [101, 163, 13], [202, 138, 4], [234, 88, 12], [220, 38, 38],
|
||||||
|
];
|
||||||
|
const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
|
||||||
|
const frac = t * (colors.length - 1) - idx;
|
||||||
|
const c0 = colors[idx], c1 = colors[idx + 1];
|
||||||
|
return `rgb(${Math.round(c0[0] + (c1[0] - c0[0]) * frac)},${Math.round(c0[1] + (c1[1] - c0[1]) * frac)},${Math.round(c0[2] + (c1[2] - c0[2]) * frac)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleSvg(fill, label) {
|
||||||
|
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
|
||||||
|
<circle cx="18" cy="18" r="17" fill="${fill}" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="18" y="22" text-anchor="middle" fill="#fff" font-size="10" font-weight="700" font-family="sans-serif">${label}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Brand colours ──────────────────────────────────────────
|
||||||
|
const BRAND_COLORS = {
|
||||||
|
'ultramar': '#0057a8',
|
||||||
|
'petro-canada': '#e4002b',
|
||||||
|
'esso': '#003087',
|
||||||
|
'shell': '#dd1d21',
|
||||||
|
'pioneer': '#f47920',
|
||||||
|
'couche-tard': '#c8102e',
|
||||||
|
'circle k': '#c8102e',
|
||||||
|
'crevier': '#e3000f',
|
||||||
|
'irving': '#006747',
|
||||||
|
'gilbert': '#00796b',
|
||||||
|
'sonic': '#7b2d8b',
|
||||||
|
'dépanneur': '#475569',
|
||||||
|
'indépendant': '#334155',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_PALETTE = [
|
||||||
|
'#b45309', '#0369a1', '#065f46', '#7c3aed',
|
||||||
|
'#be185d', '#0f766e', '#b91c1c', '#1d4ed8',
|
||||||
|
'#15803d', '#a21caf', '#c2410c', '#0e7490',
|
||||||
|
];
|
||||||
|
|
||||||
|
function brandColor(brand) {
|
||||||
|
const key = (brand || '').toLowerCase().trim();
|
||||||
|
if (BRAND_COLORS[key]) return BRAND_COLORS[key];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
|
||||||
|
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(hex) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastFg(bgHex) {
|
||||||
|
const L = luminance(bgHex);
|
||||||
|
const onWhite = (L + 0.05) / 0.05;
|
||||||
|
const onBlack = 1.05 / (L + 0.05);
|
||||||
|
return onBlack > onWhite ? '#ffffff' : '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandBadgeHtml(brand) {
|
||||||
|
if (!brand) return '';
|
||||||
|
const bg = brandColor(brand);
|
||||||
|
const fg = contrastFg(bg);
|
||||||
|
return `<span class="badge" style="--primary:${bg};--primary-foreground:${fg}">${brand}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceDeltaHtml(pct, elapsedHours) {
|
||||||
|
if (pct == null) return '';
|
||||||
|
if (Math.abs(pct) < 0.05) return '';
|
||||||
|
const up = pct > 0;
|
||||||
|
const color = up ? '#dc2626' : '#16a34a';
|
||||||
|
const arrow = up
|
||||||
|
? '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,8 5,2 8,8"/></svg>'
|
||||||
|
: '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,2 5,8 8,2"/></svg>';
|
||||||
|
const sign = up ? '+' : '';
|
||||||
|
let timeLabel = '';
|
||||||
|
if (elapsedHours != null) {
|
||||||
|
if (elapsedHours < 1) timeLabel = ' (< 1h)';
|
||||||
|
else timeLabel = ` (${Math.min(24, Math.round(elapsedHours))}h)`;
|
||||||
|
}
|
||||||
|
return ` <span style="color:${color};font-size:11px;white-space:nowrap;">${arrow}${sign}${pct.toFixed(2)}%${timeLabel}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marker building ────────────────────────────────────────
|
||||||
|
function rebuildMarkers(fitMap) {
|
||||||
|
clusterGroup.clearLayers();
|
||||||
|
visibleSet.clear();
|
||||||
|
|
||||||
|
const scopedStations = currentRegion
|
||||||
|
? allStations.filter(s => s.region === currentRegion)
|
||||||
|
: allStations;
|
||||||
|
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
|
||||||
|
minPrice = prices.length ? Math.min(...prices) : 0;
|
||||||
|
maxPrice = prices.length ? Math.max(...prices) : 300;
|
||||||
|
|
||||||
|
const slider = document.getElementById('price-slider');
|
||||||
|
slider.min = Math.floor(minPrice);
|
||||||
|
slider.max = Math.ceil(maxPrice);
|
||||||
|
slider.value = Math.ceil(maxPrice);
|
||||||
|
document.getElementById('slider-min').textContent = Math.floor(minPrice) + '¢';
|
||||||
|
document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢';
|
||||||
|
document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L';
|
||||||
|
|
||||||
|
const fuelLabel = { regular: 'Régulier', super: 'Super', diesel: 'Diesel' }[currentFuel];
|
||||||
|
document.getElementById('legend-title').textContent = `Prix ${fuelLabel.toLowerCase()} (¢/L)`;
|
||||||
|
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
|
||||||
|
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
||||||
|
|
||||||
|
const GREY = '#9ca3af';
|
||||||
|
allMarkers = allStations.map(s => {
|
||||||
|
const price = s[currentFuel];
|
||||||
|
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
|
||||||
|
const label = price > 0 ? price.toFixed(1) : '—';
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: circleSvg(fill, label), className: '',
|
||||||
|
iconSize: [36, 36], iconAnchor: [18, 18], popupAnchor: [0, -20],
|
||||||
|
});
|
||||||
|
const marker = L.marker([s.lat, s.lng], { icon });
|
||||||
|
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
|
||||||
|
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`;
|
||||||
|
const d = stationDeltas[s.address] || {};
|
||||||
|
const eh = d.elapsedHours != null ? d.elapsedHours : null;
|
||||||
|
let popup = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
|
||||||
|
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
|
||||||
|
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
|
||||||
|
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
|
||||||
|
marker.bindPopup(popup);
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
applyPriceFilter(parseFloat(slider.value), fitMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPriceFilter(priceMax, fitMap) {
|
||||||
|
const toAdd = [];
|
||||||
|
const toRemove = [];
|
||||||
|
|
||||||
|
allStations.forEach((s, i) => {
|
||||||
|
const price = s[currentFuel];
|
||||||
|
const inRegion = !currentRegion || s.region === currentRegion;
|
||||||
|
const visible = inRegion && (price <= 0 || price <= priceMax);
|
||||||
|
if (visible && !visibleSet.has(i)) {
|
||||||
|
toAdd.push(allMarkers[i]);
|
||||||
|
visibleSet.add(i);
|
||||||
|
} else if (!visible && visibleSet.has(i)) {
|
||||||
|
toRemove.push(allMarkers[i]);
|
||||||
|
visibleSet.delete(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toRemove.length) clusterGroup.removeLayers(toRemove);
|
||||||
|
if (toAdd.length) clusterGroup.addLayers(toAdd);
|
||||||
|
|
||||||
|
document.getElementById('visible-count').textContent =
|
||||||
|
visibleSet.size + ' / ' + allStations.length + ' stations';
|
||||||
|
|
||||||
|
if (fitMap && currentRegion) {
|
||||||
|
const latlngs = allStations
|
||||||
|
.filter(s => s.region === currentRegion)
|
||||||
|
.map(s => [s.lat, s.lng]);
|
||||||
|
if (latlngs.length > 0) {
|
||||||
|
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40], maxZoom: 13 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire up controls ───────────────────────────────────────
|
||||||
|
function bindControls() {
|
||||||
|
document.getElementById('region-select').addEventListener('change', function () {
|
||||||
|
currentRegion = this.value;
|
||||||
|
rebuildMarkers(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('price-slider').addEventListener('input', function () {
|
||||||
|
const val = parseFloat(this.value);
|
||||||
|
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
|
||||||
|
clearTimeout(sliderTimer);
|
||||||
|
sliderTimer = setTimeout(() => applyPriceFilter(val), 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.cluster-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.mode === clusterMode) return;
|
||||||
|
clusterMode = btn.dataset.mode;
|
||||||
|
document.querySelectorAll('.cluster-btn').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.mode === clusterMode));
|
||||||
|
if (allStations.length) clusterGroup.refreshClusters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.fuel === currentFuel) return;
|
||||||
|
currentFuel = btn.dataset.fuel;
|
||||||
|
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.fuel === currentFuel));
|
||||||
|
rebuildMarkers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialise ─────────────────────────────────────────────
|
||||||
|
const loadingEl = document.getElementById('loading');
|
||||||
|
try {
|
||||||
|
// Create map and cluster group now that the DOM is ready.
|
||||||
|
map = L.map('map', { zoomControl: false }).setView([46.8, -71.2], 7);
|
||||||
|
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a> | Données: <a href="https://regieessencequebec.ca">Régie de l\'énergie du Québec</a>',
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
clusterGroup = L.markerClusterGroup({
|
||||||
|
maxClusterRadius: 50,
|
||||||
|
spiderfyOnMaxZoom: true,
|
||||||
|
showCoverageOnHover: false,
|
||||||
|
zoomToBoundsOnClick: true,
|
||||||
|
disableClusteringAtZoom: 14,
|
||||||
|
iconCreateFunction: function (cluster) {
|
||||||
|
const children = cluster.getAllChildMarkers();
|
||||||
|
const prices = children.map(m => m.fuelPrices[currentFuel]).filter(p => p > 0);
|
||||||
|
const count = cluster.getChildCount();
|
||||||
|
let fill, label;
|
||||||
|
if (prices.length > 0) {
|
||||||
|
let displayPrice;
|
||||||
|
if (clusterMode === 'min') displayPrice = Math.min(...prices);
|
||||||
|
else if (clusterMode === 'max') displayPrice = Math.max(...prices);
|
||||||
|
else displayPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
|
||||||
|
fill = priceColor(displayPrice, minPrice, maxPrice);
|
||||||
|
label = displayPrice.toFixed(1);
|
||||||
|
} else {
|
||||||
|
fill = '#9ca3af';
|
||||||
|
label = count;
|
||||||
|
}
|
||||||
|
const size = count < 10 ? 44 : count < 50 ? 52 : 60;
|
||||||
|
const r = size / 2;
|
||||||
|
const fs = count < 10 ? 11 : count < 50 ? 10 : 9;
|
||||||
|
const svg = `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
||||||
|
<circle cx="${r}" cy="${r}" r="${r - 2}" fill="${fill}" stroke="#fff" stroke-width="2.5"/>
|
||||||
|
<text x="${r}" y="${r + fs * 0.38}" text-anchor="middle" fill="#fff" font-size="${fs}" font-weight="700" font-family="sans-serif">${label}</text>
|
||||||
|
</svg>`;
|
||||||
|
return L.divIcon({ html: svg, className: '', iconSize: [size, size], iconAnchor: [r, r] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
document.getElementById('map-stats').textContent = allStations.length + ' stations';
|
||||||
|
|
||||||
|
rebuildMarkers();
|
||||||
|
map.addLayer(clusterGroup);
|
||||||
|
|
||||||
|
bindControls();
|
||||||
|
|
||||||
|
// Expose for use after htmx page-swap back to the map page.
|
||||||
|
window.__mapInvalidate = function () {
|
||||||
|
setTimeout(() => map.invalidateSize(), 50);
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
loadingEl.innerHTML = '<span style="color:#dc2626;padding:20px;text-align:center;">Erreur JavaScript: ' + err.message + '</span>';
|
||||||
|
}
|
||||||
|
})();
|
||||||
117
static/stats.js
Normal file
117
static/stats.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// stats.js — Chart.js logic for Essence QC stats page.
|
||||||
|
// Reads window.__statsData injected by the server into the stats-content fragment.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let statsChart = null;
|
||||||
|
|
||||||
|
function darkMode() {
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssVar(name) {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
const snaps = window.__statsData;
|
||||||
|
const canvas = document.getElementById('stats-chart-canvas');
|
||||||
|
if (!snaps || !snaps.length || !canvas) return;
|
||||||
|
|
||||||
|
// Destroy previous instance if the fragment was re-swapped.
|
||||||
|
if (statsChart) {
|
||||||
|
statsChart.destroy();
|
||||||
|
statsChart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textColor = cssVar('--foreground') || (darkMode() ? '#e4e4e7' : '#18181b');
|
||||||
|
const gridColor = cssVar('--border') || (darkMode() ? '#3f3f46' : '#e4e4e7');
|
||||||
|
const tooltipBg = cssVar('--card') || (darkMode() ? '#18181b' : '#ffffff');
|
||||||
|
const tooltipText = cssVar('--card-foreground') || textColor;
|
||||||
|
|
||||||
|
const labels = snaps.map(s => {
|
||||||
|
const d = new Date(s.generatedAt);
|
||||||
|
return d.toLocaleString('fr-CA', {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pt = snaps.length > 60 ? 0 : 3;
|
||||||
|
const datasets = [
|
||||||
|
{
|
||||||
|
label: 'Régulier',
|
||||||
|
data: snaps.map(s => s.regularAvg > 0 ? +s.regularAvg.toFixed(2) : null),
|
||||||
|
borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)',
|
||||||
|
tension: 0.3, fill: false, pointRadius: pt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Super',
|
||||||
|
data: snaps.map(s => s.superAvg > 0 ? +s.superAvg.toFixed(2) : null),
|
||||||
|
borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)',
|
||||||
|
tension: 0.3, fill: false, pointRadius: pt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Diesel',
|
||||||
|
data: snaps.map(s => s.dieselAvg > 0 ? +s.dieselAvg.toFixed(2) : null),
|
||||||
|
borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)',
|
||||||
|
tension: 0.3, fill: false, pointRadius: pt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartOpts = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: { color: textColor },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: tooltipBg,
|
||||||
|
titleColor: tooltipText,
|
||||||
|
bodyColor: tooltipText,
|
||||||
|
borderColor: gridColor,
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => ctx.dataset.label + ': ' +
|
||||||
|
(ctx.parsed.y != null ? ctx.parsed.y.toFixed(1) + '¢/L' : '—'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { maxTicksLimit: 10, maxRotation: 30, color: textColor },
|
||||||
|
grid: { color: gridColor },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: '¢/L', color: textColor },
|
||||||
|
ticks: { callback: v => v + '¢', color: textColor },
|
||||||
|
grid: { color: gridColor },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
statsChart = new Chart(canvas, { type: 'line', data: { labels, datasets }, options: chartOpts });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called inline from stats-content.html after __statsData is set.
|
||||||
|
window.__initStatsChart = initChart;
|
||||||
|
|
||||||
|
// Re-render on dark-mode change.
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
if (statsChart) initChart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-init after any htmx swap of #stats-content.
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||||
|
if (evt.detail && evt.detail.target && evt.detail.target.id === 'stats-content') {
|
||||||
|
// __statsData was updated inline by the swapped fragment; just init the chart.
|
||||||
|
if (window.__statsData) initChart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
316
templates/layout.html
Normal file
316
templates/layout.html
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Essence Québec</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@knadh/oat/oat.min.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@knadh/oat/oat.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ── Theme: Gouvernement du Québec blue ─────────────── */
|
||||||
|
:root {
|
||||||
|
--primary: rgb(9 87 151); /* #095797 */
|
||||||
|
--primary-foreground: rgb(255 255 255);
|
||||||
|
--ring: rgb(9 87 151);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--primary: rgb(41 121 193);
|
||||||
|
--primary-foreground: rgb(255 255 255);
|
||||||
|
--ring: rgb(41 121 193);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────── */
|
||||||
|
html, body { height: 100%; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* ── Top nav ─────────────────────────────────────────── */
|
||||||
|
#topnav {
|
||||||
|
display: flex; align-items: center; gap: 0;
|
||||||
|
height: 44px; padding: 0 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
#topnav .brand {
|
||||||
|
font-weight: 700; font-size: 15px; letter-spacing: -0.2px;
|
||||||
|
margin-right: 24px; color: var(--foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#topnav nav { display: flex; gap: 2px; }
|
||||||
|
#topnav nav a {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 5px 12px; border-radius: 6px;
|
||||||
|
text-decoration: none; color: var(--muted-foreground);
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#topnav nav a:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
#topnav nav a[aria-current="page"] {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
#topnav nav a svg { flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── App shell ───────────────────────────────────────── */
|
||||||
|
#app {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#content {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Map page ────────────────────────────────────────── */
|
||||||
|
#page-map { display: flex; flex-direction: column; flex: 1; position: relative; }
|
||||||
|
#map { flex: 1; min-height: 0; }
|
||||||
|
|
||||||
|
/* Slider + fuel panel */
|
||||||
|
#slider-panel {
|
||||||
|
position: absolute; top: 12px; left: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
z-index: 1000; font-size: 13px; min-width: 280px;
|
||||||
|
}
|
||||||
|
#slider-panel .fuel-btns { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||||
|
#region-select {
|
||||||
|
width: 100%; margin-bottom: 10px;
|
||||||
|
padding: 5px 8px; font-size: 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: var(--secondary); color: var(--secondary-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#slider-panel label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||||
|
#price-slider { width: 100%; cursor: pointer; }
|
||||||
|
#slider-value {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
margin-top: 4px; font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
#visible-count { margin-top: 6px; font-size: 11px; color: var(--muted-foreground); }
|
||||||
|
|
||||||
|
/* Map overlay panels — sit above the Leaflet tiles */
|
||||||
|
.map-panel {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
#legend {
|
||||||
|
position: absolute; bottom: 20px; right: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
z-index: 1000; font-size: 13px; min-width: 180px;
|
||||||
|
}
|
||||||
|
#legend h3 { margin-bottom: 8px; font-size: 14px; }
|
||||||
|
.legend-gradient {
|
||||||
|
height: 16px; border-radius: 4px;
|
||||||
|
background: linear-gradient(to right, #16a34a, #65a30d, #ca8a04, #ea580c, #dc2626);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.legend-labels {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
font-size: 11px; color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
#map-stats { margin-top: 8px; font-size: 11px; color: var(--muted-foreground); }
|
||||||
|
|
||||||
|
#last-updated {
|
||||||
|
position: absolute; bottom: 8px; left: 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
z-index: 1000; font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon { filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35)); }
|
||||||
|
|
||||||
|
.cluster-info-tip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--muted-foreground);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
cursor: default;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cluster-info-tip:hover .cluster-info-tooltip { display: block; }
|
||||||
|
.cluster-info-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 22px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px; font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 2000;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuel-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
cursor: pointer; font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.fuel-btn:hover { background: var(--muted); }
|
||||||
|
.fuel-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading overlay ─────────────────────────────────── */
|
||||||
|
#loading {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: var(--background);
|
||||||
|
opacity: 0.92;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 10000; font-size: 1.1em;
|
||||||
|
color: var(--foreground);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats page ──────────────────────────────────────── */
|
||||||
|
#page-stats { display: flex; padding: 24px; overflow-y: auto; flex: 1; flex-direction: column; }
|
||||||
|
#page-stats h2 { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.stat-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.stat-cards .card { margin: 0; }
|
||||||
|
.stat-cards .card header { padding-bottom: 4px; }
|
||||||
|
.stat-cards .card header p { font-size: 12px; color: var(--muted-foreground); margin: 0; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; margin: 4px 0 0; }
|
||||||
|
.stat-sub { font-size: 12px; color: var(--muted-foreground); margin-top: 2px; }
|
||||||
|
|
||||||
|
.chart-wrap {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.chart-wrap h3 { margin-bottom: 4px; }
|
||||||
|
.chart-wrap .chart-subtitle {
|
||||||
|
font-size: 12px; color: var(--muted-foreground); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btns { display: flex; gap: 6px; margin-bottom: 16px; }
|
||||||
|
.range-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.range-btn:hover { background: var(--muted); }
|
||||||
|
.range-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats-chart-canvas { width: 100% !important; max-height: 320px; }
|
||||||
|
|
||||||
|
.no-data { color: var(--muted-foreground); font-size: 14px; padding: 24px 0; text-align: center; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
/* ── htmx loading indicator ──────────────────────────── */
|
||||||
|
.htmx-request #stats-content { opacity: 0.5; transition: opacity 0.2s; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- ── Top nav ── -->
|
||||||
|
<header id="topnav">
|
||||||
|
<span class="brand">Essence QC</span>
|
||||||
|
<nav>
|
||||||
|
<a href="/map"
|
||||||
|
id="nav-map"
|
||||||
|
{{if eq .Page "map"}}aria-current="page"{{end}}
|
||||||
|
hx-get="/map"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-push-url="/map"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="window.__onPageSwap && window.__onPageSwap('map')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/>
|
||||||
|
<line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
Carte
|
||||||
|
</a>
|
||||||
|
<a href="/stats"
|
||||||
|
id="nav-stats"
|
||||||
|
{{if eq .Page "stats"}}aria-current="page"{{end}}
|
||||||
|
hx-get="/stats"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-push-url="/stats"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="window.__onPageSwap && window.__onPageSwap('stats')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
Statistiques
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Content area (htmx swap target) ── -->
|
||||||
|
<div id="content">
|
||||||
|
{{if eq .Page "map"}}{{template "map-content" .}}{{end}}
|
||||||
|
{{if eq .Page "stats"}}{{template "stats-content-shell" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update nav active state after htmx page swaps.
|
||||||
|
window.__onPageSwap = function(page) {
|
||||||
|
document.querySelectorAll('#topnav nav a').forEach(a => a.removeAttribute('aria-current'));
|
||||||
|
const target = document.getElementById('nav-' + page);
|
||||||
|
if (target) target.setAttribute('aria-current', 'page');
|
||||||
|
};
|
||||||
|
|
||||||
|
// On browser back/forward, trigger the right content reload.
|
||||||
|
window.addEventListener('popstate', function() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const page = path === '/stats' ? 'stats' : 'map';
|
||||||
|
htmx.ajax('GET', '/' + page, { target: '#content', swap: 'innerHTML' });
|
||||||
|
window.__onPageSwap(page);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
templates/map.html
Normal file
68
templates/map.html
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{{define "map-content"}}
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div id="loading">
|
||||||
|
<div aria-busy="true"></div>
|
||||||
|
<span>Chargement des données...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MAP PAGE ── -->
|
||||||
|
<div id="page-map">
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div id="slider-panel" class="map-panel">
|
||||||
|
<div class="fuel-btns">
|
||||||
|
<button class="fuel-btn active" data-fuel="regular">Régulier</button>
|
||||||
|
<button class="fuel-btn" data-fuel="super">Super</button>
|
||||||
|
<button class="fuel-btn" data-fuel="diesel">Diesel</button>
|
||||||
|
</div>
|
||||||
|
<select id="region-select">
|
||||||
|
<option value="">Toutes les régions</option>
|
||||||
|
{{range .Regions}}
|
||||||
|
<option value="{{.}}">{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; margin-bottom:10px;">
|
||||||
|
<div class="fuel-btns" id="cluster-btns" style="margin-bottom:0;">
|
||||||
|
<button class="fuel-btn cluster-btn" data-mode="min">Min</button>
|
||||||
|
<button class="fuel-btn cluster-btn active" data-mode="avg">Moy</button>
|
||||||
|
<button class="fuel-btn cluster-btn" data-mode="max">Max</button>
|
||||||
|
</div>
|
||||||
|
<div class="cluster-info-tip">
|
||||||
|
?
|
||||||
|
<div class="cluster-info-tooltip">
|
||||||
|
Prix affiché sur chaque groupe de stations :<br><br>
|
||||||
|
<strong>Min</strong> — prix le plus bas du groupe<br>
|
||||||
|
<strong>Moy</strong> — prix moyen du groupe<br>
|
||||||
|
<strong>Max</strong> — prix le plus élevé du groupe<br><br>
|
||||||
|
La couleur reflète également la valeur affichée.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="price-slider">Prix max: <span id="slider-label">-</span></label>
|
||||||
|
<input type="range" id="price-slider" min="0" max="300" step="0.5" value="300">
|
||||||
|
<div id="slider-value">
|
||||||
|
<span id="slider-min">-</span>
|
||||||
|
<span id="slider-max">-</span>
|
||||||
|
</div>
|
||||||
|
<div id="visible-count"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="last-updated" class="map-panel">{{.LastUpdated}}</div>
|
||||||
|
|
||||||
|
<div id="legend" class="map-panel">
|
||||||
|
<h3 id="legend-title">Prix régulier (¢/L)</h3>
|
||||||
|
<div class="legend-gradient"></div>
|
||||||
|
<div class="legend-labels">
|
||||||
|
<span id="min-price">-</span>
|
||||||
|
<span id="max-price">-</span>
|
||||||
|
</div>
|
||||||
|
<div id="map-stats">{{.StationCount}} stations</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__stations = {{.StationsJSON}};
|
||||||
|
window.__deltas = {{.DeltasJSON}};
|
||||||
|
</script>
|
||||||
|
<script src="/map.js"></script>
|
||||||
|
{{end}}
|
||||||
106
templates/stats-content.html
Normal file
106
templates/stats-content.html
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="stat-cards">
|
||||||
|
<article class="card">
|
||||||
|
<header><p>Régulier — Moyenne</p></header>
|
||||||
|
<p class="stat-value">{{fmtPrice .Last.RegularAvg}}</p>
|
||||||
|
<p class="stat-sub">¢/L</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<header><p>Régulier — Min</p></header>
|
||||||
|
<p class="stat-value">{{fmtPrice .Last.RegularMin}}</p>
|
||||||
|
<p class="stat-sub">¢/L</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<header><p>Régulier — Max</p></header>
|
||||||
|
<p class="stat-value">{{fmtPrice .Last.RegularMax}}</p>
|
||||||
|
<p class="stat-sub">¢/L</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<header><p>Super — Moyenne</p></header>
|
||||||
|
<p class="stat-value">{{fmtPrice .Last.SuperAvg}}</p>
|
||||||
|
<p class="stat-sub">¢/L</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<header><p>Diesel — Moyenne</p></header>
|
||||||
|
<p class="stat-value">{{fmtPrice .Last.DieselAvg}}</p>
|
||||||
|
<p class="stat-sub">¢/L</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<header><p>Stations</p></header>
|
||||||
|
<p class="stat-value">{{if .Last.StationCount}}{{.Last.StationCount}}{{else}}—{{end}}</p>
|
||||||
|
<p class="stat-sub">dernière mise à jour</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<h3>Évolution des prix</h3>
|
||||||
|
<p class="chart-subtitle">Prix moyen par carburant (¢/L)</p>
|
||||||
|
<div class="range-btns">
|
||||||
|
<button class="range-btn {{if eq .Days 7}}active{{end}}"
|
||||||
|
hx-get="/stats/content"
|
||||||
|
hx-target="#stats-content"
|
||||||
|
hx-include="#stats-region-select"
|
||||||
|
hx-vals='{"days": "7"}'
|
||||||
|
onclick="document.getElementById('stats-days').value='7'">7 jours</button>
|
||||||
|
<button class="range-btn {{if eq .Days 30}}active{{end}}"
|
||||||
|
hx-get="/stats/content"
|
||||||
|
hx-target="#stats-content"
|
||||||
|
hx-include="#stats-region-select"
|
||||||
|
hx-vals='{"days": "30"}'
|
||||||
|
onclick="document.getElementById('stats-days').value='30'">30 jours</button>
|
||||||
|
<button class="range-btn {{if eq .Days 0}}active{{end}}"
|
||||||
|
hx-get="/stats/content"
|
||||||
|
hx-target="#stats-content"
|
||||||
|
hx-include="#stats-region-select"
|
||||||
|
hx-vals='{"days": "0"}'
|
||||||
|
onclick="document.getElementById('stats-days').value='0'">Tout</button>
|
||||||
|
</div>
|
||||||
|
{{if .Snapshots}}
|
||||||
|
<canvas id="stats-chart-canvas"></canvas>
|
||||||
|
<script>
|
||||||
|
window.__statsData = {{.SnapshotsJSON}};
|
||||||
|
if (window.__initStatsChart) window.__initStatsChart();
|
||||||
|
</script>
|
||||||
|
{{else}}
|
||||||
|
<p class="no-data">Pas encore assez de données historiques.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History table -->
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<h3>Historique récent</h3>
|
||||||
|
<p class="chart-subtitle">10 dernières mises à jour</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Régulier moy.</th>
|
||||||
|
<th>Régulier min</th>
|
||||||
|
<th>Régulier max</th>
|
||||||
|
<th>Super moy.</th>
|
||||||
|
<th>Diesel moy.</th>
|
||||||
|
<th>Stations</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{if .HistoryRows}}
|
||||||
|
{{range .HistoryRows}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Date}}</td>
|
||||||
|
<td>{{.RegularAvg}}</td>
|
||||||
|
<td>{{.RegularMin}}</td>
|
||||||
|
<td>{{.RegularMax}}</td>
|
||||||
|
<td>{{.SuperAvg}}</td>
|
||||||
|
<td>{{.DieselAvg}}</td>
|
||||||
|
<td>{{.StationCount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" style="text-align:center;color:var(--muted-foreground);">Aucune donnée</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
templates/stats.html
Normal file
34
templates/stats.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{{define "stats-content-shell"}}
|
||||||
|
<!-- ── STATS PAGE ── -->
|
||||||
|
<div id="page-stats">
|
||||||
|
<h2>Statistiques des prix</h2>
|
||||||
|
|
||||||
|
<!-- Region filter -->
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<select id="stats-region-select"
|
||||||
|
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--secondary-foreground); cursor: pointer; min-width: 220px;"
|
||||||
|
hx-get="/stats/content"
|
||||||
|
hx-target="#stats-content"
|
||||||
|
hx-include="#stats-days"
|
||||||
|
hx-trigger="change"
|
||||||
|
name="region">
|
||||||
|
<option value="">Toutes les régions</option>
|
||||||
|
{{range .Regions}}
|
||||||
|
<option value="{{.}}">{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<!-- Hidden input tracks current days selection for region changes -->
|
||||||
|
<input type="hidden" id="stats-days" name="days" value="7">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic content: cards + chart + table -->
|
||||||
|
<div id="stats-content"
|
||||||
|
hx-get="/stats/content?days=7"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div style="text-align:center; color:var(--muted-foreground); padding:40px 0;">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/stats.js"></script>
|
||||||
|
{{end}}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue