Initial commit from monorepo
This commit is contained in:
401
app/components/RequestRoutesMap.vue
Normal file
401
app/components/RequestRoutesMap.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<ClientOnly>
|
||||
<MapboxMap
|
||||
:key="mapId"
|
||||
:map-id="mapId"
|
||||
:style="`height: ${height}px; width: 100%;`"
|
||||
class="rounded-lg border border-base-300"
|
||||
:options="mapOptions"
|
||||
@load="onMapCreated"
|
||||
>
|
||||
<MapboxNavigationControl position="top-right" />
|
||||
</MapboxMap>
|
||||
|
||||
<template #fallback>
|
||||
<div class="h-72 w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||
<p class="text-base-content/60">Загрузка карты...</p>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||
import { GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
type RouteStage = {
|
||||
fromLat?: number | null
|
||||
fromLon?: number | null
|
||||
fromName?: string | null
|
||||
toLat?: number | null
|
||||
toLon?: number | null
|
||||
toName?: string | null
|
||||
transportType?: string | null
|
||||
}
|
||||
|
||||
type RouteWithStages = {
|
||||
stages?: Array<RouteStage | null> | null
|
||||
}
|
||||
|
||||
type RouteMarker = {
|
||||
lng: number
|
||||
lat: number
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type StageGeometry = {
|
||||
routeIndex: number
|
||||
stageIndex: number
|
||||
transportType?: string | null
|
||||
coordinates: [number, number][]
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
routes: {
|
||||
type: Array as PropType<RouteWithStages[]>,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 360
|
||||
}
|
||||
})
|
||||
|
||||
const { execute } = useGraphQL()
|
||||
const mapRef = ref<MapboxMapType | null>(null)
|
||||
const isMapReady = ref(false)
|
||||
const didFitBounds = ref(false)
|
||||
|
||||
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||
const mapId = computed(() => `request-routes-${instanceId}`)
|
||||
|
||||
const routeColors = [
|
||||
'#2563eb',
|
||||
'#0f766e',
|
||||
'#7c3aed',
|
||||
'#ea580c',
|
||||
'#16a34a'
|
||||
]
|
||||
|
||||
const routeMarkers = computed(() => {
|
||||
const markers: RouteMarker[] = []
|
||||
props.routes.forEach((route, routeIndex) => {
|
||||
const stages = (route?.stages || []).filter(Boolean) as RouteStage[]
|
||||
if (!stages.length) return
|
||||
const first = stages[0]
|
||||
const last = stages[stages.length - 1]
|
||||
|
||||
if (typeof first.fromLat === 'number' && typeof first.fromLon === 'number') {
|
||||
markers.push({
|
||||
lat: first.fromLat,
|
||||
lng: first.fromLon,
|
||||
name: first.fromName || 'Старт',
|
||||
label: `Маршрут ${routeIndex + 1} — старт`
|
||||
})
|
||||
}
|
||||
if (typeof last.toLat === 'number' && typeof last.toLon === 'number') {
|
||||
markers.push({
|
||||
lat: last.toLat,
|
||||
lng: last.toLon,
|
||||
name: last.toName || 'Финиш',
|
||||
label: `Маршрут ${routeIndex + 1} — финиш`
|
||||
})
|
||||
}
|
||||
})
|
||||
return markers
|
||||
})
|
||||
|
||||
const mapCenter = computed<[number, number]>(() => {
|
||||
const points: [number, number][] = []
|
||||
routeMarkers.value.forEach(marker => {
|
||||
points.push([marker.lng, marker.lat])
|
||||
})
|
||||
|
||||
if (!points.length) 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 mapOptions = computed(() => ({
|
||||
style: 'mapbox://styles/mapbox/streets-v12',
|
||||
center: mapCenter.value,
|
||||
zoom: 2.5
|
||||
}))
|
||||
|
||||
const routeLineFeatures = ref<GeoJSON.FeatureCollection<GeoJSON.LineString>>({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
const markersFeatureCollection = computed(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: routeMarkers.value.map(marker => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
name: marker.name,
|
||||
label: marker.label
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [marker.lng, marker.lat]
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
const normalizeCoordinates = (geometry: unknown): [number, number][] | null => {
|
||||
if (!Array.isArray(geometry)) return null
|
||||
|
||||
const coords = geometry
|
||||
.filter(point => Array.isArray(point) && typeof point[0] === 'number' && typeof point[1] === 'number')
|
||||
.map(point => [point[0], point[1]] as [number, number])
|
||||
|
||||
return coords.length > 1 ? coords : null
|
||||
}
|
||||
|
||||
const fetchStageGeometry = async (stage: RouteStage, routeIndex: number, stageIndex: number): Promise<StageGeometry | null> => {
|
||||
if (
|
||||
typeof stage.fromLat !== 'number' ||
|
||||
typeof stage.fromLon !== 'number' ||
|
||||
typeof stage.toLat !== 'number' ||
|
||||
typeof stage.toLon !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fromLat = stage.fromLat
|
||||
const fromLon = stage.fromLon
|
||||
const toLat = stage.toLat
|
||||
const toLon = stage.toLon
|
||||
|
||||
let coordinates: [number, number][] | null = null
|
||||
|
||||
if (stage.transportType === 'auto' || stage.transportType === 'rail') {
|
||||
try {
|
||||
const RouteDocument = stage.transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
|
||||
const routeField = stage.transportType === 'auto' ? 'autoRoute' : 'railRoute'
|
||||
|
||||
const routeData = await execute(RouteDocument, { fromLat, fromLon, toLat, toLon }, 'public', 'geo')
|
||||
const geometry = routeData?.[routeField]?.geometry
|
||||
|
||||
if (typeof geometry === 'string') {
|
||||
coordinates = normalizeCoordinates(JSON.parse(geometry))
|
||||
} else {
|
||||
coordinates = normalizeCoordinates(geometry)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load detailed route geometry:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!coordinates) {
|
||||
coordinates = [
|
||||
[fromLon, fromLat],
|
||||
[toLon, toLat]
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
routeIndex,
|
||||
stageIndex,
|
||||
transportType: stage.transportType,
|
||||
coordinates
|
||||
}
|
||||
}
|
||||
|
||||
let loadCounter = 0
|
||||
const loadDetailedRoutes = async () => {
|
||||
if (!process.client) return
|
||||
|
||||
const requestId = ++loadCounter
|
||||
|
||||
const stageTasks: Array<Promise<StageGeometry | null>> = []
|
||||
props.routes.forEach((route, routeIndex) => {
|
||||
const stages = (route?.stages || []).filter(Boolean) as RouteStage[]
|
||||
stages.forEach((stage, stageIndex) => {
|
||||
stageTasks.push(fetchStageGeometry(stage, routeIndex, stageIndex))
|
||||
})
|
||||
})
|
||||
|
||||
const results = await Promise.all(stageTasks)
|
||||
if (requestId !== loadCounter) return
|
||||
|
||||
const features = results
|
||||
.filter((result): result is StageGeometry => Boolean(result))
|
||||
.map((result) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
routeIndex: result.routeIndex,
|
||||
stageIndex: result.stageIndex,
|
||||
transportType: result.transportType,
|
||||
color: routeColors[result.routeIndex % routeColors.length]
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: result.coordinates
|
||||
}
|
||||
}))
|
||||
|
||||
routeLineFeatures.value = {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
}
|
||||
|
||||
updateRoutesSource()
|
||||
fitMapToRoutes()
|
||||
}
|
||||
|
||||
const updateRoutesSource = () => {
|
||||
const map = mapRef.value
|
||||
if (!map) return
|
||||
|
||||
const source = map.getSource('request-routes') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!source) return
|
||||
|
||||
source.setData(routeLineFeatures.value)
|
||||
}
|
||||
|
||||
const updateMarkersSource = () => {
|
||||
const map = mapRef.value
|
||||
if (!map) return
|
||||
|
||||
const source = map.getSource('request-markers') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!source) return
|
||||
|
||||
source.setData(markersFeatureCollection.value)
|
||||
}
|
||||
|
||||
const fitMapToRoutes = () => {
|
||||
const map = mapRef.value
|
||||
if (!map || didFitBounds.value) return
|
||||
|
||||
const lineCoords = routeLineFeatures.value.features.flatMap((feature) => feature.geometry.coordinates)
|
||||
const markerCoords = routeMarkers.value.map(marker => [marker.lng, marker.lat] as [number, number])
|
||||
const coordinates = lineCoords.length > 0 ? lineCoords : markerCoords
|
||||
|
||||
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: 36,
|
||||
maxZoom: 8
|
||||
})
|
||||
|
||||
didFitBounds.value = true
|
||||
}
|
||||
|
||||
const onMapCreated = (map: MapboxMapType) => {
|
||||
mapRef.value = map
|
||||
|
||||
const initMap = () => {
|
||||
map.addSource('request-routes', {
|
||||
type: 'geojson',
|
||||
data: routeLineFeatures.value
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'request-routes-lines',
|
||||
type: 'line',
|
||||
source: 'request-routes',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.85
|
||||
}
|
||||
})
|
||||
|
||||
map.addSource('request-markers', {
|
||||
type: 'geojson',
|
||||
data: markersFeatureCollection.value
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'request-markers-circles',
|
||||
type: 'circle',
|
||||
source: 'request-markers',
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': '#f97316',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
map.on('click', 'request-markers-circles', (e) => {
|
||||
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||
const featureProps = e.features?.[0].properties
|
||||
const title = featureProps?.name || 'Точка'
|
||||
const label = featureProps?.label || ''
|
||||
|
||||
new Popup()
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(`<strong>${title}</strong><br/>${label}`)
|
||||
.addTo(map)
|
||||
})
|
||||
|
||||
map.on('mouseenter', 'request-markers-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'request-markers-circles', () => { map.getCanvas().style.cursor = '' })
|
||||
|
||||
isMapReady.value = true
|
||||
updateMarkersSource()
|
||||
updateRoutesSource()
|
||||
fitMapToRoutes()
|
||||
}
|
||||
|
||||
if (map.loaded()) {
|
||||
initMap()
|
||||
} else {
|
||||
map.on('load', initMap)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.routes,
|
||||
() => {
|
||||
didFitBounds.value = false
|
||||
loadDetailedRoutes()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => markersFeatureCollection.value,
|
||||
() => {
|
||||
updateMarkersSource()
|
||||
if (isMapReady.value) {
|
||||
fitMapToRoutes()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => routeLineFeatures.value,
|
||||
() => {
|
||||
updateRoutesSource()
|
||||
if (isMapReady.value) {
|
||||
fitMapToRoutes()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user