initial project setup

This commit is contained in:
Polen 2026-04-02 09:50:45 -04:00
commit 46c024e8bd
8 changed files with 574 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/essence
/result

61
flake.lock generated Normal file
View 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
View 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
View 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
View 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
View 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
View 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: '&copy; <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>