Files
fuel-alert/resources/js/components/LeafletMap.vue
Ovidiu U 97e27fc057
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(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection

fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries

- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
  in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
2026-05-14 13:23:52 +01:00

420 lines
12 KiB
Vue

<template>
<div v-if="isOpen" id="leaflet-map-panel" class="space-y-2">
<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-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>
Below average
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
Around average
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-red-500"></span>
Above average
</span>
</div>
</div>
</template>
<script setup>
import {ref, watch, onMounted, onUnmounted, nextTick} from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
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 DEAL_BORDER_COLOURS = {
cheap: '#16a34a',
average: '#475569',
expensive: '#991b1b',
}
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, isSelected = false) {
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>`
: ''
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, '&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},
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
if (radiusMiles <= 2) return 15
if (radiusMiles <= 5) return 14
if (radiusMiles <= 10) return 11
if (radiusMiles <= 15) return 11
if (radiusMiles <= 20) return 10
if (radiusMiles <= 25) return 10
if (radiusMiles <= 50) return 9
return 8
}
// 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="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, 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({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(() => {})
}
navigator.geolocation.getCurrentPosition(
onSuccess,
() => ipFallback(),
{enableHighAccuracy: true, timeout: 10000, maximumAge: 30000},
)
}
function onLocateClick() {
locateUser({pan: true})
}
function initMap() {
if (mapInstance || !mapContainer.value) return
// map-polish:7 — replace default attribution control with custom ⓘ button
mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false})
// Set the initial view immediately so tiles load at the correct spot from
// frame 1 — avoids the "wiggle" caused by setView running after the
// container is already laid out and tiles are mid-load.
const initialZoom = getZoomForRadius(props.radiusMiles)
const initialCenter = props.origin?.lat != null && props.origin?.lng != null
? [props.origin.lat, props.origin.lng]
: props.stations.length
? [props.stations[0].lat, props.stations[0].lng]
: null
if (initialCenter) {
mapInstance.setView(initialCenter, initialZoom)
hasInitialView = true
}
// 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: '&copy; OpenStreetMap contributors &copy; CARTO',
}).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">&copy; OpenStreetMap contributors &copy; CARTO</div>
`
L.DomEvent.disableClickPropagation(wrap)
L.DomEvent.disableScrollPropagation(wrap)
return wrap
}
attribCtl.addTo(mapInstance)
markersLayer = L.layerGroup().addTo(mapInstance)
locateUser()
}
function renderMarkers({skipRecenter = false} = {}) {
if (!mapInstance || !markersLayer) return
markersLayer.clearLayers()
markerByStationId.clear()
if (!props.stations.length) return
const bounds = []
props.stations.forEach((station, index) => {
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', () => {
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])
})
if (skipRecenter) return
const zoom = getZoomForRadius(props.radiusMiles)
const center = props.origin?.lat != null && props.origin?.lng != null
? [props.origin.lat, props.origin.lng]
: bounds[0]
// 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() {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
markersLayer = null
userMarker = null
userAccuracyCircle = null
hasInitialView = false
markerByStationId.clear()
}
}
async function openMap() {
await nextTick()
const wasFreshInit = !mapInstance
initMap()
mapInstance?.invalidateSize()
renderMarkers({skipRecenter: wasFreshInit})
}
onMounted(() => {
if (props.isOpen) openMap()
})
watch(() => props.isOpen, (open) => {
if (open) openMap()
else destroyMap()
})
watch(() => props.stations, () => {
if (props.isOpen) {
renderMarkers()
}
})
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>
/* map-polish:4 — user-location dot + pulse */
.fa-user-loc-icon { background: transparent; border: none; }
.fa-user-loc {
position: relative;
width: 22px;
height: 22px;
}
.fa-user-loc-dot {
position: absolute;
inset: 6px;
background: #2563eb;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.fa-user-loc-pulse {
position: absolute;
inset: 0;
border-radius: 50%;
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; }
}
/* 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>