Files
webapp/app/components/NearbyConnectionsSection.vue
2026-01-07 09:10:35 +07:00

443 lines
13 KiB
Vue

<template>
<Section variant="plain" paddingY="md">
<Stack gap="4">
<div v-if="autoEdges.length === 0 && railEdges.length === 0" class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Map -->
<div class="order-2 lg:order-1">
<ClientOnly>
<MapboxMap
:key="mapId"
:map-id="mapId"
style="height: 360px; width: 100%;"
class="rounded-lg border border-base-300"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
<template #fallback>
<div class="h-[360px] w-full bg-base-200 rounded-lg flex items-center justify-center">
<Spinner />
</div>
</template>
</ClientOnly>
</div>
<!-- Neighbors lists -->
<div class="order-1 lg:order-2">
<Stack gap="4">
<div>
<div class="flex items-center gap-2 mb-2">
<Icon name="lucide:truck" size="20" />
<Heading :level="3">{{ t('catalogHub.nearbyHubs.byRoad') }}</Heading>
</div>
<Stack v-if="autoEdges.length > 0" gap="2">
<NuxtLink
v-for="edge in autoEdges"
:key="edge.toUuid"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Icon name="lucide:map-pin" size="16" class="text-primary" />
</div>
<div>
<div class="font-medium">{{ edge.toName }}</div>
</div>
</div>
<div class="text-right">
<div class="font-semibold text-primary">{{ edge.distanceKm }} km</div>
</div>
</div>
</NuxtLink>
</Stack>
<div v-else class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<Icon name="lucide:train" size="20" />
<Heading :level="3">{{ t('catalogHub.nearbyHubs.byRail') }}</Heading>
</div>
<Stack v-if="railEdges.length > 0" gap="2">
<NuxtLink
v-for="edge in railEdges"
:key="edge.toUuid"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex items-center justify-center">
<Icon name="lucide:map-pin" size="16" class="text-emerald-500" />
</div>
<div>
<div class="font-medium">{{ edge.toName }}</div>
</div>
</div>
<div class="text-right">
<div class="font-semibold text-emerald-600">{{ edge.distanceKm }} km</div>
</div>
</div>
</NuxtLink>
</Stack>
<div v-else class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
</div>
</Stack>
</div>
</div>
</Stack>
</Section>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
interface CurrentHub {
uuid: string
name: string
latitude: number
longitude: number
}
interface RouteGeometry {
toUuid: string
coordinates: [number, number][]
}
const props = defineProps<{
autoEdges: EdgeType[]
railEdges: EdgeType[]
hub: CurrentHub
railHub: CurrentHub
autoRouteGeometries: RouteGeometry[]
railRouteGeometries: RouteGeometry[]
}>()
const { t } = useI18n()
const localePath = useLocalePath()
const mapRef = ref<MapboxMapType | null>(null)
const isMapReady = ref(false)
const didFitBounds = ref(false)
const mapId = computed(() => `nearby-connections-${props.hub.uuid}`)
const mapCenter = computed<[number, number]>(() => {
const points: [number, number][] = []
if (props.hub.latitude && props.hub.longitude) {
points.push([props.hub.longitude, props.hub.latitude])
}
if (props.railHub.latitude && props.railHub.longitude) {
points.push([props.railHub.longitude, props.railHub.latitude])
}
const allEdges = [...props.autoEdges, ...props.railEdges]
allEdges.forEach((edge) => {
if (edge.toLatitude && edge.toLongitude) {
points.push([edge.toLongitude, edge.toLatitude])
}
})
if (points.length === 0) return [0, 0]
const avgLng = points.reduce((sum, coord) => sum + coord[0], 0) / points.length
const avgLat = points.reduce((sum, coord) => sum + coord[1], 0) / points.length
return [avgLng, avgLat]
})
const mapZoom = computed(() => {
const distances = [...props.autoEdges, ...props.railEdges].map(e => e.distanceKm || 0)
if (distances.length === 0) return 5
const maxDistance = Math.max(...distances)
if (maxDistance > 2000) return 2
if (maxDistance > 1000) return 3
if (maxDistance > 500) return 4
if (maxDistance > 200) return 5
return 6
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: mapZoom.value
}))
const buildRouteFeatureCollection = (routes: RouteGeometry[], transportType: 'auto' | 'rail') => ({
type: 'FeatureCollection' as const,
features: routes.map(route => ({
type: 'Feature' as const,
properties: { toUuid: route.toUuid, transportType },
geometry: {
type: 'LineString' as const,
coordinates: route.coordinates
}
}))
})
const buildNeighborsFeatureCollection = (edges: EdgeType[], transportType: 'auto' | 'rail') => ({
type: 'FeatureCollection' as const,
features: edges
.filter(e => e.toLatitude && e.toLongitude)
.map(edge => ({
type: 'Feature' as const,
properties: {
uuid: edge.toUuid,
name: edge.toName,
distanceKm: edge.distanceKm,
transportType
},
geometry: {
type: 'Point' as const,
coordinates: [edge.toLongitude!, edge.toLatitude!]
}
}))
})
const updateRoutesSource = () => {
const map = mapRef.value
if (!map) return
const autoSource = map.getSource('auto-routes') as mapboxgl.GeoJSONSource | undefined
if (autoSource) {
autoSource.setData(buildRouteFeatureCollection(props.autoRouteGeometries, 'auto'))
}
const railSource = map.getSource('rail-routes') as mapboxgl.GeoJSONSource | undefined
if (railSource) {
railSource.setData(buildRouteFeatureCollection(props.railRouteGeometries, 'rail'))
}
}
const updateNeighborsSource = () => {
const map = mapRef.value
if (!map) return
const autoSource = map.getSource('auto-neighbors') as mapboxgl.GeoJSONSource | undefined
if (autoSource) {
autoSource.setData(buildNeighborsFeatureCollection(props.autoEdges, 'auto'))
}
const railSource = map.getSource('rail-neighbors') as mapboxgl.GeoJSONSource | undefined
if (railSource) {
railSource.setData(buildNeighborsFeatureCollection(props.railEdges, 'rail'))
}
}
const fitMapToRoutes = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const coordinates = [
...props.autoRouteGeometries.flatMap(route => route.coordinates || []),
...props.railRouteGeometries.flatMap(route => route.coordinates || [])
]
if (coordinates.length === 0) return
const bounds = coordinates.reduce(
(acc, coord) => acc.extend(coord as [number, number]),
new LngLatBounds(
coordinates[0] as [number, number],
coordinates[0] as [number, number]
)
)
map.fitBounds(bounds, {
padding: 40,
maxZoom: 8
})
didFitBounds.value = true
}
const addHubSource = (map: MapboxMapType, id: string, hub: CurrentHub, color: string) => {
map.addSource(id, {
type: 'geojson',
data: {
type: 'Feature',
properties: { name: hub.name },
geometry: {
type: 'Point',
coordinates: [hub.longitude, hub.latitude]
}
}
})
map.addLayer({
id: `${id}-circle`,
type: 'circle',
source: id,
paint: {
'circle-radius': 10,
'circle-color': color,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.on('click', `${id}-circle`, (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = e.features![0].properties?.name
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong>`)
.addTo(map)
})
map.on('mouseenter', `${id}-circle`, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', `${id}-circle`, () => { map.getCanvas().style.cursor = '' })
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
const initMap = () => {
map.addSource('auto-routes', {
type: 'geojson',
data: buildRouteFeatureCollection(props.autoRouteGeometries, 'auto')
})
map.addSource('rail-routes', {
type: 'geojson',
data: buildRouteFeatureCollection(props.railRouteGeometries, 'rail')
})
map.addLayer({
id: 'auto-routes-lines',
type: 'line',
source: 'auto-routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#3b82f6',
'line-width': 4,
'line-opacity': 0.8
}
})
map.addLayer({
id: 'rail-routes-lines',
type: 'line',
source: 'rail-routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#10b981',
'line-width': 4,
'line-opacity': 0.8
}
})
addHubSource(map, 'hub-origin', props.hub, '#3b82f6')
if (props.railHub.uuid && props.railHub.uuid !== props.hub.uuid) {
addHubSource(map, 'rail-origin', props.railHub, '#10b981')
}
map.addSource('auto-neighbors', {
type: 'geojson',
data: buildNeighborsFeatureCollection(props.autoEdges, 'auto')
})
map.addSource('rail-neighbors', {
type: 'geojson',
data: buildNeighborsFeatureCollection(props.railEdges, 'rail')
})
map.addLayer({
id: 'auto-neighbors-circles',
type: 'circle',
source: 'auto-neighbors',
paint: {
'circle-radius': 8,
'circle-color': '#ef4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'rail-neighbors-circles',
type: 'circle',
source: 'rail-neighbors',
paint: {
'circle-radius': 8,
'circle-color': '#22c55e',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
const onNeighborsClick = (e: mapboxgl.MapLayerMouseEvent) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features![0].properties
const name = featureProps?.name
const distanceKm = featureProps?.distanceKm
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong><br/>${distanceKm} km`)
.addTo(map)
}
map.on('click', 'auto-neighbors-circles', onNeighborsClick)
map.on('click', 'rail-neighbors-circles', onNeighborsClick)
map.on('mouseenter', 'auto-neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'auto-neighbors-circles', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'rail-neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'rail-neighbors-circles', () => { map.getCanvas().style.cursor = '' })
isMapReady.value = true
updateRoutesSource()
updateNeighborsSource()
fitMapToRoutes()
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
watch(
() => [props.autoRouteGeometries, props.railRouteGeometries],
() => {
didFitBounds.value = false
updateRoutesSource()
if (isMapReady.value) {
fitMapToRoutes()
}
},
{ deep: true }
)
watch(
() => [props.autoEdges, props.railEdges],
() => updateNeighborsSource(),
{ deep: true }
)
</script>