initial project setup
This commit is contained in:
commit
46c024e8bd
8 changed files with 574 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/essence
|
||||||
|
/result
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1775036866,
|
||||||
|
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
31
flake.nix
Normal file
31
flake.nix
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
description = "Essence Quebec - Gas price heatmap";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
go
|
||||||
|
gopls
|
||||||
|
gotools
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
packages.default = pkgs.buildGoModule {
|
||||||
|
pname = "essence";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
vendorHash = null; # Will need updating after go mod tidy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
module github.com/polen/essence
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require github.com/xuri/excelize/v2 v2.10.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
)
|
||||||
28
go.sum
Normal file
28
go.sum
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||||
|
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
171
main.go
Normal file
171
main.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataURL = "https://regieessencequebec.ca/data/stations-20260402132004.xlsx"
|
||||||
|
defaultPort = "8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Station struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
PostalCode string `json:"postalCode"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lng float64 `json:"lng"`
|
||||||
|
Regular float64 `json:"regular"`
|
||||||
|
Super float64 `json:"super"`
|
||||||
|
Diesel float64 `json:"diesel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
staticSub, err := fs.Sub(staticFiles, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("static files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/api/stations", handleStations)
|
||||||
|
http.Handle("/", http.FileServer(http.FS(staticSub)))
|
||||||
|
|
||||||
|
log.Printf("Listening on http://localhost:%s", port)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stations, err := fetchAndParse()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("error: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
json.NewEncoder(w).Encode(stations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAndParse() ([]Station, error) {
|
||||||
|
log.Println("Fetching Excel data...")
|
||||||
|
resp, err := http.Get(dataURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching data: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "stations-*.xlsx")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return nil, fmt.Errorf("writing temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
f, err := excelize.OpenFile(tmp.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening excel: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
sheets := f.GetSheetList()
|
||||||
|
if len(sheets) == 0 {
|
||||||
|
return nil, fmt.Errorf("no sheets found")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := f.GetRows(sheets[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) < 2 {
|
||||||
|
return nil, fmt.Errorf("not enough rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stations []Station
|
||||||
|
for _, row := range rows[1:] {
|
||||||
|
if len(row) < 8 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lat, err := strconv.ParseFloat(row[5], 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lng, err := strconv.ParseFloat(row[6], 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
regular := parsePrice(row[7])
|
||||||
|
if regular <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s := Station{
|
||||||
|
Name: row[0],
|
||||||
|
Brand: row[1],
|
||||||
|
Address: row[2],
|
||||||
|
Region: row[3],
|
||||||
|
PostalCode: row[4],
|
||||||
|
Lat: lat,
|
||||||
|
Lng: lng,
|
||||||
|
Regular: regular,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(row) > 8 {
|
||||||
|
s.Super = parsePrice(row[8])
|
||||||
|
}
|
||||||
|
if len(row) > 9 {
|
||||||
|
s.Diesel = parsePrice(row[9])
|
||||||
|
}
|
||||||
|
|
||||||
|
stations = append(stations, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Parsed %d stations", len(stations))
|
||||||
|
return stations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrice converts "190.9¢" to 190.9
|
||||||
|
func parsePrice(s string) float64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || s == "N/D" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
s = strings.TrimSuffix(s, "¢")
|
||||||
|
s = strings.TrimSuffix(s, "\u00a2") // cent sign
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
264
static/index.html
Normal file
264
static/index.html
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
<!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 - Carte des prix</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
#map { width: 100vw; height: 100vh; }
|
||||||
|
|
||||||
|
#loading {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 10000; font-size: 1.2em; color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls panel (top-left) */
|
||||||
|
#controls {
|
||||||
|
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: 1000; 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) */
|
||||||
|
#slider-panel {
|
||||||
|
position: fixed; top: 56px; left: 56px;
|
||||||
|
background: white; 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: 260px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#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: #666; }
|
||||||
|
#visible-count { margin-top: 6px; font-size: 11px; color: #888; }
|
||||||
|
|
||||||
|
/* Legend (bottom-right, heatmap mode only) */
|
||||||
|
#legend {
|
||||||
|
position: fixed; bottom: 20px; right: 20px;
|
||||||
|
background: white; 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;
|
||||||
|
}
|
||||||
|
#legend h3 { margin-bottom: 8px; font-size: 14px; }
|
||||||
|
.legend-gradient {
|
||||||
|
height: 16px; border-radius: 4px;
|
||||||
|
background: linear-gradient(to right, #313695, #4575b4, #74add1, #abd9e9, #fee090, #fdae61, #f46d43, #d73027, #a50026);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.legend-labels { display: flex; justify-content: space-between; font-size: 11px; color: #666; }
|
||||||
|
#stats { margin-top: 8px; font-size: 11px; color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading">Chargement des données...</div>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div id="controls">
|
||||||
|
<button class="mode-btn active" data-mode="heatmap">Heatmap</button>
|
||||||
|
<button class="mode-btn" data-mode="stations">Stations</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slider-panel">
|
||||||
|
<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="legend">
|
||||||
|
<h3>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="stats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
const map = L.map('map').setView([46.8, -71.2], 7);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// State
|
||||||
|
let currentMode = 'heatmap';
|
||||||
|
let allStations = [];
|
||||||
|
let minPrice = 0;
|
||||||
|
let maxPrice = 300;
|
||||||
|
let heatLayer = null;
|
||||||
|
let stationMarkers = L.layerGroup();
|
||||||
|
|
||||||
|
fetch('/api/stations')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(stations => {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
allStations = stations;
|
||||||
|
|
||||||
|
const prices = stations.map(s => s.regular).filter(p => p > 0);
|
||||||
|
minPrice = Math.min(...prices);
|
||||||
|
maxPrice = Math.max(...prices);
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
|
||||||
|
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
||||||
|
document.getElementById('stats').textContent = stations.length + ' stations';
|
||||||
|
|
||||||
|
// Slider setup
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Build heatmap layer
|
||||||
|
const heatData = stations
|
||||||
|
.filter(s => s.regular > 0)
|
||||||
|
.map(s => {
|
||||||
|
const intensity = (s.regular - minPrice) / (maxPrice - minPrice);
|
||||||
|
return [s.lat, s.lng, intensity];
|
||||||
|
});
|
||||||
|
|
||||||
|
heatLayer = L.heatLayer(heatData, {
|
||||||
|
radius: 25,
|
||||||
|
blur: 20,
|
||||||
|
maxZoom: 12,
|
||||||
|
max: 1.0,
|
||||||
|
gradient: {
|
||||||
|
0.0: '#313695',
|
||||||
|
0.1: '#4575b4',
|
||||||
|
0.2: '#74add1',
|
||||||
|
0.3: '#abd9e9',
|
||||||
|
0.4: '#e0f3f8',
|
||||||
|
0.5: '#fee090',
|
||||||
|
0.6: '#fdae61',
|
||||||
|
0.7: '#f46d43',
|
||||||
|
0.8: '#d73027',
|
||||||
|
1.0: '#a50026',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build all station markers
|
||||||
|
buildStationMarkers(maxPrice);
|
||||||
|
|
||||||
|
// Start in heatmap mode
|
||||||
|
setMode('heatmap');
|
||||||
|
|
||||||
|
// Slider events
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
const val = parseFloat(slider.value);
|
||||||
|
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
|
||||||
|
buildStationMarkers(val);
|
||||||
|
if (currentMode === 'stations') {
|
||||||
|
map.removeLayer(stationMarkers);
|
||||||
|
map.addLayer(stationMarkers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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(stationMarkers);
|
||||||
|
if (heatLayer) map.addLayer(heatLayer);
|
||||||
|
document.getElementById('legend').style.display = '';
|
||||||
|
document.getElementById('slider-panel').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if (heatLayer) map.removeLayer(heatLayer);
|
||||||
|
map.addLayer(stationMarkers);
|
||||||
|
document.getElementById('legend').style.display = 'none';
|
||||||
|
document.getElementById('slider-panel').style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStationMarkers(priceMax) {
|
||||||
|
stationMarkers.clearLayers();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
allStations.forEach(s => {
|
||||||
|
if (s.regular <= 0 || s.regular > priceMax) return;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
const color = priceColor(s.regular, minPrice, maxPrice);
|
||||||
|
const marker = L.circleMarker([s.lat, s.lng], {
|
||||||
|
radius: 7,
|
||||||
|
fillColor: color,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 1.5,
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
let popup = `<strong>${s.name}</strong><br>`;
|
||||||
|
popup += `${s.brand}<br>`;
|
||||||
|
popup += `${s.address}<br>`;
|
||||||
|
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.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L`;
|
||||||
|
marker.bindPopup(popup);
|
||||||
|
|
||||||
|
stationMarkers.addLayer(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('visible-count').textContent = count + ' / ' + allStations.length + ' stations';
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceColor(price, min, max) {
|
||||||
|
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
|
||||||
|
const colors = [
|
||||||
|
[49, 54, 149],
|
||||||
|
[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 frac = t * (colors.length - 1) - idx;
|
||||||
|
const c0 = colors[idx], c1 = colors[idx + 1];
|
||||||
|
const r = Math.round(c0[0] + (c1[0] - c0[0]) * 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})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue