Files
apt15e/src/pages/modules/map.vue
2025-08-27 11:16:49 +08:00

585 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page_container" :class="{'enClass': $i18n.locale === 'en-us'}">
<div class="canvas-container">
<canvas
id="mapCanvas"
ref="mapCanvas"
@wheel="handleZoom"
@click="handleCanvasClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
></canvas>
<div class="map_tools">
<el-button class="zoom_btn" type="primary" :disabled="zoomPercentage === 2" icon="el-icon-minus" size="mini" @click="zoomOut"></el-button>
<div class="zoom_data">{{ zoomPercentage }}%</div>
<el-button class="zoom_btn" type="primary" :disabled="zoomPercentage === 200" icon="el-icon-plus" size="mini" @click="zoomIn"></el-button>
<el-button class="zoom_btn" style="margin-top: .2rem !important" type="primary" :disabled="!carPosition?.x || !carPosition?.y" icon="el-icon-location" size="mini" @click="centerToCar"></el-button>
</div>
</div>
<div
v-if="showPopup"
class="point-popup"
:style="popupStyle"
@click.stop
>
<el-row type="flex" justify="space-between" class="popup-content" style="border-bottom: 1px solid #fff;">
<el-col :span="10"><h3>{{$t('Number')}}</h3></el-col>
<el-col :span="14"><p>{{selectedPoint.station_code}}</p></el-col>
</el-row>
<el-row type="flex" justify="space-between" class="popup-content">
<el-col :span="10"><h3>{{$t('Alias')}}</h3></el-col>
<el-col :span="14"><p>{{selectedPoint.station_name}}</p></el-col>
</el-row>
<el-row type="flex" justify="space-between" class="popup-content">
<el-col :span="10"><h3>{{$t('XCoordinate')}}</h3></el-col>
<el-col :span="14"><p>{{ selectedPoint.x }}</p></el-col>
</el-row>
<el-row type="flex" justify="space-between" class="popup-content">
<el-col :span="10"><h3>{{ $t('YCoordinate') }}</h3></el-col>
<el-col :span="14"><p>{{ selectedPoint.y }}</p></el-col>
</el-row>
<el-row type="flex" justify="space-between" class="popup-content">
<el-col :span="10"><h3>{{$t('AngleValue')}}</h3></el-col>
<el-col :span="14"><p>{{ selectedPoint.angle }}</p></el-col>
</el-row>
</div>
<div
v-if="showPathPopup"
class="point-popup"
:style="pathPopupStyle"
@click.stop
>
<el-row type="flex" justify="space-between" class="popup-content" style="border-bottom: 1px solid #fff;">
<el-col :span="10"><h3>{{ $t('PathID') }}</h3></el-col>
<el-col :span="14"><p>{{selectedPath.route_id}}</p></el-col>
</el-row>
<el-row type="flex" justify="space-between" class="popup-content">
<el-col :span="10"><h3>{{$t('StartCode')}}</h3></el-col>
<el-col :span="14"><p>{{getStationNameById(selectedPath.start_id)}}</p></el-col>
</el-row>
<el-row type="flex" justify="space-between" class="popup-content">
<el-col :span="10"><h3>{{$t('EndCode')}}</h3></el-col>
<el-col :span="14"><p>{{getStationNameById(selectedPath.end_id)}}</p></el-col>
</el-row>
</div>
</div>
</template>
<script>
import { throttle } from 'lodash'
import markerImage from '../../images/new/station.png'
import carImage from '../../images/new/agv.png'
import { getMapInfoByCode, getRouteInfo, queryMapAllStation } from '../../config/getData.js'
import canvasZoomDrag from '../../config/canvasZoomDrag'
import { mapGetters } from 'vuex'
export default {
name: 'ModuleMap',
data () {
return {
canvas: null, // Canvas 元素
ctx: null, // Canvas 绘图上下文
mapData: null, // 地图数据
pathData: null, // 路径数据
pointData: null, // 点位数据
showPopup: false,
selectedPoint: {},
popupStyle: {left: '0px', top: '0px'},
selectedPointId: null,
cachedImages: {
map: null,
marker: null,
car: null
},
imageLoadStatus: {
map: false,
marker: false,
car: false
},
showPathPopup: false,
selectedPath: {route_id: null},
pathPopupStyle: { left: '0px', top: '0px' },
pathClickThreshold: 5
}
},
computed: {
...mapGetters(['serverUrl', 'carPosition'])
},
watch: {
carPosition: {
handler() {
this.redrawCanvas()
},
deep: true
}
},
mixins: [canvasZoomDrag],
mounted () {
this.preloadMarkerImage()
this.preloadCarImage()
this.loadAllDataInParallel().then(() => {
this.waitForResourcesReady().then(() => {
this.centerToCar();
});
});
document.addEventListener('click', this.handleDocumentClick)
},
beforeDestroy () {
document.removeEventListener('click', this.handleDocumentClick)
},
methods: {
waitForResourcesReady() {
return new Promise((resolve) => {
const checkReady = () => {
const isImagesReady = this.imageLoadStatus.map && this.imageLoadStatus.marker && this.imageLoadStatus.car;
const isCanvasReady = this.canvas && this.ctx;
if (isImagesReady && isCanvasReady) {
resolve();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
});
},
preloadCarImage () {
if (this.cachedImages.car) {
this.imageLoadStatus.car = true
return
}
const img = new Image()
img.src = carImage
img.onload = () => {
this.cachedImages.car = img
this.imageLoadStatus.car = true
this.checkImagesLoadedAndRedraw()
}
img.onerror = () => {
console.error('小车图片加载失败')
this.imageLoadStatus.car = true
this.checkImagesLoadedAndRedraw()
}
},
preloadMarkerImage () {
if (this.cachedImages.marker) {
this.imageLoadStatus.marker = true
return
}
const img = new Image()
img.src = markerImage
img.onload = () => {
this.cachedImages.marker = img
this.imageLoadStatus.marker = true
this.checkImagesLoadedAndRedraw()
}
img.onerror = () => {
console.error('标记图片加载失败')
this.imageLoadStatus.marker = true
this.checkImagesLoadedAndRedraw()
}
},
loadMapImage () {
if (!this.mapData.mapImageAddress) {
return Promise.reject(new Error('地图数据缺失'))
}
if (this.cachedImages.map && this.imageLoadStatus.map) {
return Promise.resolve(this.cachedImages.map)
}
return new Promise((resolve, reject) => {
const img = new Image()
img.src = `${this.serverUrl}${this.mapData.mapImageAddress}`
// img.src = this.mapData.mapImageAddress
img.onload = () => {
this.cachedImages.map = img
this.imageLoadStatus.map = true
this.checkImagesLoadedAndRedraw()
resolve(img)
}
img.onerror = (error) => {
console.error('地图图片加载失败:', error)
this.imageLoadStatus.map = true
reject(error)
}
})
},
checkImagesLoadedAndRedraw () {
if (this.imageLoadStatus.map && this.imageLoadStatus.marker && this.imageLoadStatus.car) {
this.redrawCanvas()
}
},
async loadAllDataInParallel () {
try {
this.loading = this.$loading({
lock: true,
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.6)'
})
const [mapData, pathData, stations] = await Promise.all([
this._getMapInfoByCode(),
this._getRouteInfo(),
this._queryMapAllStation()
])
this.mapData = mapData
this.pathData = [...pathData]
this.pointData = this.filterPointData(pathData, stations)
this.initCanvas()
await this.loadMapImage()
this.loading.close()
return true
} catch (e) {
this.$message.error(e.message)
this.loading.close()
return false
}
},
async _getMapInfoByCode () {
try {
const res = await getMapInfoByCode()
if (!res) throw new Error('地图信息为空')
return res
} catch (e) {
console.error('获取地图信息失败:', e)
throw new Error(`获取地图信息失败: ${e.message}`)
}
},
async _getRouteInfo () {
try {
const res = await getRouteInfo()
if (!res) throw new Error('路径信息为空')
return res
} catch (e) {
console.error('获取路径信息失败:', e)
throw new Error(`获取路径信息失败: ${e.message}`)
}
},
async _queryMapAllStation () {
try {
const res = await queryMapAllStation()
if (!res) throw new Error('站点信息为空')
return res
} catch (e) {
console.error('获取站点信息失败:', e)
throw new Error(`获取站点信息失败: ${e.message}`)
}
},
filterPointData (routes, stations) {
const result = []
const seenStationIds = new Set()
routes.forEach(route => {
const startStation = stations.find(s => s.station_id === route.start_id)
const endStation = stations.find(s => s.station_id === route.end_id)
if (startStation && !seenStationIds.has(startStation.station_id)) {
result.push(startStation)
seenStationIds.add(startStation.station_id)
}
if (endStation && !seenStationIds.has(endStation.station_id)) {
result.push(endStation)
seenStationIds.add(endStation.station_id)
}
})
return result
},
initCanvas () {
this.canvas = this.$refs.mapCanvas
this.ctx = this.canvas.getContext('2d')
this.canvas.width = this.mapData.width
this.canvas.height = this.mapData.height
this.redrawCanvas()
},
redrawCanvas: throttle(function () {
if (!this.ctx || !this.mapData) return
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.save()
// 绘制地图背景
if (this.cachedImages.map) {
this.ctx.drawImage(
this.cachedImages.map,
0, 0,
this.canvas.width,
this.canvas.height
)
} else {
this.ctx.fillStyle = '#ccc'
this.ctx.fillText(this.$t('Mapisload'), 50, 50)
}
this.drawPath()
this.drawMarkers()
this.drawCar()
this.ctx.restore()
}, 30),
drawPath () {
if (!this.pathData.length) return
this.pathData.forEach((point, index) => {
const startX = (point.start_x - this.mapData.x) / this.mapData.resolution
const startY = this.mapData.height - (point.start_y - this.mapData.y) / this.mapData.resolution
const endX = (point.end_x - this.mapData.x) / this.mapData.resolution
const endY = this.mapData.height - (point.end_y - this.mapData.y) / this.mapData.resolution
const nextPoint = this.pathData[index + 1];
let controlX, controlY;
if (nextPoint) {
// 有下一个点时,使用当前终点和下一个起点的中点作为控制点
const nextStartX = (nextPoint.start_x - this.mapData.x) / this.mapData.resolution;
const nextStartY = this.mapData.height - (nextPoint.start_y - this.mapData.y) / this.mapData.resolution;
controlX = (endX + nextStartX) / 2;
controlY = (endY + nextStartY) / 2;
} else {
// 最后一个点,使用当前终点作为控制点
controlX = endX;
controlY = endY;
}
this.ctx.beginPath()
this.ctx.moveTo(startX, startY)
this.ctx.quadraticCurveTo(controlX, controlY, endX, endY);
if (this.selectedPath.route_id === point.route_id) {
this.ctx.strokeStyle = '#ff5722' // 橙色高亮
this.ctx.lineWidth = 4
} else {
this.ctx.strokeStyle = '#009de5' // 默认蓝色
this.ctx.lineWidth = 2
}
this.ctx.stroke()
})
},
drawMarkers () {
if (!this.pointData.length) return
const markerSize = 16 // 标记大小随缩放变化
this.pointData.forEach(point => {
const x = (point.x - this.mapData.x) / this.mapData.resolution
const y = this.mapData.height - (point.y - this.mapData.y) / this.mapData.resolution
// 绘制选中状态
if (point.station_id === this.selectedPointId) {
this.ctx.beginPath()
this.ctx.arc(x, y, 5, 0, Math.PI * 2)
this.ctx.fillStyle = '#59ccd2'
this.ctx.fill()
} else if (this.cachedImages.marker) {
this.ctx.drawImage(
this.cachedImages.marker,
x - markerSize / 2, // 居中对齐
y - markerSize / 2,
markerSize,
markerSize
)
}
// 绘制点位名称(文字大小随缩放变化)
if (point.station_type === 'Station') {
this.ctx.font = '12px Arial'
this.ctx.fillStyle = '#62fa0a'
this.ctx.textAlign = 'center'
this.ctx.fillText(point.station_name, x, y + 22)
}
})
},
drawCar () {
if (!this.cachedImages.car || !this.mapData) return
const carX = (this.carPosition.x - this.mapData.x) / this.mapData.resolution
const carY = this.mapData.height - (this.carPosition.y - this.mapData.y) / this.mapData.resolution
this.ctx.save()
this.ctx.translate(carX, carY)
this.ctx.rotate(-this.carPosition.angle)
this.ctx.drawImage(
this.cachedImages.car,
-15,
-15,
30,
30
)
this.ctx.restore()
},
handleCanvasClick (event) {
const rect = this.canvas.getBoundingClientRect()
const mouseX = (event.clientX - rect.left) / this.scale
const mouseY = (event.clientY - rect.top) / this.scale
// 1. 优先检测点位点击
let pointClicked = false
this.pointData.forEach(point => {
let x = (point.x - this.mapData.x) / this.mapData.resolution
let y = this.mapData.height - (point.y - this.mapData.y) / this.mapData.resolution
x = Math.abs(x) === 0 ? 0 : x
y = Math.abs(y) === 0 ? 0 : y
// 检查点击位置是否在标记图片内
if (mouseX >= x - 10 && mouseX <= x + 10 && mouseY >= y - 10 && mouseY <= y + 10) {
this.handlePointSelect(point, event)
event.stopPropagation()
pointClicked = true
}
})
// 2. 只有点位未被点击时,才检测路径点击
if (!pointClicked) {
const clickedPath = this.detectPathClick(mouseX, mouseY)
if (clickedPath) {
this.handlePathSelect(clickedPath, event)
event.stopPropagation()
}
}
},
// 检测路径点击
detectPathClick (mouseX, mouseY) {
if (!this.pathData.length) return null
for (const path of this.pathData) {
// 计算路径线段的Canvas坐标
const startX = (path.start_x - this.mapData.x) / this.mapData.resolution
const startY = this.mapData.height - (path.start_y - this.mapData.y) / this.mapData.resolution
const endX = (path.end_x - this.mapData.x) / this.mapData.resolution
const endY = this.mapData.height - (path.end_y - this.mapData.y) / this.mapData.resolution
// 计算点击点到线段的距离
const distance = this.pointToLineDistance(
{ x: mouseX, y: mouseY },
{ x: startX, y: startY },
{ x: endX, y: endY }
)
// 距离小于阈值则认为点击了该路径
if (distance <= this.pathClickThreshold) {
return path
}
}
return null
},
// 计算点到线段的最短距离
pointToLineDistance (point, lineStart, lineEnd) {
// 线段向量
const dx = lineEnd.x - lineStart.x
const dy = lineEnd.y - lineStart.y
// 线段长度的平方
const lengthSquared = dx * dx + dy * dy
// 线段长度为0起点终点重合直接返回点到起点的距离
if (lengthSquared === 0) {
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y)
}
// 计算点击点在投影到线段上的位置比例0~1之间为在线段上
let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared
t = Math.max(0, Math.min(1, t)) // 限制在0~1范围内
// 投影点坐标
const projectionX = lineStart.x + t * dx
const projectionY = lineStart.y + t * dy
// 点到投影点的距离
return Math.hypot(point.x - projectionX, point.y - projectionY)
},
handlePointSelect (point, event) {
this.resetPathSelection()
if (this.selectedPointId === point.station_id) {
this.resetSelection()
} else {
this.selectedPointId = point.station_id
this.selectedPoint = point
this.showPopup = true
this.calculatePopupPosition(event)
this.redrawCanvas()
}
},
// 处理路径选中
handlePathSelect (path, event) {
// 关闭点位弹窗(避免冲突)
this.resetPointSelection()
if (this.selectedPath.route_id === path.route_id) {
// 再次点击同一路径关闭弹窗
this.resetPathSelection()
} else {
this.selectedPath = path
this.showPathPopup = true
this.calculatePathPopupPosition(event)
this.redrawCanvas() // 高亮显示选中路径
}
},
calculatePopupPosition (event) {
const popupWidth = 200
const popupHeight = 180
let left = event.clientX - 40
let top = event.clientY - 150
// 限制弹窗在窗口可视范围内
left = Math.max(10, Math.min(left, window.innerWidth - popupWidth - 10))
top = Math.max(10, Math.min(top, window.innerHeight - popupHeight - 10))
this.popupStyle = { left: `${left}px`, top: `${top}px` }
},
// 计算路径弹窗位置
calculatePathPopupPosition (event) {
const popupWidth = 160
const popupHeight = 100
let left = event.clientX - 40
let top = event.clientY - 120
// 限制弹窗在窗口可视范围内
left = Math.max(10, Math.min(left, window.innerWidth - popupWidth - 10))
top = Math.max(10, Math.min(top, window.innerHeight - popupHeight - 10))
this.pathPopupStyle = { left: `${left}px`, top: `${top}px` }
},
handleDocumentClick () {
this.resetSelection()
},
resetPointSelection () {
if (this.selectedPointId) {
this.selectedPointId = null
this.selectedPoint = null
this.showPopup = false
}
},
// 重置路径选中状态
resetPathSelection () {
if (this.selectedPath) {
this.selectedPath = {route_id: null}
this.showPathPopup = false
}
},
resetSelection () {
this.resetPointSelection()
this.resetPathSelection()
this.redrawCanvas()
},
getStationNameById (stationId) {
if (!stationId || !this.pointData || !this.pointData.length) return null
const station = this.pointData.find(point => point.station_id === stationId)
return station ? station.station_name : null
}
}
}
</script>
<style lang="stylus" scoped>
.canvas-container
position relative
display flex
justify-content: center;
align-items: center;
height 100%
background-color rgba(4, 33, 58, 70%)
box-shadow inset 1px 1px 7px 2px #4d9bcd
overflow hidden
.point-popup
position fixed
background rgba(0, 0, 0, 70%)
border 1px solid rgba(255, 255, 255, .3)
border-radius: 8px;
padding 10px
box-shadow 0 0px 4px 2px rgba(255,255,255,0.1)
z-index 100
min-width 160px
animation fadeIn 0.2s ease-out
h3
color #fff
font-size 14px
line-height 24px
text-align center
p
color #fff
font-size 14px
line-height 24px
text-align center
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.enClass
.point-popup
min-width 240px
</style>