- 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
420 lines
12 KiB
Vue
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
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: '© OpenStreetMap contributors © 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">© OpenStreetMap contributors © 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>
|