index.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. <template>
  2. <div
  3. class="base-radar-view"
  4. :style="{ width: canvasSize.width + 'px', height: canvasSize.height + 'px' }"
  5. >
  6. <canvas ref="canvasRef" :width="canvasSize.width" :height="canvasSize.height"></canvas>
  7. <div class="overlay-layer">
  8. <div
  9. v-for="target in targets"
  10. :key="target.id"
  11. class="target-point"
  12. :style="getTargetStyle(target)"
  13. >
  14. <span class="target-id">{{ target.id }}</span>
  15. </div>
  16. </div>
  17. </div>
  18. </template>
  19. <script setup lang="ts">
  20. import { onMounted, ref, watch, type CSSProperties } from 'vue'
  21. import radarUrl from '@/assets/furnitures/radar.png'
  22. import { convert_region_r2c, rotateRect } from '@/utils/coordTransform'
  23. import type { TargetPoint } from '@/types/radar'
  24. defineOptions({ name: 'BaseRadarView' })
  25. interface CanvasSize {
  26. width: number
  27. height: number
  28. }
  29. interface Props {
  30. coordinates: number[] // 检测区域坐标
  31. angle?: number // 旋转角度,默认 0 度
  32. targets?: TargetPoint[] // 目标点数组
  33. canvasSize?: CanvasSize // 画布尺寸,默认 500x500
  34. mode?: 'view' | 'edit'
  35. }
  36. const props = withDefaults(defineProps<Props>(), {
  37. canvasSize: () => ({
  38. width: 500,
  39. height: 500,
  40. }),
  41. angle: 0,
  42. mode: 'view',
  43. })
  44. const canvasRef = ref<HTMLCanvasElement | null>(null)
  45. let ctx: CanvasRenderingContext2D | null = null
  46. /**
  47. * 网格选项
  48. * 用于绘制背景网格的选项
  49. * @param width 画布宽度
  50. * @param height 画布高度
  51. * @param spacing 网格间距,默认 50
  52. * @param color 网格线颜色,默认 '#d9d9d7'
  53. * @param lineWidth 网格线宽度,默认 0.5
  54. */
  55. interface GridOptions {
  56. width: number
  57. height: number
  58. spacing?: number
  59. color?: string
  60. lineWidth?: number
  61. }
  62. /**
  63. * 绘制背景网格
  64. * 50px间距的灰色网格线,辅助定位
  65. * @param ctx 画布上下文
  66. * @param options 网格选项
  67. */
  68. const drawGrid = (ctx: CanvasRenderingContext2D, options: GridOptions) => {
  69. const { width, height, spacing = 50, color = '#d9d9d7', lineWidth = 0.5 } = options
  70. if (!ctx) return
  71. ctx.strokeStyle = color
  72. ctx.lineWidth = lineWidth
  73. // 水平网格线
  74. for (let y = spacing; y < height; y += spacing) {
  75. ctx.beginPath()
  76. ctx.moveTo(0, y)
  77. ctx.lineTo(width, y)
  78. ctx.stroke()
  79. }
  80. // 垂直网格线
  81. for (let x = spacing; x < width; x += spacing) {
  82. ctx.beginPath()
  83. ctx.moveTo(x, 0)
  84. ctx.lineTo(x, height)
  85. ctx.stroke()
  86. }
  87. }
  88. /**
  89. * 坐标轴选项
  90. * 用于绘制坐标轴的选项
  91. * @param width 画布宽度
  92. * @param height 画布高度
  93. * @param angle 旋转角度(单位:度),默认 0
  94. * @param xColor X轴颜色,默认 'red'
  95. * @param yColor Y轴颜色,默认 'green'
  96. */
  97. interface CoordinateOptions {
  98. width: number
  99. height: number
  100. angle?: number
  101. xColor?: string
  102. yColor?: string
  103. }
  104. /**
  105. * 绘制坐标轴
  106. * 虚线样式的X轴和Y轴,标识坐标系中心
  107. * @param ctx 画布上下文
  108. * @param options 坐标轴选项
  109. */
  110. const drawCoordinateAxes = (ctx: CanvasRenderingContext2D, options: CoordinateOptions) => {
  111. const { width, height, angle = 0, xColor = '#2c3e50', yColor = '#2c3e50' } = options
  112. if (!ctx) return
  113. const centerX = width / 2
  114. const centerY = height / 2
  115. // const arrowSize = 10
  116. // 只接受 0, 90, 180, 270,其他归为 0
  117. const validAngles: number[] = [0, 90, 180, 270]
  118. const rotationDeg: number = validAngles.includes(angle) ? angle : 0
  119. const rotationRad = (rotationDeg * Math.PI) / 180
  120. ctx.save()
  121. ctx.translate(centerX, centerY)
  122. ctx.rotate(rotationRad)
  123. ctx.translate(-centerX, -centerY)
  124. ctx.lineWidth = 1
  125. ctx.setLineDash([5, 5])
  126. // 绘制X轴(水平线)
  127. ctx.strokeStyle = xColor
  128. ctx.beginPath()
  129. ctx.moveTo(0, centerY)
  130. ctx.lineTo(width, centerY)
  131. ctx.stroke()
  132. // X轴箭头(右端)
  133. // ctx.beginPath()
  134. // ctx.moveTo(width, centerY)
  135. // ctx.lineTo(width - arrowSize, centerY - arrowSize / 2)
  136. // ctx.lineTo(width - arrowSize, centerY + arrowSize / 2)
  137. // ctx.closePath()
  138. // ctx.fillStyle = xColor
  139. // ctx.fill()
  140. // 绘制Y轴(垂直线)
  141. ctx.strokeStyle = yColor
  142. ctx.beginPath()
  143. ctx.moveTo(centerX, 0)
  144. ctx.lineTo(centerX, height)
  145. ctx.stroke()
  146. // Y轴箭头(顶部)
  147. // ctx.beginPath()
  148. // ctx.moveTo(centerX, 0)
  149. // ctx.lineTo(centerX - arrowSize / 2, arrowSize)
  150. // ctx.lineTo(centerX + arrowSize / 2, arrowSize)
  151. // ctx.closePath()
  152. // ctx.fillStyle = yColor
  153. // ctx.fill()
  154. ctx.setLineDash([])
  155. ctx.restore()
  156. }
  157. /**
  158. * 雷达图图片选项
  159. * 用于绘制雷达图图片的选项
  160. * @param width 画布宽度
  161. * @param height 画布高度
  162. * @param radarImage 雷达图图片
  163. * @param angle 旋转角度,默认 0
  164. * @param size 图片尺寸(宽高),默认 20
  165. * @param offset 图片偏移角度,默认 90 度 指向X轴
  166. */
  167. interface RadarImageOptions {
  168. width: number
  169. height: number
  170. radarImage: HTMLImageElement
  171. angle?: number
  172. size?: number
  173. offset?: number
  174. }
  175. /**
  176. * 绘制雷达图
  177. * 图片以指定角度旋转,默认指向X轴
  178. * @param ctx 画布上下文
  179. * @param options 雷达图图片选项
  180. */
  181. const drawRadarImage = (ctx: CanvasRenderingContext2D, options: RadarImageOptions) => {
  182. const { width, height, angle = 0, size = 20, radarImage, offset = 90 } = options
  183. const centerX = width / 2
  184. const centerY = height / 2
  185. const halfSize = size / 2
  186. const angleRad = ((angle + offset) * Math.PI) / 180
  187. ctx.save()
  188. ctx.translate(centerX, centerY)
  189. ctx.rotate(angleRad)
  190. ctx.drawImage(radarImage, -halfSize, -halfSize, size, size)
  191. ctx.restore()
  192. }
  193. /**
  194. * 检测区域选项
  195. * 用于绘制检测区域的选项
  196. * @param area 检测区域坐标 [xStart, xEnd, yStart, yEnd]
  197. * @param radarPosition 雷达图位置 { x_radar, y_radar }
  198. * @param angle 旋转角度(单位:度),默认 0
  199. */
  200. interface DetectionAreaOptions {
  201. area: number[] // 检测区域坐标 [xStart, xEnd, yStart, yEnd]
  202. radarPosition: {
  203. x_radar: number
  204. y_radar: number
  205. }
  206. angle?: number
  207. }
  208. /**
  209. * 绘制检测区域
  210. * @param ctx 画布上下文
  211. * @param options 检测区域选项
  212. */
  213. const drawDetectionArea = (ctx: CanvasRenderingContext2D, options: DetectionAreaOptions) => {
  214. const { area, radarPosition, angle = 0 } = options
  215. const [xStart, xEnd, yStart, yEnd] = area
  216. const canvasRect = convert_region_r2c(
  217. {
  218. x_cm_start: xStart,
  219. x_cm_stop: xEnd,
  220. y_cm_start: yStart,
  221. y_cm_stop: yEnd,
  222. },
  223. radarPosition
  224. )
  225. const rotatedRect = rotateRect(
  226. {
  227. left: canvasRect.left,
  228. top: canvasRect.top,
  229. width: canvasRect.width,
  230. height: canvasRect.height,
  231. },
  232. { x: radarPosition.x_radar, y: radarPosition.y_radar },
  233. angle
  234. )
  235. if (!ctx) return
  236. // 绘制蓝色边框
  237. ctx.strokeStyle = '#1677ff'
  238. ctx.lineWidth = 1
  239. ctx.strokeRect(rotatedRect.left, rotatedRect.top, rotatedRect.width, rotatedRect.height)
  240. // 添加半透明蓝色填充
  241. ctx.fillStyle = 'rgba(52, 152, 219, 0.3)'
  242. ctx.fillRect(rotatedRect.left, rotatedRect.top, rotatedRect.width, rotatedRect.height)
  243. }
  244. /**
  245. * 绘制画布
  246. */
  247. const drawCanvans = () => {
  248. /* 初始化画布 */
  249. if (!canvasRef.value) return
  250. ctx = canvasRef.value.getContext('2d')
  251. if (!ctx) return
  252. const CANVAS_W = props.canvasSize.width
  253. const CANVAS_H = props.canvasSize.height
  254. const CANVAS_SIZE = {
  255. width: CANVAS_W,
  256. height: CANVAS_H,
  257. }
  258. const radarPosition = {
  259. x_radar: CANVAS_W / 2,
  260. y_radar: CANVAS_H / 2,
  261. }
  262. /* 清除画布并设置背景色 */
  263. ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
  264. ctx.fillStyle = '#f2f2f0'
  265. ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
  266. /* 绘制网格和坐标轴 */
  267. drawGrid(ctx, { width: CANVAS_W, height: CANVAS_H })
  268. drawCoordinateAxes(ctx, { ...CANVAS_SIZE, angle: props.angle })
  269. /* 绘制雷达图 */
  270. const radarImage = new Image()
  271. radarImage.src = radarUrl
  272. radarImage.onload = () => {
  273. if (!ctx) return
  274. drawRadarImage(ctx, { ...CANVAS_SIZE, radarImage, angle: props.angle })
  275. }
  276. /* 绘制检测区域 */
  277. drawDetectionArea(ctx, { area: props.coordinates, radarPosition, angle: props.angle })
  278. }
  279. // 监听角度变化,重新绘制画布
  280. watch(
  281. () => [props.coordinates, props.angle],
  282. () => {
  283. drawCanvans()
  284. },
  285. {
  286. immediate: true,
  287. }
  288. )
  289. onMounted(() => {
  290. drawCanvans()
  291. })
  292. /**
  293. * 获取目标点样式
  294. * @param target 目标点
  295. * @returns 目标点样式
  296. */
  297. const getTargetStyle = (target: TargetPoint) => {
  298. const colorPalette = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple']
  299. const colorIndex = target.id < colorPalette.length ? target.id : colorPalette.length - 1
  300. const color = colorPalette[colorIndex]
  301. return {
  302. position: 'absolute',
  303. width: '18px',
  304. height: '18px',
  305. background: color,
  306. borderRadius: '50%',
  307. transform: `translate3d(${target.displayX}px, ${target.displayY}px, 0) translate(-50%, -50%)`,
  308. display: 'flex',
  309. alignItems: 'center',
  310. justifyContent: 'center',
  311. color: 'white',
  312. fontSize: '12px',
  313. fontWeight: '600',
  314. pointerEvents: 'none',
  315. zIndex: 10,
  316. transition: 'transform 1s linear',
  317. willChange: 'transform',
  318. boxShadow: '0 0 8px rgba(0, 0, 0, 0.5)',
  319. userSelect: 'none',
  320. } as CSSProperties
  321. }
  322. </script>
  323. <style scoped lang="less">
  324. .base-radar-view {
  325. position: relative;
  326. display: flex;
  327. canvas {
  328. border: 2px solid #ddd;
  329. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  330. pointer-events: none;
  331. }
  332. .overlay-layer {
  333. position: absolute;
  334. top: 0;
  335. left: 0;
  336. width: 100%;
  337. height: 100%;
  338. pointer-events: auto;
  339. .target-point {
  340. position: absolute;
  341. pointer-events: none;
  342. }
  343. }
  344. }
  345. </style>