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.
428 lines
9.6 KiB
Vue
428 lines
9.6 KiB
Vue
|
3 months ago
|
<template>
|
||
|
|
<!-- <div class="container" ref="containerRef" @wheel.prevent="onWheel">-->
|
||
|
|
<div class="container" ref="containerRef">
|
||
|
|
<!-- <div class="leftLine"></div>-->
|
||
|
|
<!-- 左侧竖向刻度尺 -->
|
||
|
|
<div class="ruler vertical" :style="{ backgroundColor: bgColor }">
|
||
|
|
<div class="ruler-content" :style="{ height: contentHeight + 'px' ,transform: 'rotate(180deg)'}">
|
||
|
|
<template v-for="tick in verticalTicks" :key="'v-' + tick.index">
|
||
|
|
<div
|
||
|
|
class="tick"
|
||
|
|
:class="{ major: tick.isMajor }"
|
||
|
|
:style="{
|
||
|
|
bottom: tick.pos + 'px',
|
||
|
|
width: tick.isMajor ? '15px' : '7px',
|
||
|
|
right: 0,
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
v-if="tick.isMajor"
|
||
|
|
class="label"
|
||
|
|
:style="{ right: '18px', bottom: '-6px',transform: 'rotate(180deg)' }"
|
||
|
|
>
|
||
|
|
{{ tick.label }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 内容区 -->
|
||
|
|
<div
|
||
|
|
class="content-area"
|
||
|
|
:style="{
|
||
|
|
width: contentWidth + 'px',
|
||
|
|
height: contentHeight + 'px',
|
||
|
|
backgroundColor: bgColor,
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
class="content-inner"
|
||
|
|
:style="{
|
||
|
|
transform: `translate(${offsetX}px, ${offsetY}px) scale(${scale})`,
|
||
|
|
transformOrigin: 'top left',
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
v-if="props.boxPos.x && props.boxPos.y"
|
||
|
|
class="box"
|
||
|
|
:style="{
|
||
|
|
width: boxSize + 'px',
|
||
|
|
height: boxSize + 'px',
|
||
|
|
left: props.boxPos.x + 'px',
|
||
|
|
top: contentHeight - props.boxPos.y - boxSize + 'px',
|
||
|
|
transform: `rotate(${props.boxPos.rotate}deg)`
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
<div class="scan"></div>
|
||
|
|
</div>
|
||
|
|
<div class="dot"
|
||
|
|
v-for="i in dots"
|
||
|
|
:style="{
|
||
|
|
width: 10 + 'px',
|
||
|
|
height: 10 + 'px',
|
||
|
|
left: i.x + 'px',
|
||
|
|
top: contentHeight - i.y - 10 + 'px',
|
||
|
|
}">
|
||
|
|
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
v-if="lineStyle"
|
||
|
|
class="line"
|
||
|
|
:style="lineStyle"
|
||
|
|
></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 下方横向刻度尺 -->
|
||
|
|
<div
|
||
|
|
class="ruler horizontal"
|
||
|
|
:style="{ backgroundColor: bgColor, width: contentWidth + 'px' }"
|
||
|
|
>
|
||
|
|
<div class="ruler-content" :style="{ width: contentWidth + 'px' }">
|
||
|
|
<template v-for="tick in horizontalTicks" :key="'h-' + tick.index">
|
||
|
|
<div
|
||
|
|
class="tick"
|
||
|
|
:class="{ major: tick.isMajor }"
|
||
|
|
:style="{
|
||
|
|
left: tick.pos + 'px',
|
||
|
|
height: tick.isMajor ? '15px' : '7px',
|
||
|
|
top: 0,
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
v-if="tick.isMajor"
|
||
|
|
class="label"
|
||
|
|
:style="{ top: '18px', left: '-6px' }"
|
||
|
|
>
|
||
|
|
{{ tick.label }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
<script setup>
|
||
|
|
import {ref, computed, onMounted, watch, watchEffect} from 'vue'
|
||
|
|
|
||
|
|
|
||
|
|
const props = defineProps({
|
||
|
|
boxPos: {
|
||
|
|
type: Object,
|
||
|
|
default: () => ({x: 200, y: 300, rotate: -45}),
|
||
|
|
},
|
||
|
|
index: {type: Number, default: -1} // 默认不选
|
||
|
|
})
|
||
|
|
// const boxPos = ref({x: 200, y: 300, rotate: -45})
|
||
|
|
// setInterval(() => {
|
||
|
|
// props.boxPos.x += (-10 + (Math.random() * 20))
|
||
|
|
// props.boxPos.y += (-10 + (Math.random() * 20))
|
||
|
|
// props.boxPos.rotate += (-5 + (Math.random() * 10))
|
||
|
|
// }, 1000)
|
||
|
|
|
||
|
|
const dots = ref([
|
||
|
|
// {
|
||
|
|
// x: 100,
|
||
|
|
// y: 100,
|
||
|
|
// },
|
||
|
|
// {
|
||
|
|
// x: 200,
|
||
|
|
// y: 200,
|
||
|
|
// }
|
||
|
|
])
|
||
|
|
const setDot = (dot, bol) => {
|
||
|
|
if (bol) {
|
||
|
|
dots.value = []
|
||
|
|
} else {
|
||
|
|
dots.value = [...dots.value, ...dot]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const lineStyle = ref(null)
|
||
|
|
|
||
|
|
watchEffect(() => {
|
||
|
|
if (props.index < 0 || !dots.value[props.index]) return
|
||
|
|
|
||
|
|
const dot = dots.value[props.index]
|
||
|
|
|
||
|
|
// box 中心点
|
||
|
|
const centerX = props.boxPos.x + boxSize / 2
|
||
|
|
const centerY = contentHeight.value - props.boxPos.y - boxSize / 2
|
||
|
|
|
||
|
|
// box 旋转角度
|
||
|
|
const rad = props.boxPos.rotate * Math.PI / 180
|
||
|
|
|
||
|
|
// 未旋转时顶部中心点(相对中心点)
|
||
|
|
const dx0 = 0
|
||
|
|
const dy0 = -boxSize / 2
|
||
|
|
|
||
|
|
// 旋转后的偏移
|
||
|
|
const dx = dx0 * Math.cos(rad) - dy0 * Math.sin(rad)
|
||
|
|
const dy = dx0 * Math.sin(rad) + dy0 * Math.cos(rad)
|
||
|
|
|
||
|
|
// 顶部中心点
|
||
|
|
const boxTopX = centerX + dx
|
||
|
|
const boxTopY = centerY + dy
|
||
|
|
|
||
|
|
// dot 中心点
|
||
|
|
const dotX = dot.x + 5
|
||
|
|
const dotY = contentHeight.value - dot.y - 5
|
||
|
|
|
||
|
|
const dX = dotX - boxTopX
|
||
|
|
const dY = dotY - boxTopY
|
||
|
|
const length = Math.sqrt(dX * dX + dY * dY)
|
||
|
|
const angle = Math.atan2(dY, dX) * 180 / Math.PI
|
||
|
|
|
||
|
|
// 先清掉上一次的定时器,避免抖动(可选)
|
||
|
|
// if (lineStyle._timer) clearTimeout(lineStyle._timer)
|
||
|
|
|
||
|
|
// 延迟 1 秒更新样式
|
||
|
|
lineStyle._timer = setTimeout(() => {
|
||
|
|
lineStyle.value = {
|
||
|
|
left: boxTopX + 'px',
|
||
|
|
top: boxTopY + 'px',
|
||
|
|
width: length + 'px',
|
||
|
|
transform: `rotate(${angle}deg)`,
|
||
|
|
transformOrigin: '0 0'
|
||
|
|
}
|
||
|
|
}, 1000)
|
||
|
|
})
|
||
|
|
|
||
|
|
defineExpose({
|
||
|
|
setDot
|
||
|
|
})
|
||
|
|
|
||
|
|
const boxSize = 30
|
||
|
|
const minScale = 1
|
||
|
|
const maxScale = 3
|
||
|
|
const scale = ref(1)
|
||
|
|
const offsetX = ref(0)
|
||
|
|
const offsetY = ref(0)
|
||
|
|
|
||
|
|
const containerRef = ref(null)
|
||
|
|
const rulerSize = 40
|
||
|
|
const containerWidth = ref(0)
|
||
|
|
const containerHeight = ref(0)
|
||
|
|
|
||
|
|
const contentWidth = computed(() => containerWidth.value - rulerSize)
|
||
|
|
const contentHeight = computed(() => containerHeight.value - rulerSize)
|
||
|
|
|
||
|
|
const bgColor = '#353741'
|
||
|
|
|
||
|
|
function updateContainerSize() {
|
||
|
|
if (containerRef.value) {
|
||
|
|
containerWidth.value = containerRef.value.clientWidth
|
||
|
|
containerHeight.value = containerRef.value.clientHeight
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
updateContainerSize()
|
||
|
|
window.addEventListener('resize', updateContainerSize)
|
||
|
|
})
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => containerRef.value,
|
||
|
|
(el) => {
|
||
|
|
if (el) updateContainerSize()
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
// 获取缩放后 box 的中心坐标(左下角为原点)
|
||
|
|
function getBoxCenterPos(s) {
|
||
|
|
const x = props.boxPos.x * s + (boxSize * s) / 2
|
||
|
|
const y = props.boxPos.y * s + (boxSize * s) / 2
|
||
|
|
return {x, y}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 缩放逻辑
|
||
|
|
function onWheel(e) {
|
||
|
|
const oldScale = scale.value
|
||
|
|
const direction = e.deltaY < 0 ? 1 : -1
|
||
|
|
const step = 0.1
|
||
|
|
let newScale = oldScale + direction * step
|
||
|
|
newScale = Math.max(minScale, Math.min(maxScale, newScale))
|
||
|
|
if (newScale === oldScale) return
|
||
|
|
|
||
|
|
// box 中心点在旧比例下的位置
|
||
|
|
const oldCenter = getBoxCenterPos(oldScale)
|
||
|
|
// box 中心点在新比例下的位置
|
||
|
|
const newCenter = getBoxCenterPos(newScale)
|
||
|
|
|
||
|
|
// 保持 box 中心点在画布中的位置不变
|
||
|
|
offsetX.value += oldCenter.x - newCenter.x
|
||
|
|
offsetY.value += oldCenter.y - newCenter.y
|
||
|
|
|
||
|
|
scale.value = newScale
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
// 刻度计算
|
||
|
|
const baseMajorTickSize = 100
|
||
|
|
const baseMinorTicks = 10
|
||
|
|
|
||
|
|
const majorTickSpacing = computed(() => baseMajorTickSize * scale.value)
|
||
|
|
const minorTickSpacing = computed(() => majorTickSpacing.value / baseMinorTicks)
|
||
|
|
|
||
|
|
function generateTicks(length, offset, isVertical = false) {
|
||
|
|
const spacing = minorTickSpacing.value
|
||
|
|
const ticks = []
|
||
|
|
|
||
|
|
const startIndex = Math.floor(-offset / spacing)
|
||
|
|
const endIndex = Math.ceil((length - offset) / spacing)
|
||
|
|
|
||
|
|
for (let i = startIndex; i <= endIndex; i++) {
|
||
|
|
const rawPos = i * spacing + offset
|
||
|
|
const pos = isVertical ? length - rawPos : rawPos
|
||
|
|
const isMajor = i % baseMinorTicks === 0
|
||
|
|
ticks.push({
|
||
|
|
index: i,
|
||
|
|
pos,
|
||
|
|
isMajor,
|
||
|
|
label: isMajor ? i * (baseMajorTickSize / baseMinorTicks) : null,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return ticks
|
||
|
|
}
|
||
|
|
|
||
|
|
const horizontalTicks = computed(() =>
|
||
|
|
generateTicks(contentWidth.value, offsetX.value)
|
||
|
|
)
|
||
|
|
|
||
|
|
const verticalTicks = computed(() =>
|
||
|
|
generateTicks(contentHeight.value, offsetY.value, true)
|
||
|
|
)
|
||
|
|
</script>
|
||
|
|
<style scoped>
|
||
|
|
.container {
|
||
|
|
position: relative;
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: row;
|
||
|
|
background: #353741;
|
||
|
|
user-select: none;
|
||
|
|
overflow: hidden;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 竖向刻度尺 */
|
||
|
|
.ruler.vertical {
|
||
|
|
width: 40px;
|
||
|
|
height: calc(100% - 40px);
|
||
|
|
position: relative;
|
||
|
|
border-right: 1px solid #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.horizontal {
|
||
|
|
height: 40px;
|
||
|
|
width: 100%;
|
||
|
|
position: absolute;
|
||
|
|
bottom: 0;
|
||
|
|
left: 40px;
|
||
|
|
border-top: 1px solid #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.content-area {
|
||
|
|
position: relative;
|
||
|
|
height: 100%;
|
||
|
|
overflow: hidden;
|
||
|
|
flex-shrink: 0;
|
||
|
|
border-left: 1px solid #666;
|
||
|
|
border-bottom: 1px solid #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.content-inner {
|
||
|
|
position: absolute;
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.box {
|
||
|
|
position: absolute;
|
||
|
|
background-image: url("../assets/car.png");
|
||
|
|
background-repeat: no-repeat;
|
||
|
|
background-size: 100% 100%;
|
||
|
|
transition: top 1s ease, left 1s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.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 {
|
||
|
|
position: absolute;
|
||
|
|
background: radial-gradient(circle at center,
|
||
|
|
#ea1212 0%, rgba(228, 116, 116, 0.2) 100%);
|
||
|
|
border-radius: 50%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.tick {
|
||
|
|
position: absolute;
|
||
|
|
opacity: 0.4;
|
||
|
|
pointer-events: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.tick.major {
|
||
|
|
opacity: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.vertical .tick {
|
||
|
|
border-top: 1px solid white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.vertical .tick.major {
|
||
|
|
border-top: 2px solid white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.vertical .label {
|
||
|
|
position: absolute;
|
||
|
|
font-size: 10px;
|
||
|
|
bottom: -6px;
|
||
|
|
right: 18px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.horizontal .tick {
|
||
|
|
border-left: 1px solid white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.horizontal .tick.major {
|
||
|
|
border-left: 2px solid white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler.horizontal .label {
|
||
|
|
position: absolute;
|
||
|
|
font-size: 10px;
|
||
|
|
top: 18px;
|
||
|
|
left: -6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ruler-content {
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.leftLine {
|
||
|
|
position: absolute;
|
||
|
|
top: 0;
|
||
|
|
left: 20%;
|
||
|
|
width: 1px;
|
||
|
|
height: calc(100% - 40px);
|
||
|
|
border-right: 3px dashed white;
|
||
|
|
z-index: 9999;
|
||
|
|
}
|
||
|
|
|
||
|
|
.line {
|
||
|
|
position: absolute;
|
||
|
|
height: 2px;
|
||
|
|
background: yellow;
|
||
|
|
}
|
||
|
|
</style>
|