Initial commit from monorepo
This commit is contained in:
374
app/components/NearbyHubsSection.vue
Normal file
374
app/components/NearbyHubsSection.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" gap="2">
|
||||
<Icon :name="transportIcon" size="24" />
|
||||
<Heading :level="2">{{ title }}</Heading>
|
||||
</Stack>
|
||||
|
||||
<div v-if="edges.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: 300px; width: 100%;"
|
||||
class="rounded-lg border border-base-300"
|
||||
:options="mapOptions"
|
||||
@load="onMapCreated"
|
||||
>
|
||||
<MapboxNavigationControl position="top-right" />
|
||||
</MapboxMap>
|
||||
|
||||
<template #fallback>
|
||||
<div class="h-[300px] w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Neighbors list -->
|
||||
<div class="order-1 lg:order-2">
|
||||
<Stack gap="2">
|
||||
<NuxtLink
|
||||
v-for="edge in edges"
|
||||
: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>
|
||||
</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<{
|
||||
edges: EdgeType[]
|
||||
currentHub: CurrentHub
|
||||
routeGeometries: RouteGeometry[]
|
||||
transportType: 'auto' | 'rail'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const mapRef = ref<MapboxMapType | null>(null)
|
||||
const isMapReady = ref(false)
|
||||
const didFitBounds = ref(false)
|
||||
|
||||
const mapId = computed(() => `nearby-hubs-${props.currentHub.uuid}-${props.transportType}`)
|
||||
|
||||
const transportIcon = computed(() =>
|
||||
props.transportType === 'auto' ? 'lucide:truck' : 'lucide:train'
|
||||
)
|
||||
|
||||
const title = computed(() =>
|
||||
props.transportType === 'auto'
|
||||
? t('catalogHub.nearbyHubs.byRoad')
|
||||
: t('catalogHub.nearbyHubs.byRail')
|
||||
)
|
||||
|
||||
const geometryByUuid = computed(() => {
|
||||
const map = new Map<string, RouteGeometry>()
|
||||
props.routeGeometries.forEach((route) => {
|
||||
if (route?.toUuid) {
|
||||
map.set(route.toUuid, route)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const lineColor = computed(() =>
|
||||
props.transportType === 'auto' ? '#3b82f6' : '#10b981'
|
||||
)
|
||||
|
||||
const mapCenter = computed<[number, number]>(() => {
|
||||
if (!props.currentHub.latitude || !props.currentHub.longitude) {
|
||||
return [0, 0]
|
||||
}
|
||||
|
||||
const allLats = [props.currentHub.latitude, ...props.edges.map(e => e.toLatitude!).filter(Boolean)]
|
||||
const allLngs = [props.currentHub.longitude, ...props.edges.map(e => e.toLongitude!).filter(Boolean)]
|
||||
|
||||
const avgLng = allLngs.reduce((a, b) => a + b, 0) / allLngs.length
|
||||
const avgLat = allLats.reduce((a, b) => a + b, 0) / allLats.length
|
||||
|
||||
return [avgLng, avgLat]
|
||||
})
|
||||
|
||||
const mapZoom = computed(() => {
|
||||
if (props.edges.length === 0) return 5
|
||||
|
||||
const distances = props.edges.map(e => e.distanceKm || 0)
|
||||
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 = () => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: props.routeGeometries.map(route => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { toUuid: route.toUuid },
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: route.coordinates
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const buildNeighborsFeatureCollection = () => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: props.edges.filter(e => e.toLatitude && e.toLongitude).map(edge => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
uuid: edge.toUuid,
|
||||
name: edge.toName,
|
||||
distanceKm: edge.distanceKm
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [edge.toLongitude!, edge.toLatitude!]
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const updateRoutesSource = () => {
|
||||
const map = mapRef.value
|
||||
if (!map) return
|
||||
const source = map.getSource('routes') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!source) return
|
||||
source.setData(buildRouteFeatureCollection())
|
||||
console.log('[NearbyHubsSection] routes source updated', {
|
||||
mapId: mapId.value,
|
||||
routes: props.routeGeometries.length
|
||||
})
|
||||
}
|
||||
|
||||
const updateNeighborsSource = () => {
|
||||
const map = mapRef.value
|
||||
if (!map) return
|
||||
const source = map.getSource('neighbors') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!source) return
|
||||
source.setData(buildNeighborsFeatureCollection())
|
||||
console.log('[NearbyHubsSection] neighbors source updated', {
|
||||
mapId: mapId.value,
|
||||
neighbors: props.edges.length
|
||||
})
|
||||
}
|
||||
|
||||
const fitMapToRoutes = () => {
|
||||
const map = mapRef.value
|
||||
if (!map || didFitBounds.value) return
|
||||
|
||||
const coordinates = props.routeGeometries.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
|
||||
console.log('[NearbyHubsSection] fitBounds applied', {
|
||||
mapId: mapId.value,
|
||||
points: coordinates.length
|
||||
})
|
||||
}
|
||||
|
||||
const onMapCreated = (map: MapboxMapType) => {
|
||||
mapRef.value = map
|
||||
console.log('[NearbyHubsSection] map created', { mapId: mapId.value })
|
||||
|
||||
const initMap = () => {
|
||||
map.addSource('routes', {
|
||||
type: 'geojson',
|
||||
data: buildRouteFeatureCollection()
|
||||
})
|
||||
|
||||
// Route lines layer
|
||||
map.addLayer({
|
||||
id: 'routes-lines',
|
||||
type: 'line',
|
||||
source: 'routes',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': lineColor.value,
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
})
|
||||
|
||||
// Add current hub marker source
|
||||
map.addSource('current-hub', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: { name: props.currentHub.name },
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [props.currentHub.longitude, props.currentHub.latitude]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Current hub circle
|
||||
map.addLayer({
|
||||
id: 'current-hub-circle',
|
||||
type: 'circle',
|
||||
source: 'current-hub',
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': lineColor.value,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
// Add neighbor markers source
|
||||
map.addSource('neighbors', {
|
||||
type: 'geojson',
|
||||
data: buildNeighborsFeatureCollection()
|
||||
})
|
||||
|
||||
// Neighbor markers
|
||||
map.addLayer({
|
||||
id: 'neighbors-circles',
|
||||
type: 'circle',
|
||||
source: 'neighbors',
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': '#ef4444',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
// Popups on click
|
||||
map.on('click', 'current-hub-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('click', 'neighbors-circles', (e) => {
|
||||
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)
|
||||
})
|
||||
|
||||
// Cursor changes
|
||||
map.on('mouseenter', 'current-hub-circle', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'current-hub-circle', () => { map.getCanvas().style.cursor = '' })
|
||||
map.on('mouseenter', 'neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'neighbors-circles', () => { map.getCanvas().style.cursor = '' })
|
||||
|
||||
isMapReady.value = true
|
||||
updateRoutesSource()
|
||||
updateNeighborsSource()
|
||||
fitMapToRoutes()
|
||||
console.log('[NearbyHubsSection] map ready', { mapId: mapId.value })
|
||||
}
|
||||
|
||||
if (map.loaded()) {
|
||||
initMap()
|
||||
} else {
|
||||
map.on('load', initMap)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.routeGeometries,
|
||||
() => {
|
||||
didFitBounds.value = false
|
||||
updateRoutesSource()
|
||||
if (isMapReady.value) {
|
||||
fitMapToRoutes()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.edges,
|
||||
() => updateNeighborsSource(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[NearbyHubsSection] mounted', {
|
||||
mapId: mapId.value,
|
||||
edges: props.edges.length,
|
||||
routes: props.routeGeometries.length,
|
||||
transportType: props.transportType
|
||||
})
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user