import L from 'leaflet'; import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow, }); function escHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } const CLASSIFICATION_COLOURS = { current: '#22c55e', recent: '#64748b', stale: '#f59e0b', outdated: '#ef4444', }; const UK_CENTRE = [54.0, -2.0]; const UK_ZOOM = 7; const USER_MARKER_CSS = ` @keyframes fuelalert-pulse { 0% { transform: scale(1); opacity: 0.6; } 70% { transform: scale(2.8); opacity: 0; } 100% { transform: scale(1); opacity: 0; } } .fuelalert-user-marker { position: relative; width: 16px; height: 16px; } .fuelalert-user-dot { position: absolute; inset: 0; border-radius: 50%; background: #3b82f6; border: 2px solid #fff; box-shadow: 0 0 0 2px #3b82f6; } .fuelalert-user-ring { position: absolute; inset: 0; border-radius: 50%; background: #3b82f6; animation: fuelalert-pulse 2s ease-out infinite; } `; function injectUserMarkerStyles() { if (document.getElementById('fuelalert-user-marker-styles')) return; const style = document.createElement('style'); style.id = 'fuelalert-user-marker-styles'; style.textContent = USER_MARKER_CSS; document.head.appendChild(style); } export function stationMap(results, meta, radius) { return { results, meta, radius, _map: null, _markers: [], _userMarker: null, init() { injectUserMarkerStyles(); this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(this._map); if (this.results && this.results.length > 0) { this._plotMarkers(); } this.$watch('results', () => this._plotMarkers()); this.locateUser(); }, getZoomForRadius(radiusMiles) { if (radiusMiles <= 1) return 15; if (radiusMiles <= 2) return 14; if (radiusMiles <= 5) return 12; if (radiusMiles <= 10) return 11; if (radiusMiles <= 15) return 10; if (radiusMiles <= 25) return 9; if (radiusMiles <= 50) return 8; return 7; }, _clearMarkers() { this._markers.forEach((m) => m.remove()); this._markers = []; }, addUserMarker(lat, lng) { if (this._userMarker) { this._userMarker.remove(); } const icon = L.divIcon({ className: '', html: '
', iconSize: [16, 16], iconAnchor: [8, 8], }); this._userMarker = L.marker([lat, lng], { icon, zIndexOffset: 1000 }) .bindPopup('Your location') .addTo(this._map); console.log(`[stationMap] user marker lat=${lat} lng=${lng}`); }, locateUser() { if (!navigator.geolocation) { console.warn('[stationMap] Geolocation not supported'); return; } const ipFallback = () => { fetch('https://ipapi.co/json/') .then((r) => r.json()) .then((d) => d.latitude && d.longitude && this.addUserMarker(d.latitude, d.longitude)) .catch(() => {}); }; // Quick low-accuracy fix first — places the marker immediately. navigator.geolocation.getCurrentPosition( (pos) => { this.addUserMarker(pos.coords.latitude, pos.coords.longitude); // Then refine with high accuracy if GPS is available. navigator.geolocation.getCurrentPosition( (precise) => this.addUserMarker(precise.coords.latitude, precise.coords.longitude), () => {}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, ); }, () => ipFallback(), { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }, ); }, destroy() { if (this._map) { this._map.remove(); this._map = null; this._markers = []; this._userMarker = null; } }, _plotMarkers() { if (!this._map) { return; } this._clearMarkers(); if (!this.results || this.results.length === 0) { return; } this.results.forEach((station) => { const colour = escHtml(CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'); const miles = (station.distance_km * 0.621371).toFixed(1); const supermarketTag = station.is_supermarket ? 'Supermarket' : ''; const popup = `
${escHtml(station.name)}${supermarketTag}
${Number(station.price).toFixed(1)}p
${escHtml(miles)} miles away
${escHtml(station.address)}, ${escHtml(station.postcode)}
`; const marker = L.circleMarker([station.lat, station.lng], { radius: 9, fillColor: colour, color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.85, }).bindPopup(popup); marker.addTo(this._map); this._markers.push(marker); }); const map = this._map; const lat = this.meta?.lat; const lng = this.meta?.lng; const zoom = this.getZoomForRadius(this.radius); setTimeout(() => { map.invalidateSize(); map.setView([lat, lng], zoom, { animate: true, duration: 0.5 }); console.log(`[stationMap] setView lat=${lat} lng=${lng} zoom=${zoom} (radius=${this.radius}mi)`); }, 50); }, }; }