// map.js — Leaflet map logic for Essence QC // Reads window.__stations and window.__deltas injected by the server. (function () { 'use strict'; // ── URL param helpers ────────────────────────────────────── function getParams() { return new URLSearchParams(window.location.search); } function updateURL() { const p = new URLSearchParams(); if (currentFuel !== 'regular') p.set('fuel', currentFuel); if (currentRegion !== '') p.set('region', currentRegion); if (clusterMode !== 'avg') p.set('cluster', clusterMode); if (!showCostco) p.set('costco', '0'); const slider = document.getElementById('price-slider'); if (slider && parseFloat(slider.value) < parseFloat(slider.max)) { p.set('price', slider.value); } if (map) { const c = map.getCenter(); p.set('lat', c.lat.toFixed(5)); p.set('lng', c.lng.toFixed(5)); p.set('zoom', map.getZoom()); } const qs = p.toString(); history.replaceState(null, '', qs ? '?' + qs : window.location.pathname); } // Read initial state from URL params (fall back to defaults). const _p = getParams(); let currentFuel = ['regular','super','diesel'].includes(_p.get('fuel')) ? _p.get('fuel') : 'regular'; let currentRegion = _p.get('region') || ''; let clusterMode = ['min','avg','max'].includes(_p.get('cluster')) ? _p.get('cluster') : 'avg'; let showCostco = _p.get('costco') !== '0'; let _initPrice = _p.has('price') ? parseFloat(_p.get('price')) : null; let _initLat = _p.has('lat') ? parseFloat(_p.get('lat')) : null; let _initLng = _p.has('lng') ? parseFloat(_p.get('lng')) : null; let _initZoom = _p.has('zoom') ? parseInt(_p.get('zoom'), 10) : null; let allStations = window.__stations || []; let allMarkers = []; let visibleSet = new Set(); let minPrice = 0, maxPrice = 300; let sliderTimer = null; let mapMoveTimer = 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 ` ${label} `; } // ── 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 `${brand}`; } 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 ? '' : ''; const sign = up ? '+' : ''; let timeLabel = ''; if (elapsedHours != null) { if (elapsedHours < 1) timeLabel = ' (< 1h)'; else timeLabel = ` (${Math.min(24, Math.round(elapsedHours))}h)`; } return ` ${arrow}${sign}${pct.toFixed(2)}%${timeLabel}`; } // ── 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 = `${s.name}
${brandBadgeHtml(s.brand)}
${s.address}
`; popup += `
Régulier: ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`; if (s.super > 0) popup += `
Super: ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`; if (s.diesel > 0) popup += `
Diesel: ${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); updateURL(); }); 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); updateURL(); }, 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(); updateURL(); }); }); 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(); updateURL(); }); }); document.getElementById('costco-toggle').addEventListener('change', function () { showCostco = this.checked; rebuildMarkers(); updateURL(); }); // ── 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: '
', 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. const initCenter = (_initLat !== null && _initLng !== null) ? [_initLat, _initLng] : [46.8, -71.2]; const initZoom = _initZoom !== null ? _initZoom : 7; map = L.map('map', { zoomControl: false }).setView(initCenter, initZoom); 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: '© OpenStreetMap | Données: Régie de l\'énergie du Québec' + (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 = ` ${label} `; return L.divIcon({ html: svg, className: '', iconSize: [size, size], iconAnchor: [r, r] }); }, }); loadingEl.style.display = 'none'; // Apply initial state from URL params to UI controls. if (currentFuel !== 'regular') { document.querySelectorAll('.fuel-btn[data-fuel]').forEach(b => b.classList.toggle('active', b.dataset.fuel === currentFuel)); } if (clusterMode !== 'avg') { document.querySelectorAll('.cluster-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === clusterMode)); } if (!showCostco) { const ct = document.getElementById('costco-toggle'); if (ct) ct.checked = false; } if (currentRegion) { const rs = document.getElementById('region-select'); if (rs) rs.value = currentRegion; } rebuildMarkers(); // Override slider value from URL after rebuildMarkers set it. if (_initPrice !== null) { const slider = document.getElementById('price-slider'); if (slider && _initPrice < parseFloat(slider.max)) { slider.value = _initPrice; document.getElementById('slider-label').textContent = _initPrice.toFixed(1) + '¢/L'; applyPriceFilter(_initPrice, false); } } map.addLayer(clusterGroup); bindControls(); // Update URL on map move/zoom. map.on('moveend zoomend', function () { clearTimeout(mapMoveTimer); mapMoveTimer = setTimeout(updateURL, 200); }); // Expose for use after htmx page-swap back to the map page. window.__mapInvalidate = function () { setTimeout(() => map.invalidateSize(), 50); }; } catch (err) { loadingEl.innerHTML = 'Erreur JavaScript: ' + err.message + ''; } })();