Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
144
app/components/map/FlightMap.vue
Normal file
144
app/components/map/FlightMap.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { LMap, LTileLayer, LCircleMarker, LPolyline, LPopup } from '@vue-leaflet/vue-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import type { InspirationItem } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
airports: { iata: string; name: string; lat: number; lon: number; city_name: string }[]
|
||||
origin: string | null
|
||||
inspirations: InspirationItem[]
|
||||
budget: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectOrigin: [iata: string]
|
||||
selectDestination: [iata: string]
|
||||
}>()
|
||||
|
||||
const zoom = ref(5)
|
||||
const center = ref<[number, number]>([40.4, -3.7]) // Madrid default
|
||||
|
||||
// Airport lookup
|
||||
const airportMap = computed(() => {
|
||||
const map = new Map<string, (typeof props.airports)[0]>()
|
||||
for (const a of props.airports) map.set(a.iata, a)
|
||||
return map
|
||||
})
|
||||
|
||||
// Filter inspirations by budget
|
||||
const filteredInspirations = computed(() => {
|
||||
if (!props.budget) return props.inspirations
|
||||
return props.inspirations.filter(i => i.minPrice <= props.budget!)
|
||||
})
|
||||
|
||||
// Lines from origin to destinations
|
||||
const routes = computed(() => {
|
||||
const origin = props.origin ? airportMap.value.get(props.origin) : null
|
||||
if (!origin) return []
|
||||
return filteredInspirations.value
|
||||
.map(insp => {
|
||||
const dest = airportMap.value.get(insp.to[0])
|
||||
if (!dest) return null
|
||||
return {
|
||||
from: [origin.lat, origin.lon] as [number, number],
|
||||
to: [dest.lat, dest.lon] as [number, number],
|
||||
iata: insp.to[0],
|
||||
price: insp.minPrice,
|
||||
stops: insp.minStops,
|
||||
destName: dest.name
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as { from: [number, number]; to: [number, number]; iata: string; price: number; stops: number; destName: string }[]
|
||||
})
|
||||
|
||||
// Center on origin when selected
|
||||
watch(() => props.origin, (iata) => {
|
||||
if (iata) {
|
||||
const a = airportMap.value.get(iata)
|
||||
if (a) {
|
||||
center.value = [a.lat, a.lon]
|
||||
zoom.value = 5
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function priceColor(price: number): string {
|
||||
if (price < 30) return '#22c55e'
|
||||
if (price < 60) return '#84cc16'
|
||||
if (price < 100) return '#eab308'
|
||||
if (price < 200) return '#f97316'
|
||||
return '#ef4444'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[500px] rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
||||
<LMap :zoom="zoom" :center="center" :use-global-leaflet="false">
|
||||
<LTileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution="© OpenStreetMap © CARTO"
|
||||
/>
|
||||
|
||||
<!-- All airports as small dots -->
|
||||
<LCircleMarker
|
||||
v-for="a in airports.filter(a => a.lat && a.lon)"
|
||||
:key="a.iata"
|
||||
:lat-lng="[a.lat, a.lon]"
|
||||
:radius="origin === a.iata ? 8 : 3"
|
||||
:color="origin === a.iata ? '#3b82f6' : '#94a3b8'"
|
||||
:fill-opacity="origin === a.iata ? 0.8 : 0.4"
|
||||
:weight="origin === a.iata ? 2 : 1"
|
||||
@click="$emit('selectOrigin', a.iata)"
|
||||
>
|
||||
<LPopup>
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold">{{ a.iata }} - {{ a.name }}</p>
|
||||
<p v-if="a.city_name" class="text-neutral-500">{{ a.city_name }}</p>
|
||||
<UButton
|
||||
v-if="origin !== a.iata"
|
||||
label="Buscar desde aqui"
|
||||
size="xs"
|
||||
class="mt-1"
|
||||
@click="$emit('selectOrigin', a.iata)"
|
||||
/>
|
||||
</div>
|
||||
</LPopup>
|
||||
</LCircleMarker>
|
||||
|
||||
<!-- Route lines -->
|
||||
<template v-for="r in routes" :key="r.iata">
|
||||
<LPolyline
|
||||
:lat-lngs="[r.from, r.to]"
|
||||
:color="priceColor(r.price)"
|
||||
:weight="2"
|
||||
:opacity="0.6"
|
||||
/>
|
||||
<LCircleMarker
|
||||
:lat-lng="r.to"
|
||||
:radius="6"
|
||||
:color="priceColor(r.price)"
|
||||
:fill-opacity="0.8"
|
||||
:weight="2"
|
||||
>
|
||||
<LPopup>
|
||||
<div class="text-sm min-w-32">
|
||||
<p class="font-semibold">{{ r.iata }} - {{ r.destName }}</p>
|
||||
<p class="text-lg font-bold" :style="{ color: priceColor(r.price) }">
|
||||
{{ r.price.toFixed(0) }}€
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ r.stops === 0 ? 'Directo' : `${r.stops} escala(s)` }}
|
||||
</p>
|
||||
<UButton
|
||||
label="Ver vuelos"
|
||||
size="xs"
|
||||
class="mt-1"
|
||||
@click="$emit('selectDestination', r.iata)"
|
||||
/>
|
||||
</div>
|
||||
</LPopup>
|
||||
</LCircleMarker>
|
||||
</template>
|
||||
</LMap>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user