369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
// 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 showCostco = true;
|
|
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;
|
|
let locateMarker = null;
|
|
|
|
// ── 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 = allStations.filter(s => {
|
|
if (currentRegion && s.region !== currentRegion) return false;
|
|
if (!showCostco && (s.brand || '').toLowerCase() === 'costco') return false;
|
|
return true;
|
|
});
|
|
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
|
|
minPrice = prices.length ? prices.reduce((m, p) => p < m ? p : m, prices[0]) : 0;
|
|
maxPrice = prices.length ? prices.reduce((m, p) => p > m ? p : m, prices[0]) : 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 = `${fuelLabel} (¢/L)`;
|
|
document.getElementById('min-price').textContent = minPrice.toFixed(1) + '¢';
|
|
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
|
|
|
const GREY = '#9ca3af';
|
|
// Only create Leaflet marker objects for stations that pass the current
|
|
// region/Costco filter — avoids allocating ~3000 markers when a region is selected.
|
|
allMarkers = allStations.map((s, i) => {
|
|
const inRegion = !currentRegion || s.region === currentRegion;
|
|
const isCostco = (s.brand || '').toLowerCase() === 'costco';
|
|
if (!inRegion || (isCostco && !showCostco)) {
|
|
return null;
|
|
}
|
|
|
|
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 };
|
|
|
|
// Lazily build popup HTML on first open to avoid doing string work
|
|
// for every station during initial render.
|
|
let popupBuilt = false;
|
|
marker.on('popupopen', function () {
|
|
if (popupBuilt) return;
|
|
popupBuilt = true;
|
|
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.getPopup().setContent(popup);
|
|
});
|
|
marker.bindPopup('');
|
|
|
|
return marker;
|
|
});
|
|
|
|
applyPriceFilter(parseFloat(slider.value), fitMap);
|
|
}
|
|
|
|
function applyPriceFilter(priceMax, fitMap) {
|
|
const toAdd = [];
|
|
const toRemove = [];
|
|
|
|
allStations.forEach((s, i) => {
|
|
const marker = allMarkers[i];
|
|
// Markers for filtered-out stations (wrong region/Costco) are null.
|
|
if (!marker) return;
|
|
const price = s[currentFuel];
|
|
const inRegion = !currentRegion || s.region === currentRegion;
|
|
const isCostco = (s.brand || '').toLowerCase() === 'costco';
|
|
const visible = inRegion && (price <= 0 || price <= priceMax) && (!isCostco || showCostco);
|
|
if (visible && !visibleSet.has(i)) {
|
|
toAdd.push(marker);
|
|
visibleSet.add(i);
|
|
} else if (!visible && visibleSet.has(i)) {
|
|
toRemove.push(marker);
|
|
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();
|
|
});
|
|
});
|
|
|
|
document.getElementById('costco-toggle').addEventListener('change', function () {
|
|
showCostco = this.checked;
|
|
rebuildMarkers();
|
|
});
|
|
|
|
// ── Locate-me button ───────────────────────────────────
|
|
var locateBtn = document.getElementById('locate-btn');
|
|
if (locateBtn) {
|
|
locateBtn.addEventListener('click', function () {
|
|
if (!navigator.geolocation) return;
|
|
navigator.geolocation.getCurrentPosition(function (pos) {
|
|
var latlng = [pos.coords.latitude, pos.coords.longitude];
|
|
map.setView(latlng, 14);
|
|
if (locateMarker) {
|
|
locateMarker.setLatLng(latlng);
|
|
} else {
|
|
var dotIcon = L.divIcon({
|
|
html: '<div class="locate-dot"><div class="locate-dot-pulse"></div><div class="locate-dot-inner"></div></div>',
|
|
className: '',
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10],
|
|
});
|
|
locateMarker = L.marker(latlng, { icon: dotIcon, pane: 'locatePane' }).addTo(map);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Mobile filter panel toggle ─────────────────────────
|
|
var filterToggle = document.getElementById('filter-toggle');
|
|
var sliderPanel = document.getElementById('slider-panel');
|
|
var filterBackdrop = document.getElementById('filter-backdrop');
|
|
|
|
function openFilterPanel() {
|
|
sliderPanel.classList.add('open');
|
|
filterToggle.classList.add('hidden');
|
|
if (filterBackdrop) filterBackdrop.classList.add('visible');
|
|
}
|
|
function closeFilterPanel() {
|
|
sliderPanel.classList.remove('open');
|
|
filterToggle.classList.remove('hidden');
|
|
if (filterBackdrop) filterBackdrop.classList.remove('visible');
|
|
}
|
|
|
|
if (filterToggle) {
|
|
filterToggle.addEventListener('click', openFilterPanel);
|
|
}
|
|
if (filterBackdrop) {
|
|
filterBackdrop.addEventListener('click', closeFilterPanel);
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
map.createPane('locatePane').style.zIndex = 650;
|
|
L.control.zoom({ position: 'topright' }).addTo(map);
|
|
var lastUpdatedText = (document.getElementById('last-updated') || {}).textContent || '';
|
|
var lastUpdatedMobile = document.getElementById('last-updated-mobile');
|
|
if (lastUpdatedMobile && lastUpdatedText) {
|
|
lastUpdatedMobile.textContent = lastUpdatedText;
|
|
}
|
|
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>' + (lastUpdatedText ? ' | ' + lastUpdatedText : ''),
|
|
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';
|
|
|
|
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>';
|
|
}
|
|
})();
|