prixdugaz/static/index.html
2026-04-02 11:38:05 -04:00

308 lines
11 KiB
HTML

<!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" />
<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" />
<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: 2000; font-size: 13px;
display: flex; align-items: center; gap: 8px;
}
.mode-btn {
padding: 5px 12px; border: 1px solid #ccc; border-radius: 4px;
background: #f5f5f5; cursor: pointer; font-size: 13px;
transition: all 0.15s;
}
.mode-btn:hover { background: #e8e8e8; }
.mode-btn.active { background: #095797; color: white; border-color: #095797; }
/* Slider panel (top-left, below controls, stations mode only) */
#slider-panel {
position: fixed; top: 70px; left: 56px;
background: white; padding: 10px 14px;
border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 2000; font-size: 13px; 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: 2000; 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; }
#last-updated {
position: fixed; bottom: 8px; left: 56px;
background: white; padding: 5px 10px;
border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
z-index: 2000; font-size: 11px; color: #666;
}
/* Colored pin markers */
.pin-icon {
filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35));
}
</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="stations">Stations</button>
<button class="mode-btn" data-mode="heatmap">Heatmap</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="last-updated"></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 src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.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 = 'stations';
let allStations = [];
let minPrice = 0;
let maxPrice = 300;
let heatLayer = null;
let clusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
disableClusteringAtZoom: 14,
});
fetch('/api/stations')
.then(r => r.json())
.then(data => {
document.getElementById('loading').style.display = 'none';
const stations = data.stations;
allStations = stations;
// Last updated
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',
});
}
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
// Each point gets an intensity based on its price.
// We set a floor of 0.25 so cheap stations are still clearly visible,
// and use a high 'max' so overlapping stations don't oversaturate to red.
const heatData = stations
.filter(s => s.regular > 0)
.map(s => {
const t = (s.regular - minPrice) / (maxPrice - minPrice);
const intensity = 0.25 + t * 0.75; // range [0.25 .. 1.0]
return [s.lat, s.lng, intensity];
});
heatLayer = L.heatLayer(heatData, {
radius: 22,
blur: 25,
maxZoom: 13,
max: 3.0,
minOpacity: 0.35,
gradient: {
0.0: '#313695',
0.15: '#4575b4',
0.3: '#74add1',
0.45: '#abd9e9',
0.55: '#fee090',
0.7: '#fdae61',
0.8: '#f46d43',
0.9: '#d73027',
1.0: '#a50026',
}
});
// Build all station markers
buildStationMarkers(maxPrice);
// Start in stations mode
setMode('stations');
// Slider events
slider.addEventListener('input', () => {
const val = parseFloat(slider.value);
document.getElementById('slider-label').textContent = val.toFixed(1) + '¢/L';
buildStationMarkers(val);
});
})
.catch(err => {
document.getElementById('loading').textContent = 'Erreur: ' + err.message;
});
// Mode toggle buttons
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => setMode(btn.dataset.mode));
});
function setMode(mode) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
if (mode === 'heatmap') {
map.removeLayer(clusterGroup);
if (heatLayer) map.addLayer(heatLayer);
document.getElementById('legend').style.display = 'block';
document.getElementById('slider-panel').style.display = 'none';
} else {
if (heatLayer) map.removeLayer(heatLayer);
map.addLayer(clusterGroup);
document.getElementById('legend').style.display = 'none';
document.getElementById('slider-panel').style.display = 'block';
}
}
function buildStationMarkers(priceMax) {
clusterGroup.clearLayers();
let count = 0;
allStations.forEach(s => {
if (s.regular <= 0 || s.regular > priceMax) return;
count++;
const color = priceColor(s.regular, minPrice, maxPrice);
const icon = L.divIcon({
html: pinSvg(color),
className: '',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
});
const marker = L.marker([s.lat, s.lng], { icon });
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);
clusterGroup.addLayer(marker);
});
document.getElementById('visible-count').textContent = count + ' / ' + allStations.length + ' stations';
}
function pinSvg(fill) {
return `<svg class="pin-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 41" width="25" height="41">
<path d="M12.5 0C5.6 0 0 5.6 0 12.5C0 22 12.5 41 12.5 41S25 22 25 12.5C25 5.6 19.4 0 12.5 0Z"
fill="${fill}" stroke="#fff" stroke-width="1.5"/>
<circle cx="12.5" cy="12.5" r="5" fill="#fff" opacity="0.9"/>
</svg>`;
}
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>