Files
fuel-price/resources/js/components/LeafletMap.vue
Ovidiu U 7dc41ba9ee
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
feat: add location-based search, redesign station cards, and implement URL state management
- Support geolocation search (lat/lng) as alternative to postcode with automatic fallback
- Redesign StationCard with expanded layout showing address, distance in miles, reliability status, directions link, and optional remove button
- Add directions integration with Google Maps including origin parameter support
- Persist search parameters (postcode/coords, fuel type, radius, sort) in URL query and hydrate on mount
- Implement compact map markers with inline directions link and click-to-zoom behavior
- Auto-trigger search when filters change (fuel type, radius, sort) if search already performed
- Add removable prop to StationCard for watchlist integration
- Display reliability status (Current/Stale/Outdated) with color-coded pricing
- Remove 2-mile radius option from search filters
2026-04-20 15:51:02 +01:00

310 lines
8.8 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 class="space-y-2">
<button
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
@click="toggleMap"
>
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
{{ isOpen ? 'Hide map' : 'Show map' }}
</button>
<template v-if="isOpen">
<div
ref="mapContainer"
class="w-full h-72 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>
</template>
</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, origin) {
const isFirst = index === 0
const h = isFirst ? 20 : 18
const fontSize = isFirst ? 11 : 10
const iconSize = isFirst ? 11 : 10
const star = isFirst
? `<span style="margin-right:2px;color:#facc15;font-size:10px;line-height:1;">★</span>`
: ''
const directionsUrl = escHtml(buildDirectionsUrl(station, origin))
const navSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>`
return `<div style="display:inline-flex;align-items:center;height:${h}px;padding:0 4px 0 6px;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;gap:3px;">
${star}<span>${Number(station.price).toFixed(1)}</span><a data-directions href="${directionsUrl}" target="_blank" rel="noopener" aria-label="Directions" style="display:inline-flex;align-items:center;justify-content:center;width:${h - 6}px;height:${h - 6}px;margin-left:1px;border-radius:50%;background:rgba(255,255,255,0.22);color:#fff;text-decoration:none;">${navSvg}</a>
</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},
defaultOpen: {type: Boolean, default: false},
radiusMiles: {type: Number, default: 10},
origin: {type: Object, default: null},
})
const mapContainer = ref(null)
const isOpen = ref(false)
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 13
if (radiusMiles <= 15) return 11
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)
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 popup = `
<div style="min-width:160px">
<strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}<br>
<span style="font-size:20px;font-weight:700;color:${escHtml(colour)}">${Number(station.price).toFixed(1)}p</span><br>
<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 ? 65 : 56
const h = isFirst ? 20 : 18
const icon = L.divIcon({
className: '',
iconSize: [w, h],
iconAnchor: [w / 2, h / 2],
html: buildMarkerHtml(station, index, colour, borderColour, props.origin),
})
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})
})
markersLayer.addLayer(marker)
bounds.push([station.lat, station.lng])
})
const zoom = getZoomForRadius(props.radiusMiles)
if (bounds.length === 1) {
mapInstance.setView(bounds[0], zoom)
} else {
mapInstance.fitBounds(bounds, {padding: [40, 40], maxZoom: zoom})
}
}
async function toggleMap() {
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
}
onMounted(async () => {
if (props.defaultOpen) {
isOpen.value = true
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
})
watch(() => props.stations, () => {
if (isOpen.value) {
renderMarkers()
}
})
onUnmounted(() => {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</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>