You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

410 lines
9.5 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>