new stack

This commit is contained in:
Polen 2026-04-07 21:42:30 -04:00
parent 6922e9d63b
commit ac9737b125
10 changed files with 1476 additions and 1221 deletions

View file

@ -1,969 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Essence Québec</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<link rel="stylesheet" href="https://unpkg.com/@knadh/oat/oat.min.css">
<style>
/* ── Theme: Gouvernement du Québec blue ─────────────── */
:root {
--primary: rgb(9 87 151); /* #095797 */
--primary-foreground: rgb(255 255 255);
--ring: rgb(9 87 151);
}
/* Dark mode: slightly lighter so it stays accessible on dark backgrounds */
@media (prefers-color-scheme: dark) {
:root {
--primary: rgb(41 121 193);
--primary-foreground: rgb(255 255 255);
--ring: rgb(41 121 193);
}
}
/* ── Layout ─────────────────────────────────────────── */
html, body { height: 100%; margin: 0; padding: 0; }
/* ── Top nav ─────────────────────────────────────────── */
#topnav {
display: flex; align-items: center; gap: 0;
height: 44px; padding: 0 16px;
background: var(--card);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 2000;
}
#topnav .brand {
font-weight: 700; font-size: 15px; letter-spacing: -0.2px;
margin-right: 24px; color: var(--foreground);
text-decoration: none;
}
#topnav nav { display: flex; gap: 2px; }
#topnav nav a {
display: flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 6px;
text-decoration: none; color: var(--muted-foreground);
font-size: 13px; font-weight: 500;
transition: background 0.12s, color 0.12s;
}
#topnav nav a:hover {
background: var(--muted);
color: var(--foreground);
}
#topnav nav a[aria-current="page"] {
background: var(--primary);
color: var(--primary-foreground);
}
#topnav nav a svg { flex-shrink: 0; }
/* ── App shell ───────────────────────────────────────── */
#app {
display: flex; flex-direction: column;
height: 100%;
}
#content {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
}
/* ── Map page ────────────────────────────────────────── */
#page-map { display: flex; flex-direction: column; flex: 1; position: relative; }
#map { flex: 1; min-height: 0; }
/* Slider + fuel panel */
#slider-panel {
position: absolute; top: 12px; left: 12px;
padding: 10px 14px;
z-index: 1000; font-size: 13px; min-width: 280px;
}
#slider-panel .fuel-btns { display: flex; gap: 6px; margin-bottom: 10px; }
#region-select {
width: 100%; margin-bottom: 10px;
padding: 5px 8px; font-size: 12px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--secondary); color: var(--secondary-foreground);
cursor: pointer;
}
#slider-panel label { display: block; margin-bottom: 6px; font-weight: 600; }
#price-slider { width: 100%; cursor: pointer; }
#slider-value {
display: flex; justify-content: space-between;
margin-top: 4px; font-size: 11px;
color: var(--muted-foreground);
}
#visible-count { margin-top: 6px; font-size: 11px; color: var(--muted-foreground); }
/* Map overlay panels — sit above the Leaflet tiles */
.map-panel {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
/* Legend */
#legend {
position: absolute; bottom: 20px; right: 20px;
padding: 12px 16px;
z-index: 1000; font-size: 13px; min-width: 180px;
}
#legend h3 { margin-bottom: 8px; font-size: 14px; }
.legend-gradient {
height: 16px; border-radius: 4px;
background: linear-gradient(to right, #16a34a, #65a30d, #ca8a04, #ea580c, #dc2626);
margin-bottom: 4px;
}
.legend-labels {
display: flex; justify-content: space-between;
font-size: 11px; color: var(--muted-foreground);
}
#map-stats { margin-top: 8px; font-size: 11px; color: var(--muted-foreground); }
#last-updated {
position: absolute; bottom: 8px; left: 12px;
padding: 5px 10px;
z-index: 1000; font-size: 11px;
color: var(--muted-foreground);
}
.pin-icon { filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35)); }
.cluster-info-tip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px; height: 16px;
border-radius: 50%;
border: 1px solid var(--muted-foreground);
color: var(--muted-foreground);
font-size: 10px; font-weight: 700;
cursor: default;
flex-shrink: 0;
}
.cluster-info-tip:hover .cluster-info-tooltip { display: block; }
.cluster-info-tooltip {
display: none;
position: absolute;
left: 22px; top: 50%;
transform: translateY(-50%);
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
padding: 10px 12px;
font-size: 12px; font-weight: 400;
white-space: nowrap;
z-index: 2000;
pointer-events: none;
line-height: 1.6;
}
.fuel-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-foreground);
cursor: pointer; font-size: 12px;
transition: all 0.15s;
}
.fuel-btn:hover { background: var(--muted); }
.fuel-btn.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
/* ── Loading overlay ─────────────────────────────────── */
#loading {
position: fixed; inset: 0;
background: var(--background);
opacity: 0.92;
display: flex; align-items: center; justify-content: center;
z-index: 10000; font-size: 1.1em;
color: var(--foreground);
gap: 12px;
}
/* ── Stats page ──────────────────────────────────────── */
#page-stats { display: none; padding: 24px; overflow-y: auto; flex: 1; flex-direction: column; }
#page-stats h2 { margin-bottom: 20px; }
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.stat-cards .card { margin: 0; }
.stat-cards .card header { padding-bottom: 4px; }
.stat-cards .card header p { font-size: 12px; color: var(--muted-foreground); margin: 0; }
.stat-value { font-size: 28px; font-weight: 700; margin: 4px 0 0; }
.stat-sub { font-size: 12px; color: var(--muted-foreground); margin-top: 2px; }
.chart-wrap {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 28px;
}
.chart-wrap h3 { margin-bottom: 4px; }
.chart-wrap .chart-subtitle {
font-size: 12px; color: var(--muted-foreground); margin-bottom: 16px;
}
.range-btns { display: flex; gap: 6px; margin-bottom: 16px; }
.range-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-foreground);
cursor: pointer; font-size: 12px;
}
.range-btn:hover { background: var(--muted); }
.range-btn.active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
#stats-chart-canvas { width: 100% !important; max-height: 320px; }
.no-data { color: var(--muted-foreground); font-size: 14px; padding: 24px 0; text-align: center; }
.table-wrap { overflow-x: auto; }
</style>
</head>
<body>
<!-- Loading overlay -->
<div id="loading">
<div aria-busy="true"></div>
<span>Chargement des données...</span>
</div>
<div id="app">
<!-- ── Top nav ── -->
<header id="topnav">
<span class="brand">Essence QC</span>
<nav>
<a href="#" id="nav-map" aria-current="page" onclick="showPage('map');return false;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/>
<line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/>
</svg>
Carte
</a>
<a href="#" id="nav-stats" onclick="showPage('stats');return false;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Statistiques
</a>
</nav>
</header>
<!-- ── Content area ── -->
<div id="content">
<!-- ── MAP PAGE ── -->
<div id="page-map">
<div id="map"></div>
<div id="slider-panel" class="map-panel">
<div class="fuel-btns">
<button class="fuel-btn active" data-fuel="regular">Régulier</button>
<button class="fuel-btn" data-fuel="super">Super</button>
<button class="fuel-btn" data-fuel="diesel">Diesel</button>
</div>
<select id="region-select">
<option value="">Toutes les régions</option>
</select>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:10px;">
<div class="fuel-btns" id="cluster-btns" style="margin-bottom:0;">
<button class="fuel-btn cluster-btn" data-mode="min">Min</button>
<button class="fuel-btn cluster-btn active" data-mode="avg">Moy</button>
<button class="fuel-btn cluster-btn" data-mode="max">Max</button>
</div>
<div class="cluster-info-tip">
?
<div class="cluster-info-tooltip">
Prix affiché sur chaque groupe de stations :<br><br>
<strong>Min</strong> — prix le plus bas du groupe<br>
<strong>Moy</strong> — prix moyen du groupe<br>
<strong>Max</strong> — prix le plus élevé du groupe<br><br>
La couleur reflète également la valeur affichée.
</div>
</div>
</div>
<label for="price-slider">Prix max: <span id="slider-label">-</span></label>
<input type="range" id="price-slider" min="0" max="300" step="0.5" value="300">
<div id="slider-value">
<span id="slider-min">-</span>
<span id="slider-max">-</span>
</div>
<div id="visible-count"></div>
</div>
<div id="last-updated" class="map-panel"></div>
<div id="legend" class="map-panel">
<h3 id="legend-title">Prix régulier (¢/L)</h3>
<div class="legend-gradient"></div>
<div class="legend-labels">
<span id="min-price">-</span>
<span id="max-price">-</span>
</div>
<div id="map-stats"></div>
</div>
</div>
<!-- ── STATS PAGE ── -->
<div id="page-stats">
<h2>Statistiques des prix</h2>
<!-- Region filter -->
<div style="margin-bottom: 20px;">
<select id="stats-region-select" style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--secondary-foreground); cursor: pointer; min-width: 220px;">
<option value="">Toutes les régions</option>
</select>
</div>
<div class="stat-cards">
<article class="card">
<header><p>Régulier — Moyenne</p></header>
<p class="stat-value" id="sc-reg-avg"></p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Régulier — Min</p></header>
<p class="stat-value" id="sc-reg-min"></p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Régulier — Max</p></header>
<p class="stat-value" id="sc-reg-max"></p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Super — Moyenne</p></header>
<p class="stat-value" id="sc-sup-avg"></p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Diesel — Moyenne</p></header>
<p class="stat-value" id="sc-die-avg"></p>
<p class="stat-sub">¢/L</p>
</article>
<article class="card">
<header><p>Stations</p></header>
<p class="stat-value" id="sc-count"></p>
<p class="stat-sub">dernière mise à jour</p>
</article>
</div>
<!-- Chart -->
<div class="chart-wrap">
<h3>Évolution des prix</h3>
<p class="chart-subtitle">Prix moyen par carburant (¢/L)</p>
<div class="range-btns">
<button class="range-btn active" data-days="7">7 jours</button>
<button class="range-btn" data-days="30">30 jours</button>
<button class="range-btn" data-days="0">Tout</button>
</div>
<canvas id="stats-chart-canvas"></canvas>
<p class="no-data" id="chart-no-data" style="display:none;">Pas encore assez de données historiques.</p>
</div>
<!-- History table -->
<div class="chart-wrap">
<h3>Historique récent</h3>
<p class="chart-subtitle">10 dernières mises à jour</p>
<div class="table-wrap">
<table id="history-table">
<thead>
<tr>
<th>Date</th>
<th>Régulier moy.</th>
<th>Régulier min</th>
<th>Régulier max</th>
<th>Super moy.</th>
<th>Diesel moy.</th>
<th>Stations</th>
</tr>
</thead>
<tbody id="history-tbody">
<tr><td colspan="7" style="text-align:center;color:var(--muted-foreground);">Chargement...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- #content -->
</div><!-- #app -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/@knadh/oat/oat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
// ── Page navigation ──────────────────────────────────────
let mapInitialized = false;
let statsChart = null;
let currentDays = 7;
let stationDeltas = {}; // address → {regular, super, diesel} pct change or null
function showPage(name) {
document.getElementById('page-map').style.display = name === 'map' ? 'flex' : 'none';
document.getElementById('page-stats').style.display = name === 'stats' ? 'flex' : 'none';
document.getElementById('nav-map').removeAttribute('aria-current');
document.getElementById('nav-stats').removeAttribute('aria-current');
document.getElementById('nav-' + name).setAttribute('aria-current', 'page');
if (name === 'map' && mapInitialized) {
setTimeout(() => map.invalidateSize(), 50);
}
if (name === 'stats') {
loadStatsRegions();
loadStats(currentDays);
}
}
// ── Map setup ─────────────────────────────────────────────
const map = L.map('map', { zoomControl: false }).setView([46.8, -71.2], 7);
L.control.zoom({ position: 'topright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&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);
let currentFuel = 'regular';
let currentRegion = '';
let clusterMode = 'avg'; // 'min' | 'avg' | 'max'
let allStations = [];
let allMarkers = [];
let visibleSet = new Set();
let minPrice = 0, maxPrice = 300;
let sliderTimer = null;
let clusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
disableClusteringAtZoom: 14,
iconCreateFunction: function(cluster) {
const children = cluster.getAllChildMarkers();
const prices = children.map(m => m.fuelPrices[currentFuel]).filter(p => p > 0);
const count = cluster.getChildCount();
let fill, label;
if (prices.length > 0) {
let displayPrice;
if (clusterMode === 'min') displayPrice = Math.min(...prices);
else if (clusterMode === 'max') displayPrice = Math.max(...prices);
else displayPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
fill = priceColor(displayPrice, minPrice, maxPrice);
label = displayPrice.toFixed(1);
} else {
fill = '#9ca3af';
label = count;
}
const size = count < 10 ? 44 : count < 50 ? 52 : 60;
const r = size / 2;
const fs = count < 10 ? 11 : count < 50 ? 10 : 9;
const svg = `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
<circle cx="${r}" cy="${r}" r="${r - 2}" fill="${fill}" stroke="#fff" stroke-width="2.5"/>
<text x="${r}" y="${r + fs * 0.38}" text-anchor="middle" fill="#fff" font-size="${fs}" font-weight="700" font-family="sans-serif">${label}</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [size, size], iconAnchor: [r, r] });
},
});
Promise.all([
fetch('/api/stations').then(r => r.json()),
fetch('/api/station-deltas').then(r => r.json()).catch(() => ({})),
])
.then(([data, deltas]) => {
document.getElementById('loading').style.display = 'none';
mapInitialized = true;
allStations = data.stations;
stationDeltas = deltas || {};
// Populate region dropdown from data
const regions = [...new Set(allStations.map(s => s.region).filter(Boolean))].sort();
const sel = document.getElementById('region-select');
regions.forEach(r => {
const opt = document.createElement('option');
opt.value = r; opt.textContent = r;
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
currentRegion = sel.value;
if (allStations.length) rebuildMarkers(true);
});
if (data.lastUpdated) {
const d = new Date(data.lastUpdated);
document.getElementById('last-updated').textContent =
'Dernière mise à jour: ' + d.toLocaleString('fr-CA', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
document.getElementById('map-stats').textContent = allStations.length + ' stations';
// Pre-build all markers once — reused for every slider update.
prebuildMarkers();
map.addLayer(clusterGroup);
document.getElementById('legend').style.display = 'block';
const slider = document.getElementById('price-slider');
slider.addEventListener('input', () => {
const val = parseFloat(slider.value);
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
// Update label immediately; defer the heavy map work.
clearTimeout(sliderTimer);
sliderTimer = setTimeout(() => applyPriceFilter(val), 80);
});
})
.catch(err => {
const loading = document.getElementById('loading');
loading.innerHTML = '<span style="color:var(--destructive,#ef4444)">Erreur: ' + err.message + '</span>';
setTimeout(() => { loading.style.display = 'none'; }, 4000);
});
document.querySelectorAll('.cluster-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.mode === clusterMode) return;
clusterMode = btn.dataset.mode;
document.querySelectorAll('.cluster-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === clusterMode));
if (allStations.length) clusterGroup.refreshClusters();
});
});
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.fuel === currentFuel) return;
currentFuel = btn.dataset.fuel;
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(b => b.classList.toggle('active', b.dataset.fuel === currentFuel));
if (allStations.length) rebuildMarkers();
});
});
// Add/remove only the markers that changed relative to the current filter.
function applyPriceFilter(priceMax, fitMap) {
const toAdd = [];
const toRemove = [];
allStations.forEach((s, i) => {
const price = s[currentFuel];
const inRegion = !currentRegion || s.region === currentRegion;
// Show grey markers (no data for this fuel) always within region; filter only priced ones
const visible = inRegion && (price <= 0 || price <= priceMax);
if (visible && !visibleSet.has(i)) {
toAdd.push(allMarkers[i]);
visibleSet.add(i);
} else if (!visible && visibleSet.has(i)) {
toRemove.push(allMarkers[i]);
visibleSet.delete(i);
}
});
if (toRemove.length) clusterGroup.removeLayers(toRemove);
if (toAdd.length) clusterGroup.addLayers(toAdd);
document.getElementById('visible-count').textContent =
visibleSet.size + ' / ' + allStations.length + ' stations';
if (fitMap && currentRegion) {
const latlngs = allStations
.filter(s => s.region === currentRegion)
.map(s => [s.lat, s.lng]);
if (latlngs.length > 0) {
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40], maxZoom: 13 });
}
}
}
// Build all markers once at load time. Color is fixed by price; only
// visibility changes after this point.
function prebuildMarkers() {
rebuildMarkers();
}
function rebuildMarkers(fitMap) {
// Clear existing markers from cluster
clusterGroup.clearLayers();
visibleSet.clear();
// Recompute min/max for the active fuel, scoped to the selected region
const scopedStations = currentRegion
? allStations.filter(s => s.region === currentRegion)
: allStations;
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
minPrice = prices.length ? Math.min(...prices) : 0;
maxPrice = prices.length ? Math.max(...prices) : 300;
// Update slider range
const slider = document.getElementById('price-slider');
slider.min = Math.floor(minPrice);
slider.max = Math.ceil(maxPrice);
slider.value = Math.ceil(maxPrice);
document.getElementById('slider-min').textContent = Math.floor(minPrice) + '¢';
document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢';
document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L';
// Update legend
const fuelLabel = { regular: 'Régulier', super: 'Super', diesel: 'Diesel' }[currentFuel];
document.getElementById('legend-title').textContent = `Prix ${fuelLabel.toLowerCase()} (¢/L)`;
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
const GREY = '#9ca3af';
allMarkers = allStations.map(s => {
const price = s[currentFuel];
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
const label = price > 0 ? price.toFixed(1) : '—';
const icon = L.divIcon({
html: circleSvg(fill, label), className: '',
iconSize: [36, 36], iconAnchor: [18, 18], popupAnchor: [0, -20],
});
const marker = L.marker([s.lat, s.lng], { icon });
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`;
const d = stationDeltas[s.address] || {};
const eh = d.elapsedHours != null ? d.elapsedHours : null;
let popup = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
marker.bindPopup(popup);
return marker;
});
applyPriceFilter(parseFloat(slider.value), fitMap);
}
function circleSvg(fill, label) {
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
<circle cx="18" cy="18" r="17" fill="${fill}" stroke="#fff" stroke-width="2"/>
<text x="18" y="22" text-anchor="middle" fill="#fff" font-size="10" font-weight="700" font-family="sans-serif">${label}</text>
</svg>`;
}
function priceColor(price, min, max) {
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
// green (cheap) → yellow → red (expensive)
const colors = [
[22,163,74],[101,163,13],[202,138,4],[234,88,12],[220,38,38],
];
const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
const frac = t * (colors.length - 1) - idx;
const c0 = colors[idx], c1 = colors[idx + 1];
return `rgb(${Math.round(c0[0]+(c1[0]-c0[0])*frac)},${Math.round(c0[1]+(c1[1]-c0[1])*frac)},${Math.round(c0[2]+(c1[2]-c0[2])*frac)})`;
}
// ── Brand colours ─────────────────────────────────────────
// Real brand identity colours where known; others get a
// deterministic colour picked from a curated distinct-hue palette.
const BRAND_COLORS = {
'ultramar': '#0057a8',
'petro-canada': '#e4002b',
'esso': '#003087',
'shell': '#dd1d21',
'pioneer': '#f47920',
'couche-tard': '#c8102e',
'circle k': '#c8102e',
'crevier': '#e3000f',
'irving': '#006747',
'gilbert': '#00796b',
'sonic': '#7b2d8b',
'dépanneur': '#475569',
'indépendant': '#334155',
};
// 12-colour fallback palette for unknown brands (evenly spaced hues,
// all with sufficient contrast against white).
const FALLBACK_PALETTE = [
'#b45309','#0369a1','#065f46','#7c3aed',
'#be185d','#0f766e','#b91c1c','#1d4ed8',
'#15803d','#a21caf','#c2410c','#0e7490',
];
// Return the background hex colour for a brand name.
function brandColor(brand) {
const key = (brand || '').toLowerCase().trim();
if (BRAND_COLORS[key]) return BRAND_COLORS[key];
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
}
// WCAG relative luminance of a hex colour string.
function luminance(hex) {
const r = parseInt(hex.slice(1,3), 16) / 255;
const g = parseInt(hex.slice(3,5), 16) / 255;
const b = parseInt(hex.slice(5,7), 16) / 255;
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
// Pick white or black foreground for best contrast against bg.
function contrastFg(bgHex) {
const L = luminance(bgHex);
const onWhite = (L + 0.05) / (0.05); // contrast vs white
const onBlack = (1.05) / (L + 0.05); // contrast vs black
return onBlack > onWhite ? '#ffffff' : '#000000';
}
function brandBadgeHtml(brand) {
if (!brand) return '';
const bg = brandColor(brand);
const fg = contrastFg(bg);
return `<span class="badge" style="--primary:${bg};--primary-foreground:${fg}">${brand}</span>`;
}
// Returns an inline HTML snippet showing the 24h price change.
// pct is a number (percentage) or null/undefined when no history is available.
function priceDeltaHtml(pct, elapsedHours) {
if (pct == null) return '';
if (Math.abs(pct) < 0.05) return ''; // treat sub-0.05% as unchanged
const up = pct > 0;
const color = up ? '#dc2626' : '#16a34a'; // red up, green down
const arrow = up
? '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,8 5,2 8,8"/></svg>'
: '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,2 5,8 8,2"/></svg>';
const sign = up ? '+' : '';
let timeLabel;
if (elapsedHours == null) {
timeLabel = '';
} else if (elapsedHours < 1) {
timeLabel = ' (< 1h)';
} else {
const h = Math.min(24, Math.round(elapsedHours));
timeLabel = ` (${h}h)`;
}
return ` <span style="color:${color};font-size:11px;white-space:nowrap;">${arrow}${sign}${pct.toFixed(2)}%${timeLabel}</span>`;
}
// ── Statistics page ───────────────────────────────────────
let currentStatsRegion = '';
let statsRegionsLoaded = false;
document.querySelectorAll('.range-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentDays = parseInt(btn.dataset.days);
loadStats(currentDays);
});
});
function loadStatsRegions() {
if (statsRegionsLoaded) return;
fetch('/api/regions')
.then(r => r.json())
.then(regions => {
statsRegionsLoaded = true;
const sel = document.getElementById('stats-region-select');
regions.forEach(region => {
const opt = document.createElement('option');
opt.value = region;
opt.textContent = region;
sel.appendChild(opt);
});
})
.catch(err => console.error('regions error:', err));
}
document.getElementById('stats-region-select').addEventListener('change', function() {
currentStatsRegion = this.value;
loadStats(currentDays);
});
function loadStats(days) {
const region = currentStatsRegion;
const url = region
? '/api/stats/region?region=' + encodeURIComponent(region) + '&days=' + days
: '/api/stats?days=' + days;
fetch(url)
.then(r => r.json())
.then(data => {
const snaps = data.snapshots || [];
renderStatCards(snaps);
renderChart(snaps);
renderHistoryTable(snaps);
})
.catch(err => console.error('stats error:', err));
}
function fmt(v) {
return v > 0 ? v.toFixed(1) : '—';
}
// Like fmt() but appends the unit; shows '—' (no unit) for missing values.
function fmtUnit(v, unit) {
return v > 0 ? v.toFixed(1) + unit : '—';
}
function renderStatCards(snaps) {
// Use the latest snapshot for the summary cards
if (!snaps.length) return;
const last = snaps[snaps.length - 1];
document.getElementById('sc-reg-avg').textContent = fmt(last.regularAvg);
document.getElementById('sc-reg-min').textContent = fmt(last.regularMin);
document.getElementById('sc-reg-max').textContent = fmt(last.regularMax);
document.getElementById('sc-sup-avg').textContent = fmt(last.superAvg);
document.getElementById('sc-die-avg').textContent = fmt(last.dieselAvg);
document.getElementById('sc-count').textContent = last.stationCount || '—';
}
// Read a CSS variable from the root element (works for both light and dark).
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function renderChart(snaps) {
const canvas = document.getElementById('stats-chart-canvas');
const noData = document.getElementById('chart-no-data');
if (!snaps.length) {
canvas.style.display = 'none';
noData.style.display = 'block';
if (statsChart) { statsChart.destroy(); statsChart = null; }
return;
}
canvas.style.display = 'block';
noData.style.display = 'none';
// Resolve theme-aware colours at render time.
const textColor = cssVar('--foreground') || (darkMode() ? '#e4e4e7' : '#18181b');
const gridColor = cssVar('--border') || (darkMode() ? '#3f3f46' : '#e4e4e7');
const tooltipBg = cssVar('--card') || (darkMode() ? '#18181b' : '#ffffff');
const tooltipText = cssVar('--card-foreground') || textColor;
const labels = snaps.map(s => {
const d = new Date(s.generatedAt);
return d.toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
});
const pt = snaps.length > 60 ? 0 : 3;
const datasets = [
{
label: 'Régulier',
data: snaps.map(s => s.regularAvg > 0 ? +s.regularAvg.toFixed(2) : null),
borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)',
tension: 0.3, fill: false, pointRadius: pt,
},
{
label: 'Super',
data: snaps.map(s => s.superAvg > 0 ? +s.superAvg.toFixed(2) : null),
borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)',
tension: 0.3, fill: false, pointRadius: pt,
},
{
label: 'Diesel',
data: snaps.map(s => s.dieselAvg > 0 ? +s.dieselAvg.toFixed(2) : null),
borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)',
tension: 0.3, fill: false, pointRadius: pt,
},
];
const chartOpts = {
responsive: true,
maintainAspectRatio: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
labels: { color: textColor },
},
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipText,
bodyColor: tooltipText,
borderColor: gridColor,
borderWidth: 1,
callbacks: {
label: ctx => ctx.dataset.label + ': ' + (ctx.parsed.y != null ? ctx.parsed.y.toFixed(1) + '¢/L' : '—'),
}
}
},
scales: {
x: {
ticks: { maxTicksLimit: 10, maxRotation: 30, color: textColor },
grid: { color: gridColor },
},
y: {
title: { display: true, text: '¢/L', color: textColor },
ticks: { callback: v => v + '¢', color: textColor },
grid: { color: gridColor },
}
}
};
if (statsChart) {
statsChart.data.labels = labels;
statsChart.data.datasets = datasets;
// Reapply theme-sensitive options in case theme changed since creation.
statsChart.options = chartOpts;
statsChart.update();
return;
}
statsChart = new Chart(canvas, { type: 'line', data: { labels, datasets }, options: chartOpts });
}
function darkMode() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// Re-render chart when system theme changes so colours update immediately.
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (statsChart) loadStats(currentDays);
});
}
function renderHistoryTable(snaps) {
const tbody = document.getElementById('history-tbody');
// Show last 10, most recent first
const rows = snaps.slice(-10).reverse();
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--muted-foreground);">Aucune donnée</td></tr>';
return;
}
tbody.innerHTML = rows.map(s => {
const d = new Date(s.generatedAt).toLocaleString('fr-CA', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
return `<tr>
<td>${d}</td>
<td>${fmtUnit(s.regularAvg, '¢')}</td>
<td>${fmtUnit(s.regularMin, '¢')}</td>
<td>${fmtUnit(s.regularMax, '¢')}</td>
<td>${fmtUnit(s.superAvg, '¢')}</td>
<td>${fmtUnit(s.dieselAvg, '¢')}</td>
<td>${s.stationCount}</td>
</tr>`;
}).join('');
}
</script>
</body>
</html>

287
static/map.js Normal file
View file

@ -0,0 +1,287 @@
// map.js — Leaflet map logic for Essence QC
// Reads window.__stations and window.__deltas injected by the server.
(function () {
'use strict';
let currentFuel = 'regular';
let currentRegion = '';
let clusterMode = 'avg'; // 'min' | 'avg' | 'max'
let allStations = window.__stations || [];
let allMarkers = [];
let visibleSet = new Set();
let minPrice = 0, maxPrice = 300;
let sliderTimer = null;
const stationDeltas = window.__deltas || {};
// ── Map setup (deferred to init section below) ─────────────
let map;
let clusterGroup;
// ── Colour helpers ─────────────────────────────────────────
function priceColor(price, min, max) {
const t = Math.max(0, Math.min(1, (price - min) / (max - min)));
const colors = [
[22, 163, 74], [101, 163, 13], [202, 138, 4], [234, 88, 12], [220, 38, 38],
];
const idx = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
const frac = t * (colors.length - 1) - idx;
const c0 = colors[idx], c1 = colors[idx + 1];
return `rgb(${Math.round(c0[0] + (c1[0] - c0[0]) * frac)},${Math.round(c0[1] + (c1[1] - c0[1]) * frac)},${Math.round(c0[2] + (c1[2] - c0[2]) * frac)})`;
}
function circleSvg(fill, label) {
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
<circle cx="18" cy="18" r="17" fill="${fill}" stroke="#fff" stroke-width="2"/>
<text x="18" y="22" text-anchor="middle" fill="#fff" font-size="10" font-weight="700" font-family="sans-serif">${label}</text>
</svg>`;
}
// ── Brand colours ──────────────────────────────────────────
const BRAND_COLORS = {
'ultramar': '#0057a8',
'petro-canada': '#e4002b',
'esso': '#003087',
'shell': '#dd1d21',
'pioneer': '#f47920',
'couche-tard': '#c8102e',
'circle k': '#c8102e',
'crevier': '#e3000f',
'irving': '#006747',
'gilbert': '#00796b',
'sonic': '#7b2d8b',
'dépanneur': '#475569',
'indépendant': '#334155',
};
const FALLBACK_PALETTE = [
'#b45309', '#0369a1', '#065f46', '#7c3aed',
'#be185d', '#0f766e', '#b91c1c', '#1d4ed8',
'#15803d', '#a21caf', '#c2410c', '#0e7490',
];
function brandColor(brand) {
const key = (brand || '').toLowerCase().trim();
if (BRAND_COLORS[key]) return BRAND_COLORS[key];
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
}
function luminance(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrastFg(bgHex) {
const L = luminance(bgHex);
const onWhite = (L + 0.05) / 0.05;
const onBlack = 1.05 / (L + 0.05);
return onBlack > onWhite ? '#ffffff' : '#000000';
}
function brandBadgeHtml(brand) {
if (!brand) return '';
const bg = brandColor(brand);
const fg = contrastFg(bg);
return `<span class="badge" style="--primary:${bg};--primary-foreground:${fg}">${brand}</span>`;
}
function priceDeltaHtml(pct, elapsedHours) {
if (pct == null) return '';
if (Math.abs(pct) < 0.05) return '';
const up = pct > 0;
const color = up ? '#dc2626' : '#16a34a';
const arrow = up
? '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,8 5,2 8,8"/></svg>'
: '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2,2 5,8 8,2"/></svg>';
const sign = up ? '+' : '';
let timeLabel = '';
if (elapsedHours != null) {
if (elapsedHours < 1) timeLabel = ' (< 1h)';
else timeLabel = ` (${Math.min(24, Math.round(elapsedHours))}h)`;
}
return ` <span style="color:${color};font-size:11px;white-space:nowrap;">${arrow}${sign}${pct.toFixed(2)}%${timeLabel}</span>`;
}
// ── Marker building ────────────────────────────────────────
function rebuildMarkers(fitMap) {
clusterGroup.clearLayers();
visibleSet.clear();
const scopedStations = currentRegion
? allStations.filter(s => s.region === currentRegion)
: allStations;
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
minPrice = prices.length ? Math.min(...prices) : 0;
maxPrice = prices.length ? Math.max(...prices) : 300;
const slider = document.getElementById('price-slider');
slider.min = Math.floor(minPrice);
slider.max = Math.ceil(maxPrice);
slider.value = Math.ceil(maxPrice);
document.getElementById('slider-min').textContent = Math.floor(minPrice) + '¢';
document.getElementById('slider-max').textContent = Math.ceil(maxPrice) + '¢';
document.getElementById('slider-label').textContent = Math.ceil(maxPrice) + '¢/L';
const fuelLabel = { regular: 'Régulier', super: 'Super', diesel: 'Diesel' }[currentFuel];
document.getElementById('legend-title').textContent = `Prix ${fuelLabel.toLowerCase()} (¢/L)`;
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
const GREY = '#9ca3af';
allMarkers = allStations.map(s => {
const price = s[currentFuel];
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
const label = price > 0 ? price.toFixed(1) : '—';
const icon = L.divIcon({
html: circleSvg(fill, label), className: '',
iconSize: [36, 36], iconAnchor: [18, 18], popupAnchor: [0, -20],
});
const marker = L.marker([s.lat, s.lng], { icon });
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`;
const d = stationDeltas[s.address] || {};
const eh = d.elapsedHours != null ? d.elapsedHours : null;
let popup = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
marker.bindPopup(popup);
return marker;
});
applyPriceFilter(parseFloat(slider.value), fitMap);
}
function applyPriceFilter(priceMax, fitMap) {
const toAdd = [];
const toRemove = [];
allStations.forEach((s, i) => {
const price = s[currentFuel];
const inRegion = !currentRegion || s.region === currentRegion;
const visible = inRegion && (price <= 0 || price <= priceMax);
if (visible && !visibleSet.has(i)) {
toAdd.push(allMarkers[i]);
visibleSet.add(i);
} else if (!visible && visibleSet.has(i)) {
toRemove.push(allMarkers[i]);
visibleSet.delete(i);
}
});
if (toRemove.length) clusterGroup.removeLayers(toRemove);
if (toAdd.length) clusterGroup.addLayers(toAdd);
document.getElementById('visible-count').textContent =
visibleSet.size + ' / ' + allStations.length + ' stations';
if (fitMap && currentRegion) {
const latlngs = allStations
.filter(s => s.region === currentRegion)
.map(s => [s.lat, s.lng]);
if (latlngs.length > 0) {
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40], maxZoom: 13 });
}
}
}
// ── Wire up controls ───────────────────────────────────────
function bindControls() {
document.getElementById('region-select').addEventListener('change', function () {
currentRegion = this.value;
rebuildMarkers(true);
});
document.getElementById('price-slider').addEventListener('input', function () {
const val = parseFloat(this.value);
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
clearTimeout(sliderTimer);
sliderTimer = setTimeout(() => applyPriceFilter(val), 80);
});
document.querySelectorAll('.cluster-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.mode === clusterMode) return;
clusterMode = btn.dataset.mode;
document.querySelectorAll('.cluster-btn').forEach(b =>
b.classList.toggle('active', b.dataset.mode === clusterMode));
if (allStations.length) clusterGroup.refreshClusters();
});
});
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.fuel === currentFuel) return;
currentFuel = btn.dataset.fuel;
document.querySelectorAll('.fuel-btn[data-fuel]').forEach(b =>
b.classList.toggle('active', b.dataset.fuel === currentFuel));
rebuildMarkers();
});
});
}
// ── Initialise ─────────────────────────────────────────────
const loadingEl = document.getElementById('loading');
try {
// Create map and cluster group now that the DOM is ready.
map = L.map('map', { zoomControl: false }).setView([46.8, -71.2], 7);
L.control.zoom({ position: 'topright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&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);
clusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
disableClusteringAtZoom: 14,
iconCreateFunction: function (cluster) {
const children = cluster.getAllChildMarkers();
const prices = children.map(m => m.fuelPrices[currentFuel]).filter(p => p > 0);
const count = cluster.getChildCount();
let fill, label;
if (prices.length > 0) {
let displayPrice;
if (clusterMode === 'min') displayPrice = Math.min(...prices);
else if (clusterMode === 'max') displayPrice = Math.max(...prices);
else displayPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
fill = priceColor(displayPrice, minPrice, maxPrice);
label = displayPrice.toFixed(1);
} else {
fill = '#9ca3af';
label = count;
}
const size = count < 10 ? 44 : count < 50 ? 52 : 60;
const r = size / 2;
const fs = count < 10 ? 11 : count < 50 ? 10 : 9;
const svg = `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
<circle cx="${r}" cy="${r}" r="${r - 2}" fill="${fill}" stroke="#fff" stroke-width="2.5"/>
<text x="${r}" y="${r + fs * 0.38}" text-anchor="middle" fill="#fff" font-size="${fs}" font-weight="700" font-family="sans-serif">${label}</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [size, size], iconAnchor: [r, r] });
},
});
loadingEl.style.display = 'none';
document.getElementById('map-stats').textContent = allStations.length + ' stations';
rebuildMarkers();
map.addLayer(clusterGroup);
bindControls();
// Expose for use after htmx page-swap back to the map page.
window.__mapInvalidate = function () {
setTimeout(() => map.invalidateSize(), 50);
};
} catch (err) {
loadingEl.innerHTML = '<span style="color:#dc2626;padding:20px;text-align:center;">Erreur JavaScript: ' + err.message + '</span>';
}
})();

