|
@@ -0,0 +1,393 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="base-radar-view"
|
|
|
+ :style="{ width: canvasSize.width + 'px', height: canvasSize.height + 'px' }"
|
|
|
+ >
|
|
|
+ <canvas ref="canvasRef" :width="canvasSize.width" :height="canvasSize.height"></canvas>
|
|
|
+
|
|
|
+ <div class="overlay-layer">
|
|
|
+ <div
|
|
|
+ v-for="target in targets"
|
|
|
+ :key="target.id"
|
|
|
+ class="target-point"
|
|
|
+ :style="getTargetStyle(target)"
|
|
|
+ >
|
|
|
+ <span class="target-id">{{ target.id }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { onMounted, ref, watch, type CSSProperties } from 'vue'
|
|
|
+import radarUrl from '@/assets/furnitures/radar.png'
|
|
|
+import { convert_region_r2c, rotateRect } from '@/utils/coordTransform'
|
|
|
+import type { TargetPoint } from '@/types/radar'
|
|
|
+
|
|
|
+defineOptions({ name: 'BaseRadarView' })
|
|
|
+
|
|
|
+interface CanvasSize {
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+}
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ coordinates: number[] // 检测区域坐标
|
|
|
+ angle?: number // 旋转角度,默认 0 度
|
|
|
+ targets?: TargetPoint[] // 目标点数组
|
|
|
+ canvasSize?: CanvasSize // 画布尺寸,默认 500x500
|
|
|
+ mode?: 'view' | 'edit'
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ canvasSize: () => ({
|
|
|
+ width: 500,
|
|
|
+ height: 500,
|
|
|
+ }),
|
|
|
+ angle: 0,
|
|
|
+ mode: 'view',
|
|
|
+})
|
|
|
+
|
|
|
+const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
|
+let ctx: CanvasRenderingContext2D | null = null
|
|
|
+
|
|
|
+/**
|
|
|
+ * 网格选项
|
|
|
+ * 用于绘制背景网格的选项
|
|
|
+ * @param width 画布宽度
|
|
|
+ * @param height 画布高度
|
|
|
+ * @param spacing 网格间距,默认 50
|
|
|
+ * @param color 网格线颜色,默认 '#d9d9d7'
|
|
|
+ * @param lineWidth 网格线宽度,默认 0.5
|
|
|
+ */
|
|
|
+interface GridOptions {
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ spacing?: number
|
|
|
+ color?: string
|
|
|
+ lineWidth?: number
|
|
|
+}
|
|
|
+/**
|
|
|
+ * 绘制背景网格
|
|
|
+ * 50px间距的灰色网格线,辅助定位
|
|
|
+ * @param ctx 画布上下文
|
|
|
+ * @param options 网格选项
|
|
|
+ */
|
|
|
+const drawGrid = (ctx: CanvasRenderingContext2D, options: GridOptions) => {
|
|
|
+ const { width, height, spacing = 50, color = '#d9d9d7', lineWidth = 0.5 } = options
|
|
|
+
|
|
|
+ if (!ctx) return
|
|
|
+
|
|
|
+ ctx.strokeStyle = color
|
|
|
+ ctx.lineWidth = lineWidth
|
|
|
+
|
|
|
+ // 水平网格线
|
|
|
+ for (let y = spacing; y < height; y += spacing) {
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(0, y)
|
|
|
+ ctx.lineTo(width, y)
|
|
|
+ ctx.stroke()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 垂直网格线
|
|
|
+ for (let x = spacing; x < width; x += spacing) {
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(x, 0)
|
|
|
+ ctx.lineTo(x, height)
|
|
|
+ ctx.stroke()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 坐标轴选项
|
|
|
+ * 用于绘制坐标轴的选项
|
|
|
+ * @param width 画布宽度
|
|
|
+ * @param height 画布高度
|
|
|
+ * @param angle 旋转角度(单位:度),默认 0
|
|
|
+ * @param xColor X轴颜色,默认 'red'
|
|
|
+ * @param yColor Y轴颜色,默认 'green'
|
|
|
+ */
|
|
|
+interface CoordinateOptions {
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ angle?: number
|
|
|
+ xColor?: string
|
|
|
+ yColor?: string
|
|
|
+}
|
|
|
+/**
|
|
|
+ * 绘制坐标轴
|
|
|
+ * 虚线样式的X轴和Y轴,标识坐标系中心
|
|
|
+ * @param ctx 画布上下文
|
|
|
+ * @param options 坐标轴选项
|
|
|
+ */
|
|
|
+const drawCoordinateAxes = (ctx: CanvasRenderingContext2D, options: CoordinateOptions) => {
|
|
|
+ const { width, height, angle = 0, xColor = '#2c3e50', yColor = '#2c3e50' } = options
|
|
|
+
|
|
|
+ if (!ctx) return
|
|
|
+
|
|
|
+ const centerX = width / 2
|
|
|
+ const centerY = height / 2
|
|
|
+ // const arrowSize = 10
|
|
|
+
|
|
|
+ // 只接受 0, 90, 180, 270,其他归为 0
|
|
|
+ const validAngles: number[] = [0, 90, 180, 270]
|
|
|
+ const rotationDeg: number = validAngles.includes(angle) ? angle : 0
|
|
|
+ const rotationRad = (rotationDeg * Math.PI) / 180
|
|
|
+
|
|
|
+ ctx.save()
|
|
|
+ ctx.translate(centerX, centerY)
|
|
|
+ ctx.rotate(rotationRad)
|
|
|
+ ctx.translate(-centerX, -centerY)
|
|
|
+
|
|
|
+ ctx.lineWidth = 1
|
|
|
+ ctx.setLineDash([5, 5])
|
|
|
+
|
|
|
+ // 绘制X轴(水平线)
|
|
|
+ ctx.strokeStyle = xColor
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(0, centerY)
|
|
|
+ ctx.lineTo(width, centerY)
|
|
|
+ ctx.stroke()
|
|
|
+
|
|
|
+ // X轴箭头(右端)
|
|
|
+ // ctx.beginPath()
|
|
|
+ // ctx.moveTo(width, centerY)
|
|
|
+ // ctx.lineTo(width - arrowSize, centerY - arrowSize / 2)
|
|
|
+ // ctx.lineTo(width - arrowSize, centerY + arrowSize / 2)
|
|
|
+ // ctx.closePath()
|
|
|
+ // ctx.fillStyle = xColor
|
|
|
+ // ctx.fill()
|
|
|
+
|
|
|
+ // 绘制Y轴(垂直线)
|
|
|
+ ctx.strokeStyle = yColor
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(centerX, 0)
|
|
|
+ ctx.lineTo(centerX, height)
|
|
|
+ ctx.stroke()
|
|
|
+
|
|
|
+ // Y轴箭头(顶部)
|
|
|
+ // ctx.beginPath()
|
|
|
+ // ctx.moveTo(centerX, 0)
|
|
|
+ // ctx.lineTo(centerX - arrowSize / 2, arrowSize)
|
|
|
+ // ctx.lineTo(centerX + arrowSize / 2, arrowSize)
|
|
|
+ // ctx.closePath()
|
|
|
+ // ctx.fillStyle = yColor
|
|
|
+ // ctx.fill()
|
|
|
+
|
|
|
+ ctx.setLineDash([])
|
|
|
+ ctx.restore()
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 雷达图图片选项
|
|
|
+ * 用于绘制雷达图图片的选项
|
|
|
+ * @param width 画布宽度
|
|
|
+ * @param height 画布高度
|
|
|
+ * @param radarImage 雷达图图片
|
|
|
+ * @param angle 旋转角度,默认 0
|
|
|
+ * @param size 图片尺寸(宽高),默认 20
|
|
|
+ * @param offset 图片偏移角度,默认 90 度 指向X轴
|
|
|
+ */
|
|
|
+interface RadarImageOptions {
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ radarImage: HTMLImageElement
|
|
|
+ angle?: number
|
|
|
+ size?: number
|
|
|
+ offset?: number
|
|
|
+}
|
|
|
+/**
|
|
|
+ * 绘制雷达图
|
|
|
+ * 图片以指定角度旋转,默认指向X轴
|
|
|
+ * @param ctx 画布上下文
|
|
|
+ * @param options 雷达图图片选项
|
|
|
+ */
|
|
|
+const drawRadarImage = (ctx: CanvasRenderingContext2D, options: RadarImageOptions) => {
|
|
|
+ const { width, height, angle = 0, size = 20, radarImage, offset = 90 } = options
|
|
|
+
|
|
|
+ const centerX = width / 2
|
|
|
+ const centerY = height / 2
|
|
|
+ const halfSize = size / 2
|
|
|
+ const angleRad = ((angle + offset) * Math.PI) / 180
|
|
|
+
|
|
|
+ ctx.save()
|
|
|
+ ctx.translate(centerX, centerY)
|
|
|
+ ctx.rotate(angleRad)
|
|
|
+ ctx.drawImage(radarImage, -halfSize, -halfSize, size, size)
|
|
|
+ ctx.restore()
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 检测区域选项
|
|
|
+ * 用于绘制检测区域的选项
|
|
|
+ * @param area 检测区域坐标 [xStart, xEnd, yStart, yEnd]
|
|
|
+ * @param radarPosition 雷达图位置 { x_radar, y_radar }
|
|
|
+ * @param angle 旋转角度(单位:度),默认 0
|
|
|
+ */
|
|
|
+interface DetectionAreaOptions {
|
|
|
+ area: number[] // 检测区域坐标 [xStart, xEnd, yStart, yEnd]
|
|
|
+ radarPosition: {
|
|
|
+ x_radar: number
|
|
|
+ y_radar: number
|
|
|
+ }
|
|
|
+ angle?: number
|
|
|
+}
|
|
|
+/**
|
|
|
+ * 绘制检测区域
|
|
|
+ * @param ctx 画布上下文
|
|
|
+ * @param options 检测区域选项
|
|
|
+ */
|
|
|
+const drawDetectionArea = (ctx: CanvasRenderingContext2D, options: DetectionAreaOptions) => {
|
|
|
+ const { area, radarPosition, angle = 0 } = options
|
|
|
+ const [xStart, xEnd, yStart, yEnd] = area
|
|
|
+ const canvasRect = convert_region_r2c(
|
|
|
+ {
|
|
|
+ x_cm_start: xStart,
|
|
|
+ x_cm_stop: xEnd,
|
|
|
+ y_cm_start: yStart,
|
|
|
+ y_cm_stop: yEnd,
|
|
|
+ },
|
|
|
+ radarPosition
|
|
|
+ )
|
|
|
+
|
|
|
+ const rotatedRect = rotateRect(
|
|
|
+ {
|
|
|
+ left: canvasRect.left,
|
|
|
+ top: canvasRect.top,
|
|
|
+ width: canvasRect.width,
|
|
|
+ height: canvasRect.height,
|
|
|
+ },
|
|
|
+ { x: radarPosition.x_radar, y: radarPosition.y_radar },
|
|
|
+ angle
|
|
|
+ )
|
|
|
+
|
|
|
+ if (!ctx) return
|
|
|
+
|
|
|
+ // 绘制蓝色边框
|
|
|
+ ctx.strokeStyle = '#1677ff'
|
|
|
+ ctx.lineWidth = 1
|
|
|
+ ctx.strokeRect(rotatedRect.left, rotatedRect.top, rotatedRect.width, rotatedRect.height)
|
|
|
+
|
|
|
+ // 添加半透明蓝色填充
|
|
|
+ ctx.fillStyle = 'rgba(52, 152, 219, 0.3)'
|
|
|
+ ctx.fillRect(rotatedRect.left, rotatedRect.top, rotatedRect.width, rotatedRect.height)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 绘制画布
|
|
|
+ */
|
|
|
+const drawCanvans = () => {
|
|
|
+ /* 初始化画布 */
|
|
|
+ if (!canvasRef.value) return
|
|
|
+ ctx = canvasRef.value.getContext('2d')
|
|
|
+ if (!ctx) return
|
|
|
+
|
|
|
+ const CANVAS_W = props.canvasSize.width
|
|
|
+ const CANVAS_H = props.canvasSize.height
|
|
|
+
|
|
|
+ const CANVAS_SIZE = {
|
|
|
+ width: CANVAS_W,
|
|
|
+ height: CANVAS_H,
|
|
|
+ }
|
|
|
+
|
|
|
+ const radarPosition = {
|
|
|
+ x_radar: CANVAS_W / 2,
|
|
|
+ y_radar: CANVAS_H / 2,
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 清除画布并设置背景色 */
|
|
|
+ ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
|
|
|
+ ctx.fillStyle = '#f2f2f0'
|
|
|
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
|
|
|
+
|
|
|
+ /* 绘制网格和坐标轴 */
|
|
|
+ drawGrid(ctx, { width: CANVAS_W, height: CANVAS_H })
|
|
|
+ drawCoordinateAxes(ctx, { ...CANVAS_SIZE, angle: props.angle })
|
|
|
+
|
|
|
+ /* 绘制雷达图 */
|
|
|
+ const radarImage = new Image()
|
|
|
+ radarImage.src = radarUrl
|
|
|
+ radarImage.onload = () => {
|
|
|
+ if (!ctx) return
|
|
|
+ drawRadarImage(ctx, { ...CANVAS_SIZE, radarImage, angle: props.angle })
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 绘制检测区域 */
|
|
|
+ drawDetectionArea(ctx, { area: props.coordinates, radarPosition, angle: props.angle })
|
|
|
+}
|
|
|
+
|
|
|
+// 监听角度变化,重新绘制画布
|
|
|
+watch(
|
|
|
+ () => [props.coordinates, props.angle],
|
|
|
+ () => {
|
|
|
+ drawCanvans()
|
|
|
+ },
|
|
|
+ {
|
|
|
+ immediate: true,
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ drawCanvans()
|
|
|
+})
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取目标点样式
|
|
|
+ * @param target 目标点
|
|
|
+ * @returns 目标点样式
|
|
|
+ */
|
|
|
+const getTargetStyle = (target: TargetPoint) => {
|
|
|
+ const colorPalette = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple']
|
|
|
+
|
|
|
+ const colorIndex = target.id < colorPalette.length ? target.id : colorPalette.length - 1
|
|
|
+ const color = colorPalette[colorIndex]
|
|
|
+
|
|
|
+ return {
|
|
|
+ position: 'absolute',
|
|
|
+ width: '18px',
|
|
|
+ height: '18px',
|
|
|
+ background: color,
|
|
|
+ borderRadius: '50%',
|
|
|
+ transform: `translate3d(${target.displayX}px, ${target.displayY}px, 0) translate(-50%, -50%)`,
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ color: 'white',
|
|
|
+ fontSize: '12px',
|
|
|
+ fontWeight: '600',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ zIndex: 10,
|
|
|
+ transition: 'transform 1s linear',
|
|
|
+ willChange: 'transform',
|
|
|
+ boxShadow: '0 0 8px rgba(0, 0, 0, 0.5)',
|
|
|
+ userSelect: 'none',
|
|
|
+ } as CSSProperties
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="less">
|
|
|
+.base-radar-view {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+
|
|
|
+ canvas {
|
|
|
+ border: 2px solid #ddd;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
+ pointer-events: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .overlay-layer {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ pointer-events: auto;
|
|
|
+
|
|
|
+ .target-point {
|
|
|
+ position: absolute;
|
|
|
+ pointer-events: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|