smol change

This commit is contained in:
Polen 2026-04-04 14:18:29 -04:00
parent cdec0834e9
commit 6922e9d63b
3 changed files with 668 additions and 11 deletions

View file

@ -334,7 +334,12 @@
<div id="page-stats">
<h2>Statistiques des prix</h2>
<!-- Summary cards -->
<!-- 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>
@ -418,6 +423,7 @@
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';
@ -431,6 +437,7 @@
setTimeout(() => map.invalidateSize(), 50);
}
if (name === 'stats') {
loadStatsRegions();
loadStats(currentDays);
}
}
@ -485,12 +492,15 @@
},
});
fetch('/api/stations')
.then(r => r.json())
.then(data => {
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();
@ -634,10 +644,13 @@
});
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>${s.brand}<br>${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`;
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;
});
@ -664,7 +677,93 @@
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'));
@ -674,8 +773,33 @@
});
});
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 url = '/api/stats?days=' + 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 => {