Initial commit from monorepo
This commit is contained in:
417
app/components/OrdersRoutesMap.client.vue
Normal file
417
app/components/OrdersRoutesMap.client.vue
Normal file
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="w-full h-full">
|
||||
<MapboxMap
|
||||
:key="mapId"
|
||||
:map-id="mapId"
|
||||
style="height: 100%; width: 100%;"
|
||||
:options="mapOptions"
|
||||
@load="onMapCreated"
|
||||
>
|
||||
<MapboxNavigationControl position="top-right" />
|
||||
</MapboxMap>
|
||||
</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 OrderRoute = {
|
||||
uuid: string
|
||||
name: string
|
||||
status?: string
|
||||
stages: RouteStage[]
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
routes: {
|
||||
type: Array as PropType<OrderRoute[]>,
|
||||
default: () => []
|
||||
},
|
||||
selectedOrderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-order', uuid: string): void
|
||||
}>()
|
||||
|
||||
const { execute } = useGraphQL()
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
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(() => `orders-map-${instanceId}`)
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: '#f59e0b',
|
||||
processing: '#3b82f6',
|
||||
in_transit: '#06b6d4',
|
||||
delivered: '#22c55e',
|
||||
cancelled: '#ef4444'
|
||||
}
|
||||
|
||||
const routeGeometries = ref<Map<string, [number, number][]>>(new Map())
|
||||
|
||||
const allCoordinates = computed(() => {
|
||||
const coords: [number, number][] = []
|
||||
props.routes.forEach(order => {
|
||||
order.stages.forEach(stage => {
|
||||
if (stage.fromLat && stage.fromLon) {
|
||||
coords.push([stage.fromLon, stage.fromLat])
|
||||
}
|
||||
if (stage.toLat && stage.toLon) {
|
||||
coords.push([stage.toLon, stage.toLat])
|
||||
}
|
||||
})
|
||||
})
|
||||
return coords
|
||||
})
|
||||
|
||||
const mapCenter = computed<[number, number]>(() => {
|
||||
const coords = allCoordinates.value
|
||||
if (!coords.length) return [50, 50]
|
||||
const avgLng = coords.reduce((sum, c) => sum + c[0], 0) / coords.length
|
||||
const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length
|
||||
return [avgLng, avgLat]
|
||||
})
|
||||
|
||||
const mapOptions = computed(() => ({
|
||||
style: 'mapbox://styles/mapbox/streets-v12',
|
||||
center: mapCenter.value,
|
||||
zoom: 3
|
||||
}))
|
||||
|
||||
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 fetchRouteGeometry = async (stage: RouteStage): Promise<[number, number][] | null> => {
|
||||
if (!stage.fromLat || !stage.fromLon || !stage.toLat || !stage.toLon) return 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: stage.fromLat,
|
||||
fromLon: stage.fromLon,
|
||||
toLat: stage.toLat,
|
||||
toLon: stage.toLon
|
||||
}, 'public', 'geo')
|
||||
|
||||
const geometry = routeData?.[routeField]?.geometry
|
||||
if (typeof geometry === 'string') {
|
||||
return normalizeCoordinates(JSON.parse(geometry))
|
||||
}
|
||||
return normalizeCoordinates(geometry)
|
||||
} catch (error) {
|
||||
console.error('Failed to load route geometry:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
[stage.fromLon, stage.fromLat],
|
||||
[stage.toLon, stage.toLat]
|
||||
]
|
||||
}
|
||||
|
||||
let loadCounter = 0
|
||||
const loadDetailedRoutes = async () => {
|
||||
if (!process.client) return
|
||||
|
||||
const requestId = ++loadCounter
|
||||
const newGeometries = new Map<string, [number, number][]>()
|
||||
|
||||
for (const order of props.routes) {
|
||||
const allCoords: [number, number][] = []
|
||||
|
||||
for (const stage of order.stages) {
|
||||
const coords = await fetchRouteGeometry(stage)
|
||||
if (coords) {
|
||||
allCoords.push(...coords)
|
||||
}
|
||||
}
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
newGeometries.set(order.uuid, allCoords)
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId !== loadCounter) return
|
||||
|
||||
routeGeometries.value = newGeometries
|
||||
updateRoutesSource()
|
||||
fitMapToRoutes()
|
||||
}
|
||||
|
||||
const routeLineFeatures = computed<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
|
||||
const features: GeoJSON.Feature<GeoJSON.LineString>[] = []
|
||||
|
||||
props.routes.forEach(order => {
|
||||
const coords = routeGeometries.value.get(order.uuid)
|
||||
if (coords && coords.length > 1) {
|
||||
const isSelected = props.selectedOrderId === order.uuid
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
orderId: order.uuid,
|
||||
orderName: order.name,
|
||||
status: order.status,
|
||||
color: statusColors[order.status || ''] || '#6b7280',
|
||||
isSelected,
|
||||
lineWidth: isSelected ? 6 : 4,
|
||||
opacity: isSelected ? 1 : 0.7
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: coords
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sort: selected on top
|
||||
features.sort((a, b) => {
|
||||
if (a.properties?.isSelected) return 1
|
||||
if (b.properties?.isSelected) return -1
|
||||
return 0
|
||||
})
|
||||
|
||||
return { type: 'FeatureCollection', features }
|
||||
})
|
||||
|
||||
const markersFeatureCollection = computed<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
||||
const points: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
props.routes.forEach(order => {
|
||||
const isSelected = props.selectedOrderId === order.uuid
|
||||
order.stages.forEach(stage => {
|
||||
if (stage.fromLat && stage.fromLon) {
|
||||
const key = `${order.uuid}-from-${stage.fromLon},${stage.fromLat}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
points.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
orderId: order.uuid,
|
||||
orderName: order.name,
|
||||
name: stage.fromName || 'Start',
|
||||
color: statusColors[order.status || ''] || '#6b7280',
|
||||
isSelected,
|
||||
radius: isSelected ? 10 : 6
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [stage.fromLon, stage.fromLat] }
|
||||
})
|
||||
}
|
||||
}
|
||||
if (stage.toLat && stage.toLon) {
|
||||
const key = `${order.uuid}-to-${stage.toLon},${stage.toLat}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
points.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
orderId: order.uuid,
|
||||
orderName: order.name,
|
||||
name: stage.toName || 'End',
|
||||
color: statusColors[order.status || ''] || '#6b7280',
|
||||
isSelected,
|
||||
radius: isSelected ? 10 : 6
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [stage.toLon, stage.toLat] }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Sort: selected on top
|
||||
points.sort((a, b) => {
|
||||
if (a.properties?.isSelected) return 1
|
||||
if (b.properties?.isSelected) return -1
|
||||
return 0
|
||||
})
|
||||
|
||||
return { type: 'FeatureCollection', features: points }
|
||||
})
|
||||
|
||||
const updateRoutesSource = () => {
|
||||
const map = mapRef.value
|
||||
if (!map) return
|
||||
|
||||
const source = map.getSource('orders-routes') as mapboxgl.GeoJSONSource | undefined
|
||||
if (source) source.setData(routeLineFeatures.value)
|
||||
|
||||
const markersSource = map.getSource('orders-markers') as mapboxgl.GeoJSONSource | undefined
|
||||
if (markersSource) markersSource.setData(markersFeatureCollection.value)
|
||||
}
|
||||
|
||||
const fitMapToRoutes = () => {
|
||||
const map = mapRef.value
|
||||
if (!map || didFitBounds.value) return
|
||||
|
||||
const coords = allCoordinates.value
|
||||
if (coords.length === 0) return
|
||||
|
||||
const bounds = coords.reduce(
|
||||
(acc, coord) => acc.extend(coord),
|
||||
new LngLatBounds(coords[0], coords[0])
|
||||
)
|
||||
|
||||
map.fitBounds(bounds, { padding: 50, maxZoom: 8 })
|
||||
didFitBounds.value = true
|
||||
}
|
||||
|
||||
const flyToOrder = (orderId: string) => {
|
||||
const map = mapRef.value
|
||||
if (!map) return
|
||||
|
||||
const order = props.routes.find(o => o.uuid === orderId)
|
||||
if (!order) return
|
||||
|
||||
const coords: [number, number][] = []
|
||||
order.stages.forEach(stage => {
|
||||
if (stage.fromLat && stage.fromLon) coords.push([stage.fromLon, stage.fromLat])
|
||||
if (stage.toLat && stage.toLon) coords.push([stage.toLon, stage.toLat])
|
||||
})
|
||||
|
||||
if (coords.length === 0) return
|
||||
|
||||
if (coords.length === 1) {
|
||||
map.flyTo({ center: coords[0], zoom: 8 })
|
||||
} else {
|
||||
const bounds = coords.reduce(
|
||||
(acc, coord) => acc.extend(coord),
|
||||
new LngLatBounds(coords[0], coords[0])
|
||||
)
|
||||
map.fitBounds(bounds, { padding: 80, maxZoom: 8 })
|
||||
}
|
||||
}
|
||||
|
||||
const onMapCreated = (map: MapboxMapType) => {
|
||||
mapRef.value = map
|
||||
|
||||
const initMap = () => {
|
||||
map.addSource('orders-routes', {
|
||||
type: 'geojson',
|
||||
data: routeLineFeatures.value
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'orders-routes-lines',
|
||||
type: 'line',
|
||||
source: 'orders-routes',
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'lineWidth'],
|
||||
'line-opacity': ['get', 'opacity']
|
||||
}
|
||||
})
|
||||
|
||||
map.addSource('orders-markers', {
|
||||
type: 'geojson',
|
||||
data: markersFeatureCollection.value
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'orders-markers-circles',
|
||||
type: 'circle',
|
||||
source: 'orders-markers',
|
||||
paint: {
|
||||
'circle-radius': ['get', 'radius'],
|
||||
'circle-color': ['get', 'color'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
// Click on route line
|
||||
map.on('click', 'orders-routes-lines', (e) => {
|
||||
const orderId = e.features?.[0]?.properties?.orderId
|
||||
if (orderId) {
|
||||
emit('select-order', orderId)
|
||||
}
|
||||
})
|
||||
|
||||
// Click on marker
|
||||
map.on('click', 'orders-markers-circles', (e) => {
|
||||
const props = e.features?.[0]?.properties
|
||||
const orderId = props?.orderId
|
||||
if (orderId) {
|
||||
emit('select-order', orderId)
|
||||
}
|
||||
|
||||
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||
new Popup()
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(`<strong>${props?.name || 'Point'}</strong><br/>${props?.orderName || ''}`)
|
||||
.addTo(map)
|
||||
})
|
||||
|
||||
map.on('mouseenter', 'orders-routes-lines', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'orders-routes-lines', () => { map.getCanvas().style.cursor = '' })
|
||||
map.on('mouseenter', 'orders-markers-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'orders-markers-circles', () => { map.getCanvas().style.cursor = '' })
|
||||
|
||||
isMapReady.value = true
|
||||
fitMapToRoutes()
|
||||
}
|
||||
|
||||
if (map.loaded()) {
|
||||
initMap()
|
||||
} else {
|
||||
map.on('load', initMap)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for selection changes
|
||||
watch(() => props.selectedOrderId, (newId) => {
|
||||
updateRoutesSource()
|
||||
if (newId) {
|
||||
flyToOrder(newId)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for routes changes
|
||||
watch(
|
||||
() => props.routes,
|
||||
() => {
|
||||
didFitBounds.value = false
|
||||
loadDetailedRoutes()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// Expose flyTo method
|
||||
defineExpose({
|
||||
flyTo: flyToOrder
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user