Files
fuel-price/resources/js/components/LeafletMap.vue
Ovidiu U dd9bd95657
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Redesign search UI with unified input, expandable filters, and integrated map controls
- Consolidate HeroSearch into single responsive form with inline geolocation button and submit actions
- Transform SearchBar into pill-based filter bar with visual state indicators (active filters highlighted)
- Move map toggle from separate component into SearchBar with open/close state management
- Redesign StationList sort controls as pills with icons, move brand filter inline, add result count
- Expand LeafletMap to full-width panel (96 viewport height) controlled by parent open state
- Remove nested mobile/desktop layouts in HeroSearch in favor of single adaptive form
- Add "Refine" and "Sort" labels to filter groups, implement clear-all filters button
- Show verdict card only before first search on mobile, hide after results load
- Position StatsRow within hero gradient, move results section into same gradient container
- Update map initialization to only occur when panel is open, destroy on close
- Add accessibility labels (aria-expanded, aria-controls) to map toggle button
2026-04-22 09:38:23 +01:00

311 lines
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="isOpen" id="leaflet-map-panel" class="space-y-2">
<div
ref="mapContainer"
class="w-full h-96 md:h-160 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></div>
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-green-500"></span>
Current (&lt;24h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
Recent (2448h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
Stale (25 days)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-red-500"></span>
Outdated (5+ days)
</span>
</div>
</div>
</template>
<script setup>
import {ref, watch, onMounted, onUnmounted, nextTick} from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const CLASSIFICATION_COLOURS = {
current: '#22c55e',
recent: '#64748b',
stale: '#f59e0b',
outdated: '#ef4444',
}
const CLASSIFICATION_BORDER_COLOURS = {
current: '#16a34a',
recent: '#475569',
stale: '#d97706',
outdated: '#dc2626',
}
function buildDirectionsUrl(station, origin) {
const base = `https://www.google.com/maps/dir/?api=1&destination=${station.lat},${station.lng}`
if (origin?.lat != null && origin?.lng != null) {
return `${base}&origin=${origin.lat},${origin.lng}`
}
return base
}
function buildMarkerHtml(station, index, colour, borderColour) {
const isFirst = index === 0
const w = isFirst ? 46 : 40
const h = isFirst ? 20 : 18
const fontSize = isFirst ? 11 : 10
const star = isFirst
? `<span style="margin-right:2px;color:#facc15;font-size:10px;line-height:1;">★</span>`
: ''
return `<div style="display:inline-flex;align-items:center;justify-content:center;width:${w}px;height:${h}px;padding:0 5px;background:${colour};color:#fff;font-weight:700;font-size:${fontSize}px;line-height:1;letter-spacing:-0.2px;border-radius:10px;border:1.5px solid ${borderColour};box-shadow:0 1px 3px rgba(0,0,0,0.25);white-space:nowrap;">
${star}${Number(station.price).toFixed(1)}
</div>`
}
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const props = defineProps({
stations: {type: Array, required: true},
isOpen: {type: Boolean, default: true},
radiusMiles: {type: Number, default: 10},
origin: {type: Object, default: null},
})
const mapContainer = ref(null)
let mapInstance = null
let markersLayer = null
let userMarker = null
function getZoomForRadius(radiusMiles) {
if (radiusMiles <= 1) return 16
if (radiusMiles <= 2) return 15
if (radiusMiles <= 5) return 14
if (radiusMiles <= 10) return 12
if (radiusMiles <= 15) return 12
if (radiusMiles <= 20) return 10
if (radiusMiles <= 25) return 10
if (radiusMiles <= 50) return 9
return 8
}
function addUserMarker(lat, lng) {
if (userMarker) {
userMarker.remove()
}
const pulseHtml =
'<div class="absolute w-full h-full bg-blue-500/30 rounded-full animate-ping"></div>'
;
const icon = L.divIcon({
html: `
<div class="relative w-10 h-10 flex items-center justify-center">
${pulseHtml}
<div class="relative w-4 h-4 bg-blue-600 border-2 border-white rounded-full shadow-lg"></div>
</div>
`,
className: 'user-location-div-icon',
iconSize: [40, 40],
iconAnchor: [20, 20]
})
userMarker = L.marker([lat, lng], {icon, zIndexOffset: -100})
.bindPopup('Your location')
.addTo(mapInstance)
}
function locateUser() {
if (!navigator.geolocation) return
const ipFallback = () => {
fetch('https://ipapi.co/json/')
.then(r => r.json())
.then(d => d.latitude && d.longitude && addUserMarker(d.latitude, d.longitude))
.catch(() => {
})
}
navigator.geolocation.getCurrentPosition(
pos => {
addUserMarker(pos.coords.latitude, pos.coords.longitude)
navigator.geolocation.getCurrentPosition(
precise => addUserMarker(precise.coords.latitude, precise.coords.longitude),
() => {
},
{enableHighAccuracy: true, timeout: 10000, maximumAge: 0},
)
},
() => ipFallback(),
{enableHighAccuracy: false, timeout: 5000, maximumAge: 60000},
)
}
function initMap() {
if (mapInstance || !mapContainer.value) return
mapInstance = L.map(mapContainer.value)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
}).addTo(mapInstance)
markersLayer = L.layerGroup().addTo(mapInstance)
mapInstance.on('zoomend', () => {
console.log('Map zoom:', mapInstance.getZoom())
})
locateUser()
}
function renderMarkers() {
if (!mapInstance || !markersLayer) return
markersLayer.clearLayers()
if (!props.stations.length) return
const bounds = []
props.stations.forEach((station, index) => {
const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'
const borderColour = CLASSIFICATION_BORDER_COLOURS[station.price_classification] ?? '#475569'
const miles = ((station.distance_km ?? 0) * 0.621371).toFixed(1)
const supermarketTag = station.is_supermarket
? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>'
: ''
const directionsUrl = escHtml(buildDirectionsUrl(station, props.origin))
const popup = `
<div style="min-width:180px">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">
<div style="min-width:0;flex:1;">
<strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}
</div>
<a data-directions href="${directionsUrl}" target="_blank" rel="noopener" aria-label="Directions" style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;border-radius:8px;;color:black;text-decoration:none;"><iconify-icon icon="lucide:navigation" style="font-size:16px;"></iconify-icon></a>
</div>
<div style="margin-top:4px;"><span style="font-size:20px;font-weight:700;color:${escHtml(colour)}">${Number(station.price).toFixed(1)}p</span></div>
<span style="font-size:12px;color:#6b7280">${escHtml(miles)} miles away</span><br>
<span style="font-size:11px;color:#9ca3af">${escHtml(station.address)}, ${escHtml(station.postcode)}</span>
</div>
`
const isFirst = index === 0
const w = isFirst ? 46 : 40
const h = isFirst ? 20 : 18
const icon = L.divIcon({
className: '',
iconSize: [w, h],
iconAnchor: [w / 2, h / 2],
html: buildMarkerHtml(station, index, colour, borderColour),
})
const marker = L.marker([station.lat, station.lng], {icon}).bindPopup(popup)
marker.on('click', () => {
const target = Math.max(mapInstance.getZoom(), 16)
mapInstance.setView([station.lat, station.lng], target, {animate: true})
})
marker.on('popupopen', (e) => {
const link = e.popup.getElement()?.querySelector('a[data-directions]')
if (link) L.DomEvent.disableClickPropagation(link)
})
markersLayer.addLayer(marker)
bounds.push([station.lat, station.lng])
})
const zoom = getZoomForRadius(props.radiusMiles)
const center = props.origin?.lat != null && props.origin?.lng != null
? [props.origin.lat, props.origin.lng]
: bounds[0]
mapInstance.setView(center, zoom)
}
function destroyMap() {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
markersLayer = null
userMarker = null
}
}
async function openMap() {
await nextTick()
initMap()
mapInstance?.invalidateSize()
renderMarkers()
}
onMounted(() => {
if (props.isOpen) openMap()
})
watch(() => props.isOpen, (open) => {
if (open) openMap()
else destroyMap()
})
watch(() => props.stations, () => {
if (props.isOpen) {
renderMarkers()
}
})
onUnmounted(destroyMap)
</script>
<style>
.fuelalert-user-marker {
position: relative;
width: 16px;
height: 16px;
}
.fuelalert-user-dot {
position: absolute;
inset: 3px;
background: #3b82f6;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.fuelalert-user-ring {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(59, 130, 246, 0.25);
animation: fuelalert-pulse 2s ease-out infinite;
}
@keyframes fuelalert-pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(3);
opacity: 0;
}
}
</style>