index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. <template>
  2. <div class="radar-view" :style="areaStyle">
  3. <furniture-icon
  4. icon="radar"
  5. :width="radar.width"
  6. :height="radar.length"
  7. :style="{
  8. left: `${radar.left}px`,
  9. top: `${radar.top}px`,
  10. position: 'absolute',
  11. transform: `translate(-50%, -50%) rotate(${adjustedAngle + 90}deg)`,
  12. cursor: 'default',
  13. zIndex: 5,
  14. }"
  15. :draggable="false"
  16. />
  17. <div class="content">
  18. <div class="furnitures" :style="{ overflow: !!slots.furnitures ? 'visible' : 'hidden' }">
  19. <slot name="furnitures">
  20. <furniture-icon
  21. v-for="item in filteredFurniture"
  22. :key="item.nanoid"
  23. :icon="item.type"
  24. :width="item.width"
  25. :height="item.length"
  26. :style="{
  27. left: `${item.left}px`,
  28. top: `${item.top}px`,
  29. position: 'absolute',
  30. rotate: `${item.rotate}deg`,
  31. cursor: 'default',
  32. }"
  33. :draggable="false"
  34. />
  35. </slot>
  36. </div>
  37. <div class="subregion"><slot name="subregion"></slot></div>
  38. </div>
  39. <div v-if="targets && Object.keys(targets).length > 0" class="targets">
  40. <template v-for="t in targets" :key="t.id">
  41. <div
  42. class="target-dot"
  43. :style="{
  44. position: 'absolute',
  45. width: '18px',
  46. height: '18px',
  47. background: t.id === 0 ? 'red' : t.id === 1 ? 'blue' : 'green',
  48. borderRadius: '50%',
  49. transform: `translate3d(${t.displayX}px, ${-t.displayY!}px, 0) translate(-50%, -50%)`,
  50. zIndex: 10,
  51. transition: 'transform 1s linear',
  52. willChange: 'transform',
  53. }"
  54. >
  55. <span
  56. style="
  57. color: #fff;
  58. font-size: 12px;
  59. font-weight: 600;
  60. position: absolute;
  61. left: 50%;
  62. top: 50%;
  63. transform: translate(-50%, -50%);
  64. pointer-events: none;
  65. "
  66. >
  67. {{ t.id }}
  68. </span>
  69. </div>
  70. </template>
  71. </div>
  72. <!-- <div class="content"><slot /></div> -->
  73. <div v-if="showInfo" class="info-box">
  74. 检测区域:{{ areaWidth }} × {{ areaHeight }} cm<br />
  75. 坐标范围:[{{ xxStart }}, {{ xxEnd }}, {{ yyStart }}, {{ yyEnd }}]<br />
  76. 电源灯朝向: {{ northArrow }} ({{ angle }}°)<br />
  77. 坐标参考:<span style="color: red">X轴 {{ xArrow }}</span
  78. >,<span style="color: blue">Y轴 {{ yArrow }}</span>
  79. </div>
  80. <div v-if="areaWidth > 50 && areaHeight > 50" class="info-toggle">
  81. <a-switch
  82. v-model:checked="showAxis"
  83. size="small"
  84. checked-children="展示坐标"
  85. un-checked-children="隐藏坐标"
  86. />
  87. <QuestionCircleOutlined @click="showInfo = !showInfo" />
  88. </div>
  89. <div class="full-coordinate-axes" v-if="showAxis">
  90. <div class="axis-line x-axis" :style="getFullAxisStyle('x')"> </div>
  91. <div class="axis-line y-axis" :style="getFullAxisStyle('y')"> </div>
  92. </div>
  93. <div class="axis-markers">
  94. <div class="axis-dot x-dot" :style="getAxisDotStyle('x')"></div>
  95. <div class="axis-dot y-dot" :style="getAxisDotStyle('y')"></div>
  96. </div>
  97. </div>
  98. </template>
  99. <script setup lang="ts">
  100. import { computed, ref, type CSSProperties, useSlots } from 'vue'
  101. import type { FurnitureItem, TargetPoint } from '@/types/radar'
  102. import { QuestionCircleOutlined } from '@ant-design/icons-vue'
  103. defineOptions({ name: 'RadarView' })
  104. const slots = useSlots()
  105. interface Props {
  106. coordinates: [number, number, number, number] // 坐标边界:[xStart, xEnd, yStart, yEnd]
  107. angle: number // 雷达旋转角度(单位:度)
  108. furnitureItems?: FurnitureItem[] // 家具列表(包含雷达和其他家具)
  109. targets?: TargetPoint[] // 目标点列表(已处理好的点位数据)
  110. }
  111. const props = defineProps<Props>()
  112. // 坐标边界拆解为响应式变量
  113. const xxStart = computed(() => props.coordinates[0])
  114. const xxEnd = computed(() => props.coordinates[1])
  115. const yyStart = computed(() => props.coordinates[2])
  116. const yyEnd = computed(() => props.coordinates[3])
  117. // 区域宽高计算(单位:cm)
  118. const areaWidth = computed(() => Math.abs(xxEnd.value - xxStart.value))
  119. const areaHeight = computed(() => Math.abs(yyEnd.value - yyStart.value))
  120. // 雷达角度(默认值为 0°,表示正北朝上)
  121. const angle = computed(() => props.angle ?? 0)
  122. const adjustedAngle = computed(() => props.angle - 90)
  123. /**
  124. * 坐标转换函数:将雷达坐标系中的点 (x, y) 转换为 CSS 坐标系中的位置 (left, top)
  125. * - 雷达坐标系以左下角为原点,单位为 cm
  126. * - CSS 坐标系以左上角为原点,单位为 px
  127. * - 支持角度旋转(0°, 90°, 180°, 270°)
  128. * 使用场景:用于将家具或目标点定位到页面上
  129. */
  130. function convertRadarToCss(x: number, y: number): { left: number; top: number } {
  131. let rx = x,
  132. ry = y
  133. switch (adjustedAngle.value) {
  134. case 90:
  135. ;[rx, ry] = [y, -x]
  136. break
  137. case 180:
  138. ;[rx, ry] = [-x, -y]
  139. break
  140. case 270:
  141. ;[rx, ry] = [-y, x]
  142. break
  143. }
  144. return {
  145. left: rx - xxStart.value,
  146. top: yyEnd.value - ry,
  147. }
  148. }
  149. // 雷达图标位置计算:固定在坐标原点 (0, 0),并转换为 CSS 坐标
  150. const radar = computed(() => {
  151. const { left, top } = convertRadarToCss(0, 0)
  152. return {
  153. name: '雷达',
  154. type: 'radar',
  155. width: 20,
  156. length: 20,
  157. rotate: 0,
  158. left,
  159. top,
  160. x: 0,
  161. y: 0,
  162. }
  163. })
  164. // 过滤家具列表,排除雷达图标,仅保留其他家具
  165. const filteredFurniture = computed(
  166. () => props.furnitureItems?.filter((item) => item.type !== 'radar') ?? []
  167. )
  168. /**
  169. * 雷达区域样式计算:
  170. * - 设置区域尺寸
  171. * - 添加网格背景(20px 间距)
  172. * - 设置边框和阴影
  173. * 使用场景:用于渲染雷达区域容器
  174. */
  175. const areaStyle = computed(() => ({
  176. width: `${areaWidth.value}px`,
  177. height: `${areaHeight.value}px`,
  178. position: 'relative' as const,
  179. backgroundImage: `
  180. linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
  181. linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px)
  182. `,
  183. backgroundSize: '20px 20px',
  184. border: '1px solid rgba(0, 0, 0, 0.8)',
  185. boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
  186. backgroundColor: '#fff',
  187. }))
  188. // 区域信息展示开关(点击问号按钮切换)
  189. const showInfo = ref(false)
  190. const showAxis = ref(false)
  191. const northArrow = computed(() => {
  192. switch (angle.value) {
  193. case 0:
  194. return '北边'
  195. case 90:
  196. return '东边'
  197. case 180:
  198. return '南边'
  199. case 270:
  200. return '西边'
  201. default:
  202. return ''
  203. }
  204. })
  205. function getDirectionLabelWithIcon(axis: 'x' | 'y', angleDeg: number): string {
  206. const rad = (angleDeg * Math.PI) / 180
  207. const dx = axis === 'x' ? Math.cos(rad) : Math.sin(rad)
  208. const dy = axis === 'x' ? -Math.sin(rad) : Math.cos(rad)
  209. const deg = Math.atan2(dx, dy) * (180 / Math.PI)
  210. const normalized = (deg + 360) % 360
  211. if (normalized >= 45 && normalized < 135) return '东 ➡'
  212. if (normalized >= 135 && normalized < 225) return '南 ⬇'
  213. if (normalized >= 225 && normalized < 315) return '西 ⬅'
  214. return '北 ⬆'
  215. }
  216. const xArrow = computed(() => getDirectionLabelWithIcon('x', adjustedAngle.value))
  217. const yArrow = computed(() => getDirectionLabelWithIcon('y', adjustedAngle.value))
  218. function getFullAxisStyle(axis: 'x' | 'y') {
  219. const originX = radar.value.left
  220. const originY = radar.value.top
  221. const boxWidth = areaWidth.value
  222. const boxHeight = areaHeight.value
  223. const angleDeg = axis === 'x' ? angle.value : (angle.value + 90) % 360
  224. const { dx, dy } = getDirectionVector(angleDeg)
  225. // 最大延伸长度:对角线长度,确保覆盖整个盒子
  226. const maxLength = Math.sqrt(boxWidth ** 2 + boxHeight ** 2)
  227. const rotateDeg = Math.atan2(dy, dx) * (180 / Math.PI)
  228. return {
  229. position: 'absolute',
  230. left: `${originX}px`,
  231. top: `${originY}px`,
  232. width: `${maxLength}px`,
  233. height: '1px',
  234. backgroundColor: axis === 'x' ? 'red' : 'blue',
  235. transform: `translate(-50%, -50%) rotate(${rotateDeg}deg)`,
  236. transformOrigin: 'center center',
  237. zIndex: 5,
  238. pointerEvents: 'none',
  239. } as CSSProperties
  240. }
  241. function getDirectionVector(angleDeg: number): { dx: number; dy: number } {
  242. const rad = (angleDeg * Math.PI) / 180
  243. return {
  244. dx: Math.sin(rad), // 东向为正
  245. dy: -Math.cos(rad), // 北向为正
  246. }
  247. }
  248. function getAxisDotStyle(axis: 'x' | 'y') {
  249. const originX = radar.value.left
  250. const originY = radar.value.top
  251. const boxWidth = areaWidth.value
  252. const boxHeight = areaHeight.value
  253. const angleDeg = axis === 'x' ? angle.value : (angle.value - 90) % 360
  254. const { dx, dy } = getDirectionVector(angleDeg)
  255. // 计算终点比例,确保不超出盒子边界
  256. const scaleX = dx > 0 ? (boxWidth - originX) / dx : dx < 0 ? -originX / dx : Infinity
  257. const scaleY = dy > 0 ? (boxHeight - originY) / dy : dy < 0 ? -originY / dy : Infinity
  258. const scale = Math.min(dx !== 0 ? scaleX : Infinity, dy !== 0 ? scaleY : Infinity)
  259. const x = originX + dx * scale
  260. const y = originY + dy * scale
  261. return {
  262. position: 'absolute',
  263. left: `${x}px`,
  264. top: `${y}px`,
  265. width: '8px',
  266. height: '8px',
  267. borderRadius: '50%',
  268. backgroundColor: axis === 'x' ? 'red' : 'blue',
  269. transform: 'translate(-50%, -50%)',
  270. zIndex: 10,
  271. pointerEvents: 'none',
  272. } as CSSProperties
  273. }
  274. </script>
  275. <style lang="less" scoped>
  276. .radar-view {
  277. flex-shrink: 0;
  278. z-index: 0;
  279. // 家具层
  280. .furnitures {
  281. width: 100%;
  282. height: 100%;
  283. overflow: hidden;
  284. position: absolute;
  285. top: 0;
  286. }
  287. // 子区域层
  288. .subregion {
  289. width: 0%;
  290. height: 0%;
  291. position: absolute;
  292. top: 0;
  293. }
  294. // 点位层
  295. .targets {
  296. width: 100%;
  297. height: 100%;
  298. position: absolute;
  299. top: 0;
  300. z-index: 2;
  301. }
  302. // 信息展示按钮
  303. .info-toggle {
  304. position: absolute;
  305. right: 4px;
  306. bottom: 4px;
  307. z-index: 3;
  308. border: 1px solid rgba(0, 0, 0, 0.2);
  309. padding: 4px 6px;
  310. font-size: 12px;
  311. border-radius: 4px;
  312. cursor: pointer;
  313. color: rgba(51, 51, 51, 0.5);
  314. transition: background 0.2s;
  315. font-size: 14px;
  316. opacity: 0.5;
  317. display: flex;
  318. align-items: center;
  319. gap: 4px;
  320. background: rgba(255, 255, 255, 0.8);
  321. &:hover {
  322. background: rgba(0, 0, 0, 0.1);
  323. color: #222222;
  324. }
  325. }
  326. // 信息展示框
  327. .info-box {
  328. position: absolute;
  329. right: 4px;
  330. bottom: 36px;
  331. right: -210px;
  332. bottom: -50px;
  333. font-size: 12px;
  334. color: #333;
  335. background: rgba(255, 255, 255, 0.85);
  336. padding: 6px 10px;
  337. border-radius: 6px;
  338. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
  339. z-index: 99;
  340. line-height: 1.5;
  341. pointer-events: none;
  342. min-width: 200px;
  343. user-select: none;
  344. }
  345. // 全屏坐标轴线和点
  346. .full-coordinate-axes {
  347. position: absolute;
  348. width: 100%;
  349. height: 100%;
  350. z-index: 3;
  351. pointer-events: none;
  352. overflow: hidden;
  353. .axis-line {
  354. position: absolute;
  355. height: 1px;
  356. background-color: #000;
  357. }
  358. .x-axis {
  359. background-color: red;
  360. opacity: 0.6;
  361. }
  362. .y-axis {
  363. background-color: blue;
  364. opacity: 0.6;
  365. }
  366. .axis-arrow {
  367. position: absolute;
  368. font-weight: bold;
  369. font-size: 12px;
  370. line-height: 1;
  371. }
  372. }
  373. // 坐标轴标记点
  374. .axis-markers {
  375. position: absolute;
  376. width: 100%;
  377. height: 100%;
  378. z-index: 3;
  379. pointer-events: none;
  380. .axis-dot {
  381. position: absolute;
  382. }
  383. .x-dot {
  384. background-color: red;
  385. opacity: 0.6;
  386. }
  387. .y-dot {
  388. background-color: blue;
  389. opacity: 0.6;
  390. }
  391. }
  392. }
  393. .target-dot {
  394. span {
  395. color: #fff;
  396. font-size: 12px;
  397. font-weight: 600;
  398. position: absolute;
  399. left: 50%;
  400. top: 50%;
  401. transform: translate(-50%, -50%);
  402. pointer-events: none;
  403. }
  404. }
  405. </style>