117
static/stats.js Normal file
View file

@ -0,0 +1,117 @@
// stats.js — Chart.js logic for Essence QC stats page.
// Reads window.__statsData injected by the server into the stats-content fragment.
(function () {
'use strict';
let statsChart = null;
function darkMode() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function initChart() {
const snaps = window.__statsData;
const canvas = document.getElementById('stats-chart-canvas');
if (!snaps || !snaps.length || !canvas) return;
// Destroy previous instance if the fragment was re-swapped.
if (statsChart) {
statsChart.destroy();
statsChart = null;
}
const textColor = cssVar('--foreground') || (darkMode() ? '#e4e4e7' : '#18181b');
const gridColor = cssVar('--border') || (darkMode() ? '#3f3f46' : '#e4e4e7');
const tooltipBg = cssVar('--card') || (darkMode() ? '#18181b' : '#ffffff');
const tooltipText = cssVar('--card-foreground') || textColor;
const labels = snaps.map(s => {
const d = new Date(s.generatedAt);
return d.toLocaleString('fr-CA', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
});
const pt = snaps.length > 60 ? 0 : 3;
const datasets = [
{
label: 'Régulier',
data: snaps.map(s => s.regularAvg > 0 ? +s.regularAvg.toFixed(2) : null),
borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)',
tension: 0.3, fill: false, pointRadius: pt,
},
{
label: 'Super',
data: snaps.map(s => s.superAvg > 0 ? +s.superAvg.toFixed(2) : null),
borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)',
tension: 0.3, fill: false, pointRadius: pt,
},
{
label: 'Diesel',
data: snaps.map(s => s.dieselAvg > 0 ? +s.dieselAvg.toFixed(2) : null),
borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)',
tension: 0.3, fill: false, pointRadius: pt,
},
];
const chartOpts = {
responsive: true,
maintainAspectRatio: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
labels: { color: textColor },
},
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipText,
bodyColor: tooltipText,
borderColor: gridColor,
borderWidth: 1,
callbacks: {
label: ctx => ctx.dataset.label + ': ' +
(ctx.parsed.y != null ? ctx.parsed.y.toFixed(1) + '¢/L' : '—'),
},
},
},
scales: {
x: {
ticks: { maxTicksLimit: 10, maxRotation: 30, color: textColor },
grid: { color: gridColor },
},
y: {
title: { display: true, text: '¢/L', color: textColor },
ticks: { callback: v => v + '¢', color: textColor },
grid: { color: gridColor },
},
},
};
statsChart = new Chart(canvas, { type: 'line', data: { labels, datasets }, options: chartOpts });
}
// Called inline from stats-content.html after __statsData is set.
window.__initStatsChart = initChart;
// Re-render on dark-mode change.
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (statsChart) initChart();
});
}
// Re-init after any htmx swap of #stats-content.
document.body.addEventListener('htmx:afterSwap', function (evt) {
if (evt.detail && evt.detail.target && evt.detail.target.id === 'stats-content') {
// __statsData was updated inline by the swapped fragment; just init the chart.
if (window.__statsData) initChart();
}
});
})();