Files
fuel-price/resources/js/components/LeafletMap.vue
Ovidiu U d25883ead4
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 geolocation support with Near Me button and user location marker on map
- Add "Near Me" button to SearchBar with loading state and geolocation via postcodes.io API
- Display user location on map with pulsing blue marker using geolocation API with IP fallback
- Adjust map zoom level based on search radius for better context
- Pass radiusMiles prop from
2026-04-11 21:27:11 +01:00

312 lines
9.7 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'
import {zoom} from "leaflet/src/control/Control.Zoom.js";
const CLASSIFICATION_COLOURS = {
current: '#22c55e',
recent: '#64748b',
stale: '#f59e0b',
outdated: '#ef4444',
}
const CLASSIFICATION_BORDER_COLOURS = {
current: '#16a34a',
recent: '#475569',
stale: '#d97706',
outdated: '#dc2626',
}
function buildMarkerHtml(station, index, colour, borderColour) {
const isFirst = index === 0
const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51
const bw = isFirst ? 56 : 52
const bh = isFirst ? 43 : 38
const br = isFirst ? 17 : 15
const tailTop = isFirst ? 45 : 40
const tailW = isFirst ? 9 : 7
const tailH = isFirst ? 11 : 9
const badgeSize = isFirst ? 18 : 16
const badgeFontSize = isFirst ? 10 : 8
const priceFontSize = isFirst ? 12 : 11
const initial = escHtml((station.brand || station.name || '?')[0].toUpperCase())
const badge = isFirst
? `<div style="position:absolute;top:-4px;right:-4px;width:${badgeSize}px;height:${badgeSize}px;background:#facc15;border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid white;box-shadow:0 2px 4px rgba(0,0,0,0.2);z-index:30;"><span style="font-size:${badgeFontSize}px;color:#713f12;">★</span></div>`
: `<div style="position:absolute;top:-3px;right:-3px;width:${badgeSize}px;height:${badgeSize}px;background:#374151;border-radius:50%;display:flex;align-items:center;justify-content:center;border:1px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.2);z-index:30;"><span style="font-size:${badgeFontSize}px;font-weight:bold;color:white;">${index + 1}</span></div>`
return `<div style="position:relative;width:${w}px;height:${h}px;">
${badge}
<div style="position:absolute;top:6px;left:50%;transform:translateX(-50%);width:${bw}px;height:${bh}px;background:${colour};border-radius:${br}px;border:3px solid ${borderColour};box-shadow:0 2px 6px rgba(0,0,0,0.24),inset 0 1px 0 rgba(255,255,255,0.22);overflow:hidden;z-index:10;">
<div style="position:absolute;top:0;left:0;right:0;height:11px;background:rgba(15,23,42,0.20);border-bottom:1px solid rgba(255,255,255,0.24);display:flex;align-items:center;justify-content:center;">
<span style="font-size:9px;font-weight:700;letter-spacing:-0.05px;color:rgba(255,255,255,0.84);text-transform:uppercase;line-height:1;">${initial}</span>
</div>
<div style="position:absolute;left:3px;right:3px;top:12px;bottom:2px;display:flex;align-items:center;justify-content:center;text-align:center;">
<span style="display:inline-block;max-width:${bw - 13}px;color:#ffffff;font-size:${priceFontSize}px;font-weight:800;letter-spacing:-0.1px;line-height:1.12;white-space:nowrap;text-shadow:0 1px 1px rgba(0,0,0,0.42);">${Number(station.price).toFixed(1)}p</span>
</div>
</div>
<div style="position:absolute;top:${tailTop}px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:${tailW}px solid transparent;border-right:${tailW}px solid transparent;border-top:${tailH}px solid ${colour};z-index:5;"></div>
</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},
})
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 ? 63 : 59
const h = isFirst ? 58 : 51
const icon = L.divIcon({
className: '',
iconSize: [w, h],
iconAnchor: [w / 2, h],
html: buildMarkerHtml(station, index, colour, borderColour),
})
const marker = L.marker([station.lat, station.lng], {icon}).bindPopup(popup)
markersLayer.addLayer(marker)
bounds.push([station.lat, station.lng])
})
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>