131 lines
4.4 KiB
Vue
131 lines
4.4 KiB
Vue
<template>
|
|
<component
|
|
:is="linkable ? NuxtLink : 'div'"
|
|
:to="linkable ? resolvedLink : undefined"
|
|
class="block"
|
|
:class="{ 'cursor-pointer': selectable }"
|
|
@click="selectable && $emit('select')"
|
|
@mouseenter="$emit('hover', true)"
|
|
@mouseleave="$emit('hover', false)"
|
|
>
|
|
<Card
|
|
padding="small"
|
|
:interactive="linkable || selectable"
|
|
:class="[
|
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
|
]"
|
|
>
|
|
<div class="flex flex-col gap-1">
|
|
<!-- Title + distance/compass -->
|
|
<div class="flex items-start justify-between gap-2">
|
|
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
|
|
<div class="flex items-center gap-2 text-xs text-white/70 whitespace-nowrap">
|
|
<Text v-if="distanceLabel" size="xs" class="text-white/70">{{ distanceLabel }}</Text>
|
|
<div v-if="bearing !== null" class="flex items-center gap-1">
|
|
<div class="w-6 h-6 rounded-full border border-white/20 bg-white/5 flex items-center justify-center">
|
|
<Icon
|
|
name="lucide:arrow-up"
|
|
size="12"
|
|
class="text-white/80"
|
|
:style="{ transform: `rotate(${bearing}deg)` }"
|
|
/>
|
|
</div>
|
|
<Text size="xs" class="text-white/60">{{ Math.round(bearing) }}°</Text>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Country -->
|
|
<div class="flex items-center justify-between">
|
|
<Text tone="muted" size="sm">
|
|
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
|
|
</Text>
|
|
</div>
|
|
<!-- Transport icons bottom -->
|
|
<div v-if="hub.transportTypes?.length" class="flex items-center gap-1 pt-1">
|
|
<Icon v-if="hasTransport('auto')" name="lucide:truck" size="14" class="text-base-content/50" />
|
|
<Icon v-if="hasTransport('rail')" name="lucide:train-front" size="14" class="text-base-content/50" />
|
|
<Icon v-if="hasTransport('sea')" name="lucide:ship" size="14" class="text-base-content/50" />
|
|
<Icon v-if="hasTransport('air')" name="lucide:plane" size="14" class="text-base-content/50" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</component>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { NuxtLink } from '#components'
|
|
|
|
interface Hub {
|
|
uuid?: string | null
|
|
name?: string | null
|
|
country?: string | null
|
|
countryCode?: string | null
|
|
latitude?: number | null
|
|
longitude?: number | null
|
|
distance?: string
|
|
distanceKm?: number | null
|
|
transportTypes?: (string | null)[] | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
hub: Hub
|
|
origin?: { latitude: number; longitude: number } | null
|
|
selectable?: boolean
|
|
isSelected?: boolean
|
|
linkTo?: string
|
|
}>()
|
|
|
|
defineEmits<{
|
|
(e: 'select'): void
|
|
(e: 'hover', hovered: boolean): void
|
|
}>()
|
|
|
|
const localePath = useLocalePath()
|
|
const { t } = useI18n()
|
|
|
|
const linkable = computed(() => !props.selectable && !!(props.linkTo || props.hub.uuid))
|
|
const resolvedLink = computed(() => props.linkTo || localePath(`/catalog/hubs/${props.hub.uuid}`))
|
|
|
|
// ISO code to emoji flag
|
|
const isoToEmoji = (code: string): string => {
|
|
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
|
}
|
|
|
|
const countryFlag = computed(() => {
|
|
if (props.hub.countryCode) {
|
|
return isoToEmoji(props.hub.countryCode)
|
|
}
|
|
return '🌍'
|
|
})
|
|
|
|
const hasTransport = (type: string) => props.hub.transportTypes?.some(t => t === type)
|
|
const distanceLabel = computed(() => {
|
|
if (props.hub.distance) return props.hub.distance
|
|
if (props.hub.distanceKm != null) return `${Math.round(props.hub.distanceKm)} km`
|
|
return ''
|
|
})
|
|
|
|
const toRadians = (deg: number) => (deg * Math.PI) / 180
|
|
const toDegrees = (rad: number) => (rad * 180) / Math.PI
|
|
|
|
const bearing = computed(() => {
|
|
const origin = props.origin
|
|
const lat2 = props.hub.latitude
|
|
const lon2 = props.hub.longitude
|
|
if (!origin || lat2 == null || lon2 == null) return null
|
|
const lat1 = origin.latitude
|
|
const lon1 = origin.longitude
|
|
if (lat1 == null || lon1 == null) return null
|
|
|
|
const φ1 = toRadians(lat1)
|
|
const φ2 = toRadians(lat2)
|
|
const Δλ = toRadians(lon2 - lon1)
|
|
|
|
const y = Math.sin(Δλ) * Math.cos(φ2)
|
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
|
|
const θ = Math.atan2(y, x)
|
|
const deg = (toDegrees(θ) + 360) % 360
|
|
return deg
|
|
})
|
|
</script>
|