better prouct
This commit is contained in:
parent
60aa3ad422
commit
ecab4d550c
6 changed files with 969 additions and 229 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
/essence
|
/essence
|
||||||
/result
|
/result
|
||||||
|
*.db
|
||||||
|
|
|
||||||
16
go.mod
16
go.mod
|
|
@ -1,3 +1,17 @@
|
||||||
module github.com/polen/essence
|
module github.com/polen/essence
|
||||||
|
|
||||||
go 1.26.1
|
go 1.25.0
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.48.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
|
||||||
51
go.sum
Normal file
51
go.sum
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||||
|
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
231
main.go
231
main.go
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -10,9 +11,12 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
|
|
@ -21,7 +25,7 @@ var staticFiles embed.FS
|
||||||
const (
|
const (
|
||||||
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
|
geojsonURL = "https://regieessencequebec.ca/stations.geojson.gz"
|
||||||
defaultPort = "8080"
|
defaultPort = "8080"
|
||||||
cacheTTL = 5 * time.Minute
|
pollInterval = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeoJSON structures matching the upstream format.
|
// GeoJSON structures matching the upstream format.
|
||||||
|
|
@ -57,35 +61,195 @@ type StationsResponse struct {
|
||||||
Stations []Station `json:"stations"`
|
Stations []Station `json:"stations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot holds the aggregated statistics for one fetch.
|
||||||
|
type Snapshot struct {
|
||||||
|
GeneratedAt string `json:"generatedAt"`
|
||||||
|
FetchedAt string `json:"fetchedAt"`
|
||||||
|
RegularAvg float64 `json:"regularAvg"`
|
||||||
|
RegularMin float64 `json:"regularMin"`
|
||||||
|
RegularMax float64 `json:"regularMax"`
|
||||||
|
SuperAvg float64 `json:"superAvg"`
|
||||||
|
DieselAvg float64 `json:"dieselAvg"`
|
||||||
|
StationCount int `json:"stationCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsResponse struct {
|
||||||
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
|
}
|
||||||
|
|
||||||
// In-memory cache
|
// In-memory cache
|
||||||
var (
|
var (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cachedResp *StationsResponse
|
cachedResp *StationsResponse
|
||||||
cacheExpiry time.Time
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = defaultPort
|
port = defaultPort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbPath := os.Getenv("ESSENCE_DB")
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "./essence.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
db, err = initDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db init: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Initial fetch, then background poll every 5 minutes.
|
||||||
|
go poller()
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFiles, "static")
|
staticSub, err := fs.Sub(staticFiles, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("static files: %v", err)
|
log.Fatalf("static files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.HandleFunc("/api/stations", handleStations)
|
http.HandleFunc("/api/stations", handleStations)
|
||||||
|
http.HandleFunc("/api/stats", handleStats)
|
||||||
http.Handle("/", http.FileServer(http.FS(staticSub)))
|
http.Handle("/", 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStations(w http.ResponseWriter, r *http.Request) {
|
// initDB opens (or creates) the SQLite database and ensures the schema exists.
|
||||||
resp, err := getStations()
|
func initDB(path string) (*sql.DB, error) {
|
||||||
|
d, err := sql.Open("sqlite", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("error: %v", err), http.StatusInternalServerError)
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
generated_at TEXT PRIMARY KEY,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
regular_avg REAL,
|
||||||
|
regular_min REAL,
|
||||||
|
regular_max REAL,
|
||||||
|
super_avg REAL,
|
||||||
|
diesel_avg REAL,
|
||||||
|
station_count INTEGER
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create table: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// poller fetches immediately then every pollInterval.
|
||||||
|
func poller() {
|
||||||
|
fetchAndStore()
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
fetchAndStore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAndStore fetches upstream data, updates the in-memory cache, and
|
||||||
|
// persists a snapshot to SQLite if the data has a new generated_at value.
|
||||||
|
func fetchAndStore() {
|
||||||
|
resp, err := fetchAndParse()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("fetch error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheMu.Lock()
|
||||||
|
cachedResp = resp
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
if resp.LastUpdated == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute aggregates.
|
||||||
|
snap := computeSnapshot(resp)
|
||||||
|
|
||||||
|
// Only insert if this generated_at is new.
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO snapshots
|
||||||
|
(generated_at, fetched_at, regular_avg, regular_min, regular_max, super_avg, diesel_avg, station_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
snap.GeneratedAt,
|
||||||
|
snap.FetchedAt,
|
||||||
|
snap.RegularAvg,
|
||||||
|
snap.RegularMin,
|
||||||
|
snap.RegularMax,
|
||||||
|
snap.SuperAvg,
|
||||||
|
snap.DieselAvg,
|
||||||
|
snap.StationCount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("db insert error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeSnapshot derives aggregate statistics from a StationsResponse.
|
||||||
|
func computeSnapshot(resp *StationsResponse) Snapshot {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
snap := Snapshot{
|
||||||
|
GeneratedAt: resp.LastUpdated,
|
||||||
|
FetchedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
var regularSum, superSum, dieselSum float64
|
||||||
|
var regularCount, superCount, dieselCount int
|
||||||
|
snap.RegularMin = 1<<53 - 1
|
||||||
|
snap.RegularMax = 0
|
||||||
|
|
||||||
|
for _, s := range resp.Stations {
|
||||||
|
if s.Regular > 0 {
|
||||||
|
regularSum += s.Regular
|
||||||
|
regularCount++
|
||||||
|
if s.Regular < snap.RegularMin {
|
||||||
|
snap.RegularMin = s.Regular
|
||||||
|
}
|
||||||
|
if s.Regular > snap.RegularMax {
|
||||||
|
snap.RegularMax = s.Regular
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Super > 0 {
|
||||||
|
superSum += s.Super
|
||||||
|
superCount++
|
||||||
|
}
|
||||||
|
if s.Diesel > 0 {
|
||||||
|
dieselSum += s.Diesel
|
||||||
|
dieselCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.StationCount = len(resp.Stations)
|
||||||
|
if regularCount > 0 {
|
||||||
|
snap.RegularAvg = regularSum / float64(regularCount)
|
||||||
|
} else {
|
||||||
|
snap.RegularMin = 0
|
||||||
|
}
|
||||||
|
if superCount > 0 {
|
||||||
|
snap.SuperAvg = superSum / float64(superCount)
|
||||||
|
}
|
||||||
|
if dieselCount > 0 {
|
||||||
|
snap.DieselAvg = dieselSum / float64(dieselCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,25 +258,54 @@ func handleStations(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStations() (*StationsResponse, error) {
|
func handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
cacheMu.RLock()
|
daysStr := r.URL.Query().Get("days")
|
||||||
if cachedResp != nil && time.Now().Before(cacheExpiry) {
|
days := 7
|
||||||
defer cacheMu.RUnlock()
|
if daysStr != "" {
|
||||||
return cachedResp, nil
|
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cacheMu.RUnlock()
|
|
||||||
|
|
||||||
resp, err := fetchAndParse()
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheMu.Lock()
|
w.Header().Set("Content-Type", "application/json")
|
||||||
cachedResp = resp
|
json.NewEncoder(w).Encode(StatsResponse{Snapshots: snapshots})
|
||||||
cacheExpiry = time.Now().Add(cacheTTL)
|
|
||||||
cacheMu.Unlock()
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAndParse() (*StationsResponse, error) {
|
func fetchAndParse() (*StationsResponse, error) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ in
|
||||||
description = "Port the Essence web server listens on.";
|
description = "Port the Essence web server listens on.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/var/lib/essence";
|
||||||
|
description = "Directory where the SQLite database is stored.";
|
||||||
|
};
|
||||||
|
|
||||||
openFirewall = lib.mkOption {
|
openFirewall = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
|
|
@ -37,6 +43,7 @@ in
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
PORT = toString cfg.port;
|
PORT = toString cfg.port;
|
||||||
|
ESSENCE_DB = "${cfg.dataDir}/essence.db";
|
||||||
};
|
};
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
|
|
@ -45,6 +52,8 @@ in
|
||||||
RestartSec = 5;
|
RestartSec = 5;
|
||||||
|
|
||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
|
StateDirectory = "essence";
|
||||||
|
StateDirectoryMode = "0750";
|
||||||
|
|
||||||
# Hardening
|
# Hardening
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
|
|
|
||||||
|
|
@ -3,90 +3,262 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Essence Québec - Carte des prix</title>
|
<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@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.css" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.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>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
/* ── Theme: Gouvernement du Québec blue ─────────────── */
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
:root {
|
||||||
#map { width: 100vw; height: 100vh; }
|
--primary: rgb(9 87 151); /* #095797 */
|
||||||
|
--primary-foreground: rgb(255 255 255);
|
||||||
#loading {
|
--ring: rgb(9 87 151);
|
||||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
}
|
||||||
background: rgba(255, 255, 255, 0.9);
|
/* Dark mode: slightly lighter so it stays accessible on dark backgrounds */
|
||||||
display: flex; align-items: center; justify-content: center;
|
@media (prefers-color-scheme: dark) {
|
||||||
z-index: 10000; font-size: 1.2em; color: #333;
|
:root {
|
||||||
|
--primary: rgb(41 121 193);
|
||||||
|
--primary-foreground: rgb(255 255 255);
|
||||||
|
--ring: rgb(41 121 193);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls panel (top-left) */
|
/* ── Layout ─────────────────────────────────────────── */
|
||||||
#controls {
|
html, body { height: 100%; margin: 0; padding: 0; }
|
||||||
position: fixed; top: 12px; left: 56px;
|
|
||||||
background: white; padding: 10px 14px;
|
|
||||||
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
z-index: 2000; font-size: 13px;
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
}
|
|
||||||
.mode-btn {
|
|
||||||
padding: 5px 12px; border: 1px solid #ccc; border-radius: 4px;
|
|
||||||
background: #f5f5f5; cursor: pointer; font-size: 13px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.mode-btn:hover { background: #e8e8e8; }
|
|
||||||
.mode-btn.active { background: #095797; color: white; border-color: #095797; }
|
|
||||||
|
|
||||||
/* Slider panel (top-left, below controls, stations mode only) */
|
/* ── 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 {
|
#slider-panel {
|
||||||
position: fixed; top: 70px; left: 56px;
|
position: absolute; top: 12px; left: 12px;
|
||||||
background: white; padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
z-index: 1000; font-size: 13px; min-width: 280px;
|
||||||
z-index: 2000; font-size: 13px; min-width: 260px;
|
}
|
||||||
display: none;
|
#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; }
|
#slider-panel label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||||
#price-slider { width: 100%; cursor: pointer; }
|
#price-slider { width: 100%; cursor: pointer; }
|
||||||
#slider-value { display: flex; justify-content: space-between; margin-top: 4px; font-size: 11px; color: #666; }
|
#slider-value {
|
||||||
#visible-count { margin-top: 6px; font-size: 11px; color: #888; }
|
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); }
|
||||||
|
|
||||||
/* Legend (bottom-right, heatmap mode only) */
|
/* 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 {
|
#legend {
|
||||||
position: fixed; bottom: 20px; right: 20px;
|
position: absolute; bottom: 20px; right: 20px;
|
||||||
background: white; padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
z-index: 1000; font-size: 13px; min-width: 180px;
|
||||||
z-index: 2000; font-size: 13px; min-width: 180px;
|
|
||||||
}
|
}
|
||||||
#legend h3 { margin-bottom: 8px; font-size: 14px; }
|
#legend h3 { margin-bottom: 8px; font-size: 14px; }
|
||||||
.legend-gradient {
|
.legend-gradient {
|
||||||
height: 16px; border-radius: 4px;
|
height: 16px; border-radius: 4px;
|
||||||
background: linear-gradient(to right, #313695, #4575b4, #74add1, #abd9e9, #fee090, #fdae61, #f46d43, #d73027, #a50026);
|
background: linear-gradient(to right, #16a34a, #65a30d, #ca8a04, #ea580c, #dc2626);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.legend-labels { display: flex; justify-content: space-between; font-size: 11px; color: #666; }
|
.legend-labels {
|
||||||
#stats { margin-top: 8px; font-size: 11px; color: #888; }
|
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 {
|
#last-updated {
|
||||||
position: fixed; bottom: 8px; left: 56px;
|
position: absolute; bottom: 8px; left: 12px;
|
||||||
background: white; padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
z-index: 1000; font-size: 11px;
|
||||||
z-index: 2000; font-size: 11px; color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Colored pin markers */
|
.pin-icon { filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35)); }
|
||||||
.pin-icon {
|
|
||||||
filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35));
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loading">Chargement des données...</div>
|
|
||||||
<div id="map"></div>
|
|
||||||
|
|
||||||
<div id="controls">
|
<!-- Loading overlay -->
|
||||||
<button class="mode-btn active" data-mode="stations">Stations</button>
|
<div id="loading">
|
||||||
<button class="mode-btn" data-mode="heatmap">Heatmap</button>
|
<div aria-busy="true"></div>
|
||||||
|
<span>Chargement des données...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="slider-panel">
|
<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>
|
||||||
<label for="price-slider">Prix max: <span id="slider-label">-</span></label>
|
<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">
|
<input type="range" id="price-slider" min="0" max="300" step="0.5" value="300">
|
||||||
<div id="slider-value">
|
<div id="slider-value">
|
||||||
|
|
@ -96,51 +268,190 @@
|
||||||
<div id="visible-count"></div>
|
<div id="visible-count"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="last-updated"></div>
|
<div id="last-updated" class="map-panel"></div>
|
||||||
|
|
||||||
<div id="legend">
|
<div id="legend" class="map-panel">
|
||||||
<h3>Prix régulier (¢/L)</h3>
|
<h3 id="legend-title">Prix régulier (¢/L)</h3>
|
||||||
<div class="legend-gradient"></div>
|
<div class="legend-gradient"></div>
|
||||||
<div class="legend-labels">
|
<div class="legend-labels">
|
||||||
<span id="min-price">-</span>
|
<span id="min-price">-</span>
|
||||||
<span id="max-price">-</span>
|
<span id="max-price">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats"></div>
|
<div id="map-stats"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── STATS PAGE ── -->
|
||||||
|
<div id="page-stats">
|
||||||
|
<h2>Statistiques des prix</h2>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<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@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
|
||||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.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>
|
<script>
|
||||||
const map = L.map('map').setView([46.8, -71.2], 7);
|
// ── Page navigation ──────────────────────────────────────
|
||||||
|
let mapInitialized = false;
|
||||||
|
let statsChart = null;
|
||||||
|
let currentDays = 7;
|
||||||
|
|
||||||
|
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') {
|
||||||
|
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', {
|
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>',
|
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,
|
maxZoom: 18,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// State
|
let currentFuel = 'regular';
|
||||||
let currentMode = 'stations';
|
let currentRegion = '';
|
||||||
let allStations = [];
|
let allStations = [];
|
||||||
let minPrice = 0;
|
let allMarkers = [];
|
||||||
let maxPrice = 300;
|
let visibleSet = new Set();
|
||||||
let heatLayer = null;
|
let minPrice = 0, maxPrice = 300;
|
||||||
|
let sliderTimer = null;
|
||||||
let clusterGroup = L.markerClusterGroup({
|
let clusterGroup = L.markerClusterGroup({
|
||||||
maxClusterRadius: 50,
|
maxClusterRadius: 50,
|
||||||
spiderfyOnMaxZoom: true,
|
spiderfyOnMaxZoom: true,
|
||||||
showCoverageOnHover: false,
|
showCoverageOnHover: false,
|
||||||
zoomToBoundsOnClick: true,
|
zoomToBoundsOnClick: true,
|
||||||
disableClusteringAtZoom: 14,
|
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) {
|
||||||
|
const avg = prices.reduce((a, b) => a + b, 0) / prices.length;
|
||||||
|
fill = priceColor(avg, minPrice, maxPrice);
|
||||||
|
label = avg.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] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch('/api/stations')
|
fetch('/api/stations')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
document.getElementById('loading').style.display = 'none';
|
document.getElementById('loading').style.display = 'none';
|
||||||
const stations = data.stations;
|
mapInitialized = true;
|
||||||
allStations = stations;
|
allStations = data.stations;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
// Last updated
|
|
||||||
if (data.lastUpdated) {
|
if (data.lastUpdated) {
|
||||||
const d = new Date(data.lastUpdated);
|
const d = new Date(data.lastUpdated);
|
||||||
document.getElementById('last-updated').textContent =
|
document.getElementById('last-updated').textContent =
|
||||||
|
|
@ -150,16 +461,89 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices = stations.map(s => s.regular).filter(p => p > 0);
|
document.getElementById('map-stats').textContent = allStations.length + ' stations';
|
||||||
minPrice = Math.min(...prices);
|
|
||||||
maxPrice = Math.max(...prices);
|
|
||||||
|
|
||||||
// Legend
|
// Pre-build all markers once — reused for every slider update.
|
||||||
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
|
prebuildMarkers();
|
||||||
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
map.addLayer(clusterGroup);
|
||||||
document.getElementById('stats').textContent = stations.length + ' stations';
|
document.getElementById('legend').style.display = 'block';
|
||||||
|
|
||||||
// Slider setup
|
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('.fuel-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn.dataset.fuel === currentFuel) return;
|
||||||
|
currentFuel = btn.dataset.fuel;
|
||||||
|
document.querySelectorAll('.fuel-btn').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
|
||||||
|
const prices = allStations.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');
|
const slider = document.getElementById('price-slider');
|
||||||
slider.min = Math.floor(minPrice);
|
slider.min = Math.floor(minPrice);
|
||||||
slider.max = Math.ceil(maxPrice);
|
slider.max = Math.ceil(maxPrice);
|
||||||
|
|
@ -168,140 +552,228 @@
|
||||||
document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢';
|
document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢';
|
||||||
document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L';
|
document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L';
|
||||||
|
|
||||||
// Build heatmap layer
|
// Update legend
|
||||||
// Each point gets an intensity based on its price.
|
const fuelLabel = { regular: 'Régulier', super: 'Super', diesel: 'Diesel' }[currentFuel];
|
||||||
// We set a floor of 0.25 so cheap stations are still clearly visible,
|
document.getElementById('legend-title').textContent = `Prix ${fuelLabel.toLowerCase()} (¢/L)`;
|
||||||
// and use a high 'max' so overlapping stations don't oversaturate to red.
|
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
|
||||||
const heatData = stations
|
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
||||||
.filter(s => s.regular > 0)
|
|
||||||
.map(s => {
|
|
||||||
const t = (s.regular - minPrice) / (maxPrice - minPrice);
|
|
||||||
const intensity = 0.25 + t * 0.75; // range [0.25 .. 1.0]
|
|
||||||
return [s.lat, s.lng, intensity];
|
|
||||||
});
|
|
||||||
|
|
||||||
heatLayer = L.heatLayer(heatData, {
|
const GREY = '#9ca3af';
|
||||||
radius: 22,
|
allMarkers = allStations.map(s => {
|
||||||
blur: 25,
|
const price = s[currentFuel];
|
||||||
maxZoom: 13,
|
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
|
||||||
max: 3.0,
|
const label = price > 0 ? price.toFixed(1) : '—';
|
||||||
minOpacity: 0.35,
|
|
||||||
gradient: {
|
|
||||||
0.0: '#313695',
|
|
||||||
0.15: '#4575b4',
|
|
||||||
0.3: '#74add1',
|
|
||||||
0.45: '#abd9e9',
|
|
||||||
0.55: '#fee090',
|
|
||||||
0.7: '#fdae61',
|
|
||||||
0.8: '#f46d43',
|
|
||||||
0.9: '#d73027',
|
|
||||||
1.0: '#a50026',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build all station markers
|
|
||||||
buildStationMarkers(maxPrice);
|
|
||||||
|
|
||||||
// Start in stations mode
|
|
||||||
setMode('stations');
|
|
||||||
|
|
||||||
// Slider events
|
|
||||||
slider.addEventListener('input', () => {
|
|
||||||
const val = parseFloat(slider.value);
|
|
||||||
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
|
|
||||||
buildStationMarkers(val);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
document.getElementById('loading').textContent = 'Erreur: ' + err.message;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mode toggle buttons
|
|
||||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => setMode(btn.dataset.mode));
|
|
||||||
});
|
|
||||||
|
|
||||||
function setMode(mode) {
|
|
||||||
currentMode = mode;
|
|
||||||
|
|
||||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mode === 'heatmap') {
|
|
||||||
map.removeLayer(clusterGroup);
|
|
||||||
if (heatLayer) map.addLayer(heatLayer);
|
|
||||||
document.getElementById('legend').style.display = 'block';
|
|
||||||
document.getElementById('slider-panel').style.display = 'none';
|
|
||||||
} else {
|
|
||||||
if (heatLayer) map.removeLayer(heatLayer);
|
|
||||||
map.addLayer(clusterGroup);
|
|
||||||
document.getElementById('legend').style.display = 'none';
|
|
||||||
document.getElementById('slider-panel').style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStationMarkers(priceMax) {
|
|
||||||
clusterGroup.clearLayers();
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
allStations.forEach(s => {
|
|
||||||
if (s.regular <= 0 || s.regular > priceMax) return;
|
|
||||||
count++;
|
|
||||||
|
|
||||||
const color = priceColor(s.regular, minPrice, maxPrice);
|
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
html: pinSvg(color),
|
html: circleSvg(fill, label), className: '',
|
||||||
className: '',
|
iconSize: [36, 36], iconAnchor: [18, 18], popupAnchor: [0, -20],
|
||||||
iconSize: [25, 41],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
popupAnchor: [1, -34],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const marker = L.marker([s.lat, s.lng], { icon });
|
const marker = L.marker([s.lat, s.lng], { icon });
|
||||||
|
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
|
||||||
let popup = `<strong>${s.name}</strong><br>`;
|
let popup = `<strong>${s.name}</strong><br>${s.brand}<br>${s.address}<br>`;
|
||||||
popup += `${s.brand}<br>`;
|
|
||||||
popup += `${s.address}<br>`;
|
|
||||||
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L`;
|
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L`;
|
||||||
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L`;
|
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L`;
|
||||||
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L`;
|
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L`;
|
||||||
marker.bindPopup(popup);
|
marker.bindPopup(popup);
|
||||||
|
return marker;
|
||||||
clusterGroup.addLayer(marker);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('visible-count').textContent = count + ' / ' + allStations.length + ' stations';
|
applyPriceFilter(parseFloat(slider.value), fitMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pinSvg(fill) {
|
function circleSvg(fill, label) {
|
||||||
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 41" width="25" height="41">
|
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
|
||||||
<path d="M12.5 0C5.6 0 0 5.6 0 12.5C0 22 12.5 41 12.5 41S25 22 25 12.5C25 5.6 19.4 0 12.5 0Z"
|
<circle cx="18" cy="18" r="17" fill="${fill}" stroke="#fff" stroke-width="2"/>
|
||||||
fill="${fill}" stroke="#fff" stroke-width="1.5"/>
|
<text x="18" y="22" text-anchor="middle" fill="#fff" font-size="10" font-weight="700" font-family="sans-serif">${label}</text>
|
||||||
<circle cx="12.5" cy="12.5" r="5" fill="#fff" opacity="0.9"/>
|
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceColor(price, min, max) {
|
function priceColor(price, min, max) {
|
||||||
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
|
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
|
||||||
|
// green (cheap) → yellow → red (expensive)
|
||||||
const colors = [
|
const colors = [
|
||||||
[49, 54, 149],
|
[22,163,74],[101,163,13],[202,138,4],[234,88,12],[220,38,38],
|
||||||
[69, 117, 180],
|
|
||||||
[116, 173, 209],
|
|
||||||
[171, 217, 233],
|
|
||||||
[254, 224, 144],
|
|
||||||
[253, 174, 97],
|
|
||||||
[244, 109, 67],
|
|
||||||
[215, 48, 39],
|
|
||||||
[165, 0, 38],
|
|
||||||
];
|
];
|
||||||
const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
|
const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
|
||||||
const frac = t * (colors.length - 1) - idx;
|
const frac = t * (colors.length - 1) - idx;
|
||||||
const c0 = colors[idx], c1 = colors[idx + 1];
|
const c0 = colors[idx], c1 = colors[idx + 1];
|
||||||
const r = Math.round(c0[0] + (c1[0] - c0[0]) * frac);
|
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)})`;
|
||||||
const g = Math.round(c0[1] + (c1[1] - c0[1]) * frac);
|
}
|
||||||
const b = Math.round(c0[2] + (c1[2] - c0[2]) * frac);
|
|
||||||
return `rgb(${r},${g},${b})`;
|
// ── Statistics page ───────────────────────────────────────
|
||||||
|
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 loadStats(days) {
|
||||||
|
const url = '/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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue