feat(ui): consolidate map filters and rework station selection
- replace inline filter pills with a single "Filters" popover containing small pill buttons for fuel/radius/sort/brand (no native <select>s) - map polish: Carto Positron tiles, hidden zoom buttons, locate-me floating button + accuracy ring, smooth flyTo transitions, slim ⓘ attribution - map markers no longer open Leaflet popups; clicking a marker selects the station and surfaces the existing StationCard inline over the map, with swipe-down-to-close and a small overlay × button - price colour now reflects deal quality (cheap / average / expensive vs search avg ± 3p) on both list and map — stable across sort/filter - promote the "X ago" timestamp into the card header so it stays visible in the expanded state Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,31 @@
|
||||
<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="relative w-full h-96 md:h-160 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm">
|
||||
<div ref="mapContainer" class="absolute inset-0"></div>
|
||||
<!-- map-polish:4 — Locate-me floating button -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show my location"
|
||||
class="absolute bottom-4 right-4 z-[900] inline-flex items-center justify-center w-10 h-10 rounded-full bg-white border border-zinc-200 text-zinc-700 shadow-md hover:bg-zinc-50 active:scale-95 transition cursor-pointer"
|
||||
@click="onLocateClick"
|
||||
>
|
||||
<iconify-icon icon="lucide:locate-fixed" class="text-lg"></iconify-icon>
|
||||
</button>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 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 (<24h)
|
||||
Below average
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
|
||||
Recent (24–48h)
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
|
||||
Stale (2–5 days)
|
||||
Around average
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block size-3 rounded-full bg-red-500"></span>
|
||||
Outdated (5+ days)
|
||||
Above average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,18 +36,18 @@ 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 MARKER_FOCUS_ZOOM = 13 // map-polish:6 — minimum zoom when focusing a marker
|
||||
|
||||
const DEAL_COLOURS = {
|
||||
cheap: '#22c55e', // green — below market
|
||||
average: '#64748b', // slate — around market
|
||||
expensive: '#dc2626', // red — above market
|
||||
}
|
||||
|
||||
const CLASSIFICATION_BORDER_COLOURS = {
|
||||
current: '#16a34a',
|
||||
recent: '#475569',
|
||||
stale: '#d97706',
|
||||
outdated: '#dc2626',
|
||||
const DEAL_BORDER_COLOURS = {
|
||||
cheap: '#16a34a',
|
||||
average: '#475569',
|
||||
expensive: '#991b1b',
|
||||
}
|
||||
|
||||
function buildDirectionsUrl(station, origin) {
|
||||
@@ -53,7 +58,7 @@ function buildDirectionsUrl(station, origin) {
|
||||
return base
|
||||
}
|
||||
|
||||
function buildMarkerHtml(station, index, colour, borderColour) {
|
||||
function buildMarkerHtml(station, index, colour, borderColour, isSelected = false) {
|
||||
const isFirst = index === 0
|
||||
const w = isFirst ? 46 : 40
|
||||
const h = isFirst ? 20 : 18
|
||||
@@ -62,11 +67,29 @@ function buildMarkerHtml(station, index, colour, borderColour) {
|
||||
? `<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;">
|
||||
const ringStyle = isSelected
|
||||
? 'box-shadow:0 0 0 3px rgba(187,91,62,0.35),0 1px 3px rgba(0,0,0,0.25);transform:scale(1.12);'
|
||||
: 'box-shadow:0 1px 3px rgba(0,0,0,0.25);'
|
||||
|
||||
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};${ringStyle}white-space:nowrap;transition:transform .15s ease,box-shadow .15s ease;">
|
||||
${star}${Number(station.price).toFixed(1)}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildStationIcon(station, index, isSelected) {
|
||||
const isFirst = index === 0
|
||||
const w = isFirst ? 46 : 40
|
||||
const h = isFirst ? 20 : 18
|
||||
const colour = DEAL_COLOURS[station.deal_quality] ?? DEAL_COLOURS.average
|
||||
const borderColour = DEAL_BORDER_COLOURS[station.deal_quality] ?? DEAL_BORDER_COLOURS.average
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [w, h],
|
||||
iconAnchor: [w / 2, h / 2],
|
||||
html: buildMarkerHtml(station, index, colour, borderColour, isSelected),
|
||||
})
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -80,12 +103,18 @@ const props = defineProps({
|
||||
isOpen: {type: Boolean, default: true},
|
||||
radiusMiles: {type: Number, default: 10},
|
||||
origin: {type: Object, default: null},
|
||||
selectedStationId: {type: String, default: null},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['station-select'])
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let mapInstance = null
|
||||
let markersLayer = null
|
||||
let userMarker = null
|
||||
let userAccuracyCircle = null // map-polish:4
|
||||
let hasInitialView = false // map-polish:6 — flyTo needs an existing view
|
||||
const markerByStationId = new Map()
|
||||
|
||||
function getZoomForRadius(radiusMiles) {
|
||||
if (radiusMiles <= 1) return 16
|
||||
@@ -100,73 +129,103 @@ function getZoomForRadius(radiusMiles) {
|
||||
}
|
||||
|
||||
|
||||
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>'
|
||||
;
|
||||
// map-polish:4 — accuracy ring rendered alongside the user dot
|
||||
function addUserMarker(lat, lng, accuracyMeters = null) {
|
||||
if (userMarker) userMarker.remove()
|
||||
if (userAccuracyCircle) userAccuracyCircle.remove()
|
||||
|
||||
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]
|
||||
<div class="fa-user-loc">
|
||||
<div class="fa-user-loc-pulse"></div>
|
||||
<div class="fa-user-loc-dot"></div>
|
||||
</div>
|
||||
`,
|
||||
className: 'fa-user-loc-icon',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
})
|
||||
|
||||
userMarker = L.marker([lat, lng], {icon, zIndexOffset: -100})
|
||||
.bindPopup('Your location')
|
||||
userMarker = L.marker([lat, lng], {icon, zIndexOffset: -100, interactive: false})
|
||||
.addTo(mapInstance)
|
||||
|
||||
if (accuracyMeters && accuracyMeters > 0) {
|
||||
userAccuracyCircle = L.circle([lat, lng], {
|
||||
radius: accuracyMeters,
|
||||
color: '#3b82f6',
|
||||
weight: 1,
|
||||
opacity: 0.4,
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.08,
|
||||
interactive: false,
|
||||
}).addTo(mapInstance)
|
||||
}
|
||||
}
|
||||
|
||||
function locateUser() {
|
||||
function locateUser({pan = false} = {}) {
|
||||
if (!navigator.geolocation) return
|
||||
|
||||
const onSuccess = (pos) => {
|
||||
const {latitude, longitude, accuracy} = pos.coords
|
||||
addUserMarker(latitude, longitude, accuracy)
|
||||
// map-polish:6 — smooth flyTo when explicitly locating the user
|
||||
if (pan && mapInstance) {
|
||||
const zoom = hasInitialView ? Math.max(mapInstance.getZoom(), 13) : 13
|
||||
if (hasInitialView) {
|
||||
mapInstance.flyTo([latitude, longitude], zoom, {duration: 0.8})
|
||||
} else {
|
||||
mapInstance.setView([latitude, longitude], zoom)
|
||||
hasInitialView = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ipFallback = () => {
|
||||
fetch('https://ipapi.co/json/')
|
||||
.then(r => r.json())
|
||||
.then(d => d.latitude && d.longitude && addUserMarker(d.latitude, d.longitude))
|
||||
.catch(() => {
|
||||
})
|
||||
.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},
|
||||
)
|
||||
},
|
||||
onSuccess,
|
||||
() => ipFallback(),
|
||||
{enableHighAccuracy: false, timeout: 5000, maximumAge: 60000},
|
||||
{enableHighAccuracy: true, timeout: 10000, maximumAge: 30000},
|
||||
)
|
||||
}
|
||||
|
||||
function onLocateClick() {
|
||||
locateUser({pan: true})
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (mapInstance || !mapContainer.value) return
|
||||
|
||||
mapInstance = L.map(mapContainer.value)
|
||||
// map-polish:7 — replace default attribution control with custom ⓘ button
|
||||
mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
// map-polish:5 — Carto Positron tile (cleaner than raw OSM)
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
}).addTo(mapInstance)
|
||||
|
||||
markersLayer = L.layerGroup().addTo(mapInstance)
|
||||
// map-polish:7 — small ⓘ control with attribution on click/hover
|
||||
const attribCtl = L.control({position: 'bottomleft'})
|
||||
attribCtl.onAdd = () => {
|
||||
const wrap = L.DomUtil.create('div', 'fa-attrib')
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="fa-attrib-btn" aria-label="Map attribution">i</button>
|
||||
<div class="fa-attrib-popover" role="tooltip">© OpenStreetMap contributors © CARTO</div>
|
||||
`
|
||||
L.DomEvent.disableClickPropagation(wrap)
|
||||
L.DomEvent.disableScrollPropagation(wrap)
|
||||
return wrap
|
||||
}
|
||||
attribCtl.addTo(mapInstance)
|
||||
|
||||
mapInstance.on('zoomend', () => {
|
||||
console.log('Map zoom:', mapInstance.getZoom())
|
||||
})
|
||||
markersLayer = L.layerGroup().addTo(mapInstance)
|
||||
|
||||
locateUser()
|
||||
}
|
||||
@@ -175,59 +234,31 @@ function renderMarkers() {
|
||||
if (!mapInstance || !markersLayer) return
|
||||
|
||||
markersLayer.clearLayers()
|
||||
markerByStationId.clear()
|
||||
|
||||
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)
|
||||
const isSelected = props.selectedStationId === station.station_id
|
||||
const icon = buildStationIcon(station, index, isSelected)
|
||||
const marker = L.marker([station.lat, station.lng], {icon, _stationIndex: index})
|
||||
|
||||
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)
|
||||
emit('station-select', station.station_id)
|
||||
const target = Math.max(mapInstance.getZoom(), MARKER_FOCUS_ZOOM)
|
||||
// map-polish:6 — flyTo for smooth marker focus (view always set by now)
|
||||
if (hasInitialView) {
|
||||
mapInstance.flyTo([station.lat, station.lng], target, {duration: 0.6})
|
||||
} else {
|
||||
mapInstance.setView([station.lat, station.lng], target)
|
||||
hasInitialView = true
|
||||
}
|
||||
})
|
||||
|
||||
markersLayer.addLayer(marker)
|
||||
markerByStationId.set(station.station_id, {marker, station, index})
|
||||
bounds.push([station.lat, station.lng])
|
||||
})
|
||||
|
||||
@@ -236,7 +267,13 @@ function renderMarkers() {
|
||||
? [props.origin.lat, props.origin.lng]
|
||||
: bounds[0]
|
||||
|
||||
mapInstance.setView(center, zoom)
|
||||
// map-polish:6 — flyTo only after the initial view exists
|
||||
if (hasInitialView) {
|
||||
mapInstance.flyTo(center, zoom, {duration: 0.6})
|
||||
} else {
|
||||
mapInstance.setView(center, zoom)
|
||||
hasInitialView = true
|
||||
}
|
||||
}
|
||||
|
||||
function destroyMap() {
|
||||
@@ -245,6 +282,9 @@ function destroyMap() {
|
||||
mapInstance = null
|
||||
markersLayer = null
|
||||
userMarker = null
|
||||
userAccuracyCircle = null
|
||||
hasInitialView = false
|
||||
markerByStationId.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,41 +310,93 @@ watch(() => props.stations, () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.selectedStationId, (newId, oldId) => {
|
||||
if (!mapInstance) return
|
||||
|
||||
if (oldId) {
|
||||
const prev = markerByStationId.get(oldId)
|
||||
if (prev) prev.marker.setIcon(buildStationIcon(prev.station, prev.index, false))
|
||||
}
|
||||
if (newId) {
|
||||
const next = markerByStationId.get(newId)
|
||||
if (next) {
|
||||
next.marker.setIcon(buildStationIcon(next.station, next.index, true))
|
||||
if (hasInitialView) {
|
||||
const ll = next.marker.getLatLng()
|
||||
mapInstance.flyTo(ll, Math.max(mapInstance.getZoom(), MARKER_FOCUS_ZOOM), {duration: 0.5})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(destroyMap)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fuelalert-user-marker {
|
||||
/* map-polish:4 — user-location dot + pulse */
|
||||
.fa-user-loc-icon { background: transparent; border: none; }
|
||||
.fa-user-loc {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.fuelalert-user-dot {
|
||||
.fa-user-loc-dot {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
background: #3b82f6;
|
||||
inset: 6px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fuelalert-user-ring {
|
||||
.fa-user-loc-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
animation: fuelalert-pulse 2s ease-out infinite;
|
||||
background: rgba(37, 99, 235, 0.25);
|
||||
animation: fa-user-pulse 2s ease-out infinite;
|
||||
}
|
||||
@keyframes fa-user-pulse {
|
||||
0% { transform: scale(0.6); opacity: 0.8; }
|
||||
100% { transform: scale(2.2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes fuelalert-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
/* map-polish:7 — discrete attribution control */
|
||||
.fa-attrib { position: relative; }
|
||||
.fa-attrib-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
color: #6b7280;
|
||||
font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.fa-attrib-btn:hover { color: #111827; }
|
||||
.fa-attrib-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #4b5563;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(2px);
|
||||
transition: opacity .15s ease, transform .15s ease;
|
||||
}
|
||||
.fa-attrib:hover .fa-attrib-popover,
|
||||
.fa-attrib:focus-within .fa-attrib-popover {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user