235 lines
5.8 KiB
Vue
235 lines
5.8 KiB
Vue
<template>
|
|
<div class="w-full">
|
|
<MapboxMap
|
|
:key="mapId"
|
|
:map-id="mapId"
|
|
:style="`height: ${height}px; width: 100%;`"
|
|
class="rounded-lg"
|
|
: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 } from 'mapbox-gl'
|
|
|
|
type RouteStage = {
|
|
fromLat?: number | null
|
|
fromLon?: number | null
|
|
toLat?: number | null
|
|
toLon?: number | null
|
|
transportType?: string | null
|
|
}
|
|
|
|
type OrderRoute = {
|
|
uuid: string
|
|
name: string
|
|
status?: string
|
|
stages: RouteStage[]
|
|
}
|
|
|
|
const props = defineProps({
|
|
routes: {
|
|
type: Array as PropType<OrderRoute[]>,
|
|
default: () => []
|
|
},
|
|
height: {
|
|
type: Number,
|
|
default: 192
|
|
}
|
|
})
|
|
|
|
const mapRef = ref<MapboxMapType | null>(null)
|
|
const didFitBounds = ref(false)
|
|
|
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
|
const mapId = computed(() => `orders-preview-${instanceId}`)
|
|
|
|
const statusColors: Record<string, string> = {
|
|
pending: '#f59e0b',
|
|
processing: '#3b82f6',
|
|
in_transit: '#06b6d4',
|
|
delivered: '#22c55e',
|
|
cancelled: '#ef4444'
|
|
}
|
|
|
|
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: 2,
|
|
interactive: false
|
|
}))
|
|
|
|
const routeLineFeatures = computed<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
|
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = []
|
|
|
|
props.routes.forEach(order => {
|
|
order.stages.forEach(stage => {
|
|
if (stage.fromLat && stage.fromLon && stage.toLat && stage.toLon) {
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
orderId: order.uuid,
|
|
status: order.status,
|
|
color: statusColors[order.status || ''] || '#6b7280'
|
|
},
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [
|
|
[stage.fromLon, stage.fromLat],
|
|
[stage.toLon, stage.toLat]
|
|
]
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
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 => {
|
|
order.stages.forEach(stage => {
|
|
if (stage.fromLat && stage.fromLon) {
|
|
const key = `${stage.fromLon},${stage.fromLat}`
|
|
if (!seen.has(key)) {
|
|
seen.add(key)
|
|
points.push({
|
|
type: 'Feature',
|
|
properties: { color: statusColors[order.status || ''] || '#6b7280' },
|
|
geometry: { type: 'Point', coordinates: [stage.fromLon, stage.fromLat] }
|
|
})
|
|
}
|
|
}
|
|
if (stage.toLat && stage.toLon) {
|
|
const key = `${stage.toLon},${stage.toLat}`
|
|
if (!seen.has(key)) {
|
|
seen.add(key)
|
|
points.push({
|
|
type: 'Feature',
|
|
properties: { color: statusColors[order.status || ''] || '#6b7280' },
|
|
geometry: { type: 'Point', coordinates: [stage.toLon, stage.toLat] }
|
|
})
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
return { type: 'FeatureCollection', features: points }
|
|
})
|
|
|
|
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: 30, maxZoom: 6 })
|
|
didFitBounds.value = true
|
|
}
|
|
|
|
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',
|
|
paint: {
|
|
'line-color': ['get', 'color'],
|
|
'line-width': 3,
|
|
'line-opacity': 0.8
|
|
}
|
|
})
|
|
|
|
map.addSource('orders-markers', {
|
|
type: 'geojson',
|
|
data: markersFeatureCollection.value
|
|
})
|
|
|
|
map.addLayer({
|
|
id: 'orders-markers-circles',
|
|
type: 'circle',
|
|
source: 'orders-markers',
|
|
paint: {
|
|
'circle-radius': 5,
|
|
'circle-color': ['get', 'color'],
|
|
'circle-stroke-width': 1,
|
|
'circle-stroke-color': '#ffffff'
|
|
}
|
|
})
|
|
|
|
fitMapToRoutes()
|
|
}
|
|
|
|
if (map.loaded()) {
|
|
initMap()
|
|
} else {
|
|
map.on('load', initMap)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.routes,
|
|
() => {
|
|
didFitBounds.value = false
|
|
const map = mapRef.value
|
|
if (!map) return
|
|
|
|
const routesSource = map.getSource('orders-routes') as mapboxgl.GeoJSONSource | undefined
|
|
if (routesSource) routesSource.setData(routeLineFeatures.value)
|
|
|
|
const markersSource = map.getSource('orders-markers') as mapboxgl.GeoJSONSource | undefined
|
|
if (markersSource) markersSource.setData(markersFeatureCollection.value)
|
|
|
|
fitMapToRoutes()
|
|
},
|
|
{ deep: true }
|
|
)
|
|
</script>
|