|
|
|
|
@ -0,0 +1,410 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div ref="wrapperRef" class="map-wrapper">
|
|
|
|
|
<!-- 中心区域 -->
|
|
|
|
|
<div
|
|
|
|
|
class="map-content"
|
|
|
|
|
:style="{ transformOrigin: `${transformOriginX}px ${transformOriginY}px`,transform: ` scale(${scale}) translate(${0}px, ${0}px)` }"
|
|
|
|
|
>
|
|
|
|
|
<div class="map-area"></div>
|
|
|
|
|
<div
|
|
|
|
|
v-if="lineCoordinates"
|
|
|
|
|
class="connection-line"
|
|
|
|
|
:style="{
|
|
|
|
|
left: lineCoordinates.startX + 'px',
|
|
|
|
|
bottom: lineCoordinates.startY + 'px',
|
|
|
|
|
width: lineCoordinates.width + 'px',
|
|
|
|
|
transform: `translateY(-50%) rotate(${lineCoordinates.angle}deg) scaleY(${1/scale})`,
|
|
|
|
|
transformOrigin: 'left center'
|
|
|
|
|
}"
|
|
|
|
|
></div>
|
|
|
|
|
<!-- Box元素(直接用内容坐标,不再额外 *scale/+translate) -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="boxPos"
|
|
|
|
|
class="box"
|
|
|
|
|
:style="{
|
|
|
|
|
left: boxContentX + 'px',
|
|
|
|
|
bottom: boxContentY + 'px',
|
|
|
|
|
transform: `translate(-50%, 50%) rotate(${boxPos.rotate}deg) scale(${1 / scale})`
|
|
|
|
|
}"
|
|
|
|
|
>
|
|
|
|
|
<div class="scan"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-for="(dot, index) in dots"
|
|
|
|
|
:key="index"
|
|
|
|
|
class="dot"
|
|
|
|
|
:style="{
|
|
|
|
|
left: `${lonToPixel(dot.lon)}px`,
|
|
|
|
|
bottom: `${latToPixel(dot.lat)}px`,
|
|
|
|
|
transform: `translate(-50%, 50%) scale(${1 / scale})`
|
|
|
|
|
}"
|
|
|
|
|
></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 左侧刻度尺 -->
|
|
|
|
|
<div class="y-ruler">
|
|
|
|
|
<div
|
|
|
|
|
v-for="tick in yTicks"
|
|
|
|
|
:key="tick.value"
|
|
|
|
|
class="tick"
|
|
|
|
|
:style="{ bottom: tick.pixel + 'px' }"
|
|
|
|
|
>
|
|
|
|
|
<div class="line"></div>
|
|
|
|
|
<div class="label">{{ tick.display }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 下方刻度尺 -->
|
|
|
|
|
<div class="x-ruler">
|
|
|
|
|
<div
|
|
|
|
|
v-for="tick in xTicks"
|
|
|
|
|
:key="tick.value"
|
|
|
|
|
class="tick"
|
|
|
|
|
:style="{ left: tick.pixel + 'px' }"
|
|
|
|
|
>
|
|
|
|
|
<div class="line"></div>
|
|
|
|
|
<div class="label">{{ tick.display }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import {ref, computed, onMounted} from "vue"
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
minlon: Number,
|
|
|
|
|
maxlon: Number,
|
|
|
|
|
minlat: Number,
|
|
|
|
|
maxlat: Number,
|
|
|
|
|
boxPos: Object,// { x, y, rotate }
|
|
|
|
|
dots: Array,
|
|
|
|
|
dotIndex: Number
|
|
|
|
|
})
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
// props.boxPos.x += (-0.001 + (Math.random() * 0.002))
|
|
|
|
|
// props.boxPos.y += (-0.001 + (Math.random() * 0.002))
|
|
|
|
|
props.boxPos.rotate += (-0 + (Math.random() * 50))
|
|
|
|
|
}, 1000)
|
|
|
|
|
const wrapperRef = ref(null)
|
|
|
|
|
const parentWidth = ref(0)
|
|
|
|
|
const parentHeight = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 刻度尺宽高
|
|
|
|
|
const rulerWidth = 40
|
|
|
|
|
const rulerHeight = 20
|
|
|
|
|
|
|
|
|
|
// 缩放等级
|
|
|
|
|
const zoomLevel = ref(1)
|
|
|
|
|
const scale = computed(() =>
|
|
|
|
|
zoomLevel.value === 1 ? 1 : zoomLevel.value === 2 ? 10 : 100
|
|
|
|
|
)
|
|
|
|
|
const tickValue = computed(() =>
|
|
|
|
|
zoomLevel.value === 1 ? 100 : zoomLevel.value === 2 ? 10 : 1
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const translateX = ref(0)
|
|
|
|
|
const translateY = ref(0)
|
|
|
|
|
|
|
|
|
|
// 获取父组件尺寸
|
|
|
|
|
const updateSize = () => {
|
|
|
|
|
if (wrapperRef.value) {
|
|
|
|
|
const rect = wrapperRef.value.getBoundingClientRect()
|
|
|
|
|
parentWidth.value = rect.width
|
|
|
|
|
parentHeight.value = rect.height
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(updateSize)
|
|
|
|
|
window.addEventListener("resize", updateSize)
|
|
|
|
|
|
|
|
|
|
// 可用中心区域尺寸
|
|
|
|
|
const contentWidth = computed(() => parentWidth.value - rulerWidth)
|
|
|
|
|
const contentHeight = computed(() => parentHeight.value - rulerHeight)
|
|
|
|
|
|
|
|
|
|
// 经纬度 → 内容坐标(不带缩放和偏移)
|
|
|
|
|
function lonToPixel(lon) {
|
|
|
|
|
return ((lon - props.minlon) / (props.maxlon - props.minlon)) * contentWidth.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function latToPixel(lat) {
|
|
|
|
|
return ((lat - props.minlat) / (props.maxlat - props.minlat)) * contentHeight.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transformOriginX = ref(0)
|
|
|
|
|
const transformOriginY = ref(0)
|
|
|
|
|
|
|
|
|
|
// 设置缩放
|
|
|
|
|
function setZoom(center, level) {
|
|
|
|
|
zoomLevel.value = level
|
|
|
|
|
|
|
|
|
|
const contentX = lonToPixel(center.lon)
|
|
|
|
|
const contentY = latToPixel(center.lat)
|
|
|
|
|
|
|
|
|
|
const scaledX = (contentX + translateX.value) * scale.value
|
|
|
|
|
const scaledY = (contentY + translateY.value) * scale.value
|
|
|
|
|
|
|
|
|
|
transformOriginX.value = contentX
|
|
|
|
|
transformOriginY.value = contentHeight.value - contentY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cx = lonToPixel(center.lon)
|
|
|
|
|
const cy = latToPixel(center.lat)
|
|
|
|
|
|
|
|
|
|
const targetX = contentWidth.value / 2
|
|
|
|
|
const targetY = contentHeight.value / 2
|
|
|
|
|
|
|
|
|
|
translateX.value = targetX - cx * scale.value
|
|
|
|
|
translateY.value = targetY - cy * scale.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 格式化刻度显示
|
|
|
|
|
function formatTick(valueMeters) {
|
|
|
|
|
if (zoomLevel.value === 1) return Math.round(valueMeters / 100) * 100
|
|
|
|
|
if (zoomLevel.value === 2) return Math.round(valueMeters / 10) * 10
|
|
|
|
|
return Math.round(valueMeters)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// X刻度(屏幕坐标)
|
|
|
|
|
const xTicks = computed(() => {
|
|
|
|
|
const ticks = []
|
|
|
|
|
const pixelsPerLon = contentWidth.value / (props.maxlon - props.minlon)
|
|
|
|
|
const startLon = props.minlon - translateX.value / (pixelsPerLon * scale.value)
|
|
|
|
|
|
|
|
|
|
let val = startLon
|
|
|
|
|
while (val <= props.maxlon) {
|
|
|
|
|
const contentX = lonToPixel(val)
|
|
|
|
|
const pixel = contentX * scale.value + translateX.value
|
|
|
|
|
ticks.push({
|
|
|
|
|
value: val,
|
|
|
|
|
pixel,
|
|
|
|
|
display: formatTick((val - props.minlon) * 111000)
|
|
|
|
|
})
|
|
|
|
|
val += tickValue.value / 111000
|
|
|
|
|
}
|
|
|
|
|
return ticks
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Y刻度(屏幕坐标)
|
|
|
|
|
const yTicks = computed(() => {
|
|
|
|
|
const ticks = []
|
|
|
|
|
const pixelsPerLat = contentHeight.value / (props.maxlat - props.minlat)
|
|
|
|
|
const startLat = props.minlat - translateY.value / (pixelsPerLat * scale.value)
|
|
|
|
|
|
|
|
|
|
let val = startLat
|
|
|
|
|
while (val <= props.maxlat) {
|
|
|
|
|
const contentY = latToPixel(val)
|
|
|
|
|
const pixel = contentY * scale.value + translateY.value
|
|
|
|
|
ticks.push({
|
|
|
|
|
value: val,
|
|
|
|
|
pixel,
|
|
|
|
|
display: formatTick((val - props.minlat) * 111000)
|
|
|
|
|
})
|
|
|
|
|
val += tickValue.value / 111000
|
|
|
|
|
}
|
|
|
|
|
return ticks
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Box内容坐标(不加缩放/平移)
|
|
|
|
|
const boxContentX = computed(() => {
|
|
|
|
|
if (!props.boxPos) return 0
|
|
|
|
|
return lonToPixel(props.boxPos.x)
|
|
|
|
|
})
|
|
|
|
|
const boxContentY = computed(() => {
|
|
|
|
|
if (!props.boxPos) return 0
|
|
|
|
|
return latToPixel(props.boxPos.y)
|
|
|
|
|
})
|
|
|
|
|
const BOX_SIZE = 30 // 与 .box 的 width/height 保持一致
|
|
|
|
|
const DOT_SIZE = 10 // 与 .dot 的 width/height 保持一致
|
|
|
|
|
|
|
|
|
|
const lineCoordinates = computed(() => {
|
|
|
|
|
if (!props.boxPos || !props.dots || props.dotIndex == null) return null
|
|
|
|
|
const startDot = props.dots[props.dotIndex]
|
|
|
|
|
if (!startDot) return null
|
|
|
|
|
|
|
|
|
|
// dot center
|
|
|
|
|
const startX = lonToPixel(startDot.lon)
|
|
|
|
|
const startY = latToPixel(startDot.lat) - (DOT_SIZE / 2) * (1 / scale.value)
|
|
|
|
|
|
|
|
|
|
// box 底边中点
|
|
|
|
|
const boxBottomX = lonToPixel(props.boxPos.x)
|
|
|
|
|
const boxBottomY = latToPixel(props.boxPos.y) - 15
|
|
|
|
|
|
|
|
|
|
// 换算成 box 中心点
|
|
|
|
|
const centerX = boxBottomX
|
|
|
|
|
const centerY = boxBottomY + (BOX_SIZE / 2) * (1 / scale.value)
|
|
|
|
|
|
|
|
|
|
const carHeadOffset = BOX_SIZE * 0.5
|
|
|
|
|
|
|
|
|
|
const localAttachX = 0
|
|
|
|
|
const localAttachY = carHeadOffset
|
|
|
|
|
|
|
|
|
|
const ox = localAttachX * (1 / scale.value)
|
|
|
|
|
const oy = localAttachY * (1 / scale.value)
|
|
|
|
|
|
|
|
|
|
const rotDeg = props.boxPos.rotate || 0
|
|
|
|
|
const rad = -rotDeg * Math.PI / 180
|
|
|
|
|
|
|
|
|
|
const rotatedX = ox * Math.cos(rad) - oy * Math.sin(rad)
|
|
|
|
|
const rotatedY = ox * Math.sin(rad) + oy * Math.cos(rad)
|
|
|
|
|
|
|
|
|
|
const endX = centerX + rotatedX
|
|
|
|
|
const endY = centerY + rotatedY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const dx = endX - startX
|
|
|
|
|
const dy = endY - startY
|
|
|
|
|
const width = Math.hypot(dx, dy)
|
|
|
|
|
const angle = -Math.atan2(dy, dx) * 180 / Math.PI
|
|
|
|
|
|
|
|
|
|
return {startX, startY, width, angle, endX, endY}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({setZoom})
|
|
|
|
|
|
|
|
|
|
// 测试缩放
|
|
|
|
|
// setTimeout(() => {
|
|
|
|
|
// setZoom({lon: 120.005, lat: 30.005}, 2)
|
|
|
|
|
// }, 3000)
|
|
|
|
|
// setTimeout(() => {
|
|
|
|
|
// setZoom({lon: 120.005, lat: 30.005}, 3)
|
|
|
|
|
// }, 5000)
|
|
|
|
|
// setTimeout(() => {
|
|
|
|
|
// setZoom({lon: 120.005, lat: 30.005}, 2)
|
|
|
|
|
// }, 8000)
|
|
|
|
|
// setTimeout(() => {
|
|
|
|
|
// setZoom({lon: 120.005, lat: 30.005}, 1)
|
|
|
|
|
// }, 10000)
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.map-wrapper {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
background: #000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.map-content {
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 40px; /* 左侧刻度尺宽度 */
|
|
|
|
|
bottom: 20px; /* 下方刻度尺高度 */
|
|
|
|
|
width: calc(100% - 40px);
|
|
|
|
|
height: calc(100% - 20px);
|
|
|
|
|
transform-origin: 0 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.map-area {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: #333;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 左侧刻度尺 */
|
|
|
|
|
.y-ruler {
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 0;
|
|
|
|
|
top: 0;
|
|
|
|
|
bottom: 20px;
|
|
|
|
|
width: 40px;
|
|
|
|
|
background: #000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.y-ruler .tick {
|
|
|
|
|
position: absolute;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 1px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.y-ruler .line {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 0;
|
|
|
|
|
width: 10px;
|
|
|
|
|
height: 1px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.y-ruler .label {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 12px;
|
|
|
|
|
top: -6px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 下方刻度尺 */
|
|
|
|
|
.x-ruler {
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 40px;
|
|
|
|
|
right: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
height: 20px;
|
|
|
|
|
background: #000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.x-ruler .tick {
|
|
|
|
|
position: absolute;
|
|
|
|
|
height: 100%;
|
|
|
|
|
width: 1px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.x-ruler .line {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
width: 1px;
|
|
|
|
|
height: 5px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.x-ruler .label {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 5px;
|
|
|
|
|
left: -5px;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Box元素 */
|
|
|
|
|
.box {
|
|
|
|
|
position: absolute;
|
|
|
|
|
width: 30px;
|
|
|
|
|
height: 30px;
|
|
|
|
|
background-image: url("../assets/car.png");
|
|
|
|
|
background-repeat: no-repeat;
|
|
|
|
|
background-size: 100% 100%;
|
|
|
|
|
transform-origin: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.box .scan {
|
|
|
|
|
position: absolute;
|
|
|
|
|
background-image: url("../assets/scan.gif");
|
|
|
|
|
background-size: 100% 100%;
|
|
|
|
|
width: 100px;
|
|
|
|
|
height: 100px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
right: 15px;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dot {
|
|
|
|
|
width: 10px;
|
|
|
|
|
height: 10px;
|
|
|
|
|
position: absolute;
|
|
|
|
|
background: radial-gradient(circle at center,
|
|
|
|
|
#ea1212 0%, rgba(228, 116, 116, 0.2) 100%);
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.connection-line {
|
|
|
|
|
position: absolute;
|
|
|
|
|
height: 2px;
|
|
|
|
|
background-color: transparent; /* 背景透明 */
|
|
|
|
|
border-top: 1px dashed #fff; /* 虚线样式 */
|
|
|
|
|
transform-origin: left center;
|
|
|
|
|
z-index: 1;
|
|
|
|
|
}
|
|
|
|
|
</style>
|