443 lines
13 KiB
Vue
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>
|