RegionDrawOverlay.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. <template>
  2. <div ref="layerEl" class="region-draw-layer" :class="{ 'is-drawing': isDrawingMode }" @mousedown="onMouseDown" @contextmenu.prevent="onContextMenu">
  3. <!-- SVG:绘制线与多边形 -->
  4. <svg class="region-draw-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
  5. <line
  6. v-for="line in lineList"
  7. :key="`${line.region}-${line.no}`"
  8. :x1="line.points[0].x * 100"
  9. :y1="line.points[0].y * 100"
  10. :x2="line.points[1].x * 100"
  11. :y2="line.points[1].y * 100"
  12. :stroke="drawColor(line.region)"
  13. stroke-width="2"
  14. vector-effect="non-scaling-stroke"
  15. />
  16. <polygon
  17. v-for="poly in polyList"
  18. :key="`${poly.region}-${poly.no}`"
  19. :points="svgPoints(poly.points)"
  20. :stroke="drawColor(poly.region)"
  21. :fill="drawColor(poly.region)"
  22. fill-opacity="0.12"
  23. stroke-width="2"
  24. vector-effect="non-scaling-stroke"
  25. />
  26. <!-- 线预览 -->
  27. <line
  28. v-if="previewLine"
  29. :x1="previewLine[0].x * 100"
  30. :y1="previewLine[0].y * 100"
  31. :x2="previewLine[1].x * 100"
  32. :y2="previewLine[1].y * 100"
  33. stroke="#01fefc"
  34. stroke-width="2"
  35. stroke-dasharray="4 3"
  36. vector-effect="non-scaling-stroke"
  37. />
  38. <!-- 多边形预览 -->
  39. <polyline
  40. v-if="previewPolyPoints"
  41. :points="previewPolyPoints"
  42. fill="none"
  43. stroke="#01fefc"
  44. stroke-width="2"
  45. stroke-dasharray="4 3"
  46. vector-effect="non-scaling-stroke"
  47. />
  48. </svg>
  49. <!-- 矩形(带坐标与温度标注) -->
  50. <div
  51. v-for="rect in rectList"
  52. :key="`${rect.region}-${rect.no}`"
  53. class="region-rect"
  54. :class="{ 'is-active': rect.region === activeRegion && rect.no === regionState[rect.region].no }"
  55. :style="rectStyle(rect)"
  56. >
  57. <span class="region-rect-coord region-rect-coord-tl">({{ rect.px }}, {{ rect.py }})</span>
  58. <span class="region-rect-coord region-rect-coord-br">({{ rect.px + rect.pw }}, {{ rect.py + rect.ph }})</span>
  59. <span v-if="isThermalItem(rect.region)" class="region-rect-temp">temp:{{ regionState[rect.region].temp }}</span>
  60. </div>
  61. <!-- 线端点坐标标注 -->
  62. <template v-for="line in lineList" :key="`labels-${line.region}-${line.no}`">
  63. <span class="region-point-label" :style="pointLabelStyle(line.points[0])">({{ line.points[0].px }}, {{ line.points[0].py }})</span>
  64. <span class="region-point-label" :style="pointLabelStyle(line.points[1])">({{ line.points[1].px }}, {{ line.points[1].py }})</span>
  65. </template>
  66. <!-- 矩形预览 -->
  67. <div v-if="drawingRect" class="region-rect region-rect-preview" :style="previewRectStyle"></div>
  68. <!-- 绘制提示 -->
  69. <div v-if="isDrawingMode" class="region-draw-tip">{{ shapeTip(activeShape) }}</div>
  70. </div>
  71. </template>
  72. <script setup lang="ts">
  73. import { computed, reactive, ref, toRefs, onBeforeUnmount } from 'vue';
  74. import { drawableShapes, shapeTip } from './regionDraw';
  75. import type { RegionSection, RegionState, DrawItem, RectData, LineData, PolyData, DrawPoint, PxPoint } from './regionDraw';
  76. const props = defineProps<{
  77. /** 当前激活的区域 key */
  78. activeRegion: string;
  79. /** 当前激活的形状 */
  80. activeShape: string;
  81. /** 区域配置 */
  82. sections: RegionSection[];
  83. /** 各区域当前编辑值 */
  84. regionState: Record<string, RegionState>;
  85. /** 禁用绘制(预览无操作面板时置 true) */
  86. disabled?: boolean;
  87. }>();
  88. const emit = defineEmits<{
  89. (e: 'save', region: string, drawings: DrawItem[], latest?: DrawItem): void;
  90. }>();
  91. const { activeRegion, activeShape, sections, regionState } = toRefs(props);
  92. /** 绘制层根元素(铺满视频画面,作为坐标参考) */
  93. const layerEl = ref<HTMLElement | null>(null);
  94. /** 各区域已绘制的图形(`区域:形状` → 编号 → 图形) */
  95. const regionDrawings = reactive<Record<string, Record<number, DrawItem>>>({});
  96. /** 是否处于绘制模式 */
  97. const isDrawingMode = computed(() => !props.disabled && drawableShapes.includes(activeShape.value) && !!activeRegion.value);
  98. /** 绘制模式 */
  99. const drawMode = ref<'' | 'rect' | 'line' | 'poly'>('');
  100. /** 绘制起点(像素) */
  101. const drawStartPx = ref<PxPoint | null>(null);
  102. /** 当前鼠标点(像素) */
  103. const currentPointPx = ref<PxPoint | null>(null);
  104. /** 多边形已添加的顶点(像素) */
  105. const polyPointsPx = ref<PxPoint[]>([]);
  106. /** 矩形拖拽中的临时矩形(像素) */
  107. const drawingRect = ref<{ x: number; y: number; w: number; h: number } | null>(null);
  108. /** 矩形图形列表 */
  109. const rectList = computed(() =>
  110. Object.values(regionDrawings)
  111. .flatMap((map) => Object.values(map))
  112. .filter((it): it is RectData => it.type === 'rect')
  113. .sort((a, b) => a.no - b.no)
  114. );
  115. /** 线图形列表 */
  116. const lineList = computed(() =>
  117. Object.values(regionDrawings)
  118. .flatMap((map) => Object.values(map))
  119. .filter((it): it is LineData => it.type === 'line')
  120. .sort((a, b) => a.no - b.no)
  121. );
  122. /** 多边形图形列表 */
  123. const polyList = computed(() =>
  124. Object.values(regionDrawings)
  125. .flatMap((map) => Object.values(map))
  126. .filter((it): it is PolyData => it.type === 'poly' || it.type === 'polyEdge')
  127. .sort((a, b) => a.no - b.no)
  128. );
  129. /** 鼠标事件 → 画面内像素坐标 */
  130. function layerPoint(e: MouseEvent): PxPoint {
  131. const el = layerEl.value;
  132. if (!el) return { x: 0, y: 0 };
  133. const r = el.getBoundingClientRect();
  134. return { x: e.clientX - r.left, y: e.clientY - r.top };
  135. }
  136. /** 像素点 → 绘制点(归一化 + 像素) */
  137. function toDrawPoint(p: PxPoint): DrawPoint {
  138. const el = layerEl.value;
  139. const box = el?.getBoundingClientRect();
  140. const w = box?.width || 1;
  141. const h = box?.height || 1;
  142. return { x: p.x / w, y: p.y / h, px: Math.round(p.x), py: Math.round(p.y) };
  143. }
  144. type DrawItemInput = Omit<RectData, 'no' | 'region'> | Omit<LineData, 'no' | 'region'> | Omit<PolyData, 'no' | 'region'>;
  145. /** 区域 + 形状 的组合 key */
  146. function drawKey(region: string, shape: string) {
  147. return `${region}:${shape}`;
  148. }
  149. /** 保存图形到当前激活区域(编号取面板当前值) */
  150. function saveDrawItem(item: DrawItemInput) {
  151. const region = activeRegion.value;
  152. if (!region) return;
  153. const no = regionState.value[region]?.no ?? 1;
  154. const saved = { ...item, no, region } as DrawItem;
  155. const key = drawKey(region, activeShape.value);
  156. if (!regionDrawings[key]) regionDrawings[key] = {};
  157. regionDrawings[key][no] = saved;
  158. emitSave(region, saved);
  159. }
  160. /** 通知父组件当前区域图形已变更(latest 为刚绘制的图形) */
  161. function emitSave(region: string, latest?: DrawItem) {
  162. const prefix = `${region}:`;
  163. const drawings = Object.keys(regionDrawings)
  164. .filter((k) => k.startsWith(prefix))
  165. .flatMap((k) => Object.values(regionDrawings[k]))
  166. .sort((a, b) => a.no - b.no);
  167. emit('save', region, drawings, latest);
  168. }
  169. /** 删除指定区域已绘制的所有图形 */
  170. function clearRegion(region: string) {
  171. const prefix = `${region}:`;
  172. Object.keys(regionDrawings).forEach((k) => {
  173. if (k.startsWith(prefix)) delete regionDrawings[k];
  174. });
  175. emitSave(region);
  176. }
  177. /** 设置指定区域指定形状指定编号的图形(外部查询回显),item 为 null 时删除 */
  178. function setRegionItem(region: string, shape: string, no: number, item: DrawItem | null) {
  179. const key = drawKey(region, shape);
  180. if (!regionDrawings[key]) regionDrawings[key] = {};
  181. if (item == null) {
  182. delete regionDrawings[key][no];
  183. } else {
  184. regionDrawings[key][no] = { ...item, no, region };
  185. }
  186. }
  187. defineExpose({ clearRegion, setRegionItem });
  188. function onMouseDown(e: MouseEvent) {
  189. if (!isDrawingMode.value || e.button !== 0) return;
  190. const p = layerPoint(e);
  191. const shape = activeShape.value;
  192. if (shape === 'rect') {
  193. drawMode.value = 'rect';
  194. drawStartPx.value = p;
  195. currentPointPx.value = p;
  196. drawingRect.value = { x: p.x, y: p.y, w: 0, h: 0 };
  197. } else if (shape === 'line') {
  198. // 单击确定起点(右键结束):已在绘线时忽略后续左键,终点以右键位置为准
  199. if (drawMode.value === 'line' && drawStartPx.value) {
  200. e.preventDefault();
  201. return;
  202. }
  203. drawMode.value = 'line';
  204. drawStartPx.value = p;
  205. currentPointPx.value = p;
  206. } else if (shape === 'poly' || shape === 'polyEdge') {
  207. drawMode.value = 'poly';
  208. polyPointsPx.value.push(p);
  209. currentPointPx.value = p;
  210. }
  211. window.addEventListener('mousemove', onMouseMove);
  212. window.addEventListener('mouseup', onMouseUp);
  213. e.preventDefault();
  214. }
  215. function onMouseMove(e: MouseEvent) {
  216. const p = layerPoint(e);
  217. currentPointPx.value = p;
  218. if (drawMode.value === 'rect' && drawStartPx.value) {
  219. const s = drawStartPx.value;
  220. drawingRect.value = {
  221. x: Math.min(s.x, p.x),
  222. y: Math.min(s.y, p.y),
  223. w: Math.abs(p.x - s.x),
  224. h: Math.abs(p.y - s.y),
  225. };
  226. }
  227. }
  228. function onMouseUp(_e: MouseEvent) {
  229. // 矩形:松手即完成;线 / 多边形:单击开始,右键结束(不在此结束)
  230. if (drawMode.value === 'rect') finishRect();
  231. }
  232. function onContextMenu(e: MouseEvent) {
  233. if (!isDrawingMode.value) return;
  234. e.preventDefault();
  235. if (drawMode.value === 'poly') {
  236. finishPoly();
  237. } else if (drawMode.value === 'line') {
  238. // 右键位置即线终点
  239. currentPointPx.value = layerPoint(e);
  240. finishLine();
  241. }
  242. }
  243. function finishRect() {
  244. const rect = drawingRect.value;
  245. resetDrawState();
  246. if (!rect || rect.w < 4 || rect.h < 4) return;
  247. const tl = toDrawPoint({ x: rect.x, y: rect.y });
  248. const br = toDrawPoint({ x: rect.x + rect.w, y: rect.y + rect.h });
  249. saveDrawItem({
  250. type: 'rect',
  251. x: tl.x,
  252. y: tl.y,
  253. w: br.x - tl.x,
  254. h: br.y - tl.y,
  255. px: tl.px,
  256. py: tl.py,
  257. pw: br.px - tl.px,
  258. ph: br.py - tl.py,
  259. });
  260. }
  261. function finishLine() {
  262. // 起点:单击位置;终点:右键位置(currentPointPx)
  263. const s = drawStartPx.value;
  264. const c = currentPointPx.value;
  265. resetDrawState();
  266. if (!s || !c) return;
  267. if (Math.hypot(c.x - s.x, c.y - s.y) < 4) return;
  268. saveDrawItem({
  269. type: 'line',
  270. points: [toDrawPoint(s), toDrawPoint(c)] as [DrawPoint, DrawPoint],
  271. });
  272. }
  273. function finishPoly() {
  274. // 右键结束:直接使用已单击添加的顶点(右键不会新增顶点)
  275. const pts = polyPointsPx.value;
  276. resetDrawState();
  277. if (pts.length < 2) return;
  278. saveDrawItem({
  279. type: activeShape.value === 'polyEdge' ? 'polyEdge' : 'poly',
  280. points: pts.map((p) => toDrawPoint(p)),
  281. });
  282. }
  283. function resetDrawState() {
  284. drawMode.value = '';
  285. drawStartPx.value = null;
  286. currentPointPx.value = null;
  287. polyPointsPx.value = [];
  288. drawingRect.value = null;
  289. window.removeEventListener('mousemove', onMouseMove);
  290. window.removeEventListener('mouseup', onMouseUp);
  291. }
  292. /** 图形所属区域颜色 */
  293. function drawColor(region: string) {
  294. return regionState.value[region]?.color || '#3ed43e';
  295. }
  296. /** 是否为测温区域图形 */
  297. function isThermalItem(region: string) {
  298. return sections.value.find((s) => s.key === region)?.thermal === true;
  299. }
  300. /** 矩形展示样式(归一化坐标 → 百分比) */
  301. function rectStyle(rect: RectData) {
  302. return {
  303. left: `${rect.x * 100}%`,
  304. top: `${rect.y * 100}%`,
  305. width: `${rect.w * 100}%`,
  306. height: `${rect.h * 100}%`,
  307. borderColor: drawColor(rect.region),
  308. };
  309. }
  310. /** 拖拽中临时矩形的展示样式 */
  311. const previewRectStyle = computed(() => {
  312. const r = drawingRect.value;
  313. const el = layerEl.value;
  314. if (!r || !el) return {};
  315. const box = el.getBoundingClientRect();
  316. return {
  317. left: `${(r.x / box.width) * 100}%`,
  318. top: `${(r.y / box.height) * 100}%`,
  319. width: `${(r.w / box.width) * 100}%`,
  320. height: `${(r.h / box.height) * 100}%`,
  321. borderColor: drawColor(activeRegion.value),
  322. };
  323. });
  324. /** 线预览(两端归一化点) */
  325. const previewLine = computed(() => {
  326. if (drawMode.value !== 'line' || !drawStartPx.value || !currentPointPx.value) return null;
  327. return [toDrawPoint(drawStartPx.value), toDrawPoint(currentPointPx.value)];
  328. });
  329. /** 多边形预览点串(SVG points,归一化×100) */
  330. const previewPolyPoints = computed(() => {
  331. if (drawMode.value !== 'poly' || !polyPointsPx.value.length) return '';
  332. const pts = [...polyPointsPx.value];
  333. if (currentPointPx.value) pts.push(currentPointPx.value);
  334. return pts
  335. .map((p) => {
  336. const d = toDrawPoint(p);
  337. return `${d.x * 100},${d.y * 100}`;
  338. })
  339. .join(' ');
  340. });
  341. /** SVG points 工具:归一化点 → "x,y" 串(×100 映射到 viewBox 0 0 100 100) */
  342. function svgPoints(points: DrawPoint[]) {
  343. return points.map((p) => `${p.x * 100},${p.y * 100}`).join(' ');
  344. }
  345. /** 线端点坐标标注样式 */
  346. function pointLabelStyle(p: DrawPoint) {
  347. return { left: `${p.x * 100}%`, top: `${p.y * 100}%` };
  348. }
  349. onBeforeUnmount(() => {
  350. resetDrawState();
  351. });
  352. </script>
  353. <style lang="less" scoped>
  354. .region-draw-layer {
  355. position: absolute;
  356. inset: 0;
  357. z-index: 5;
  358. pointer-events: none;
  359. &.is-drawing {
  360. pointer-events: auto;
  361. cursor: crosshair;
  362. }
  363. }
  364. .region-draw-svg {
  365. position: absolute;
  366. inset: 0;
  367. width: 100%;
  368. height: 100%;
  369. pointer-events: none;
  370. }
  371. .region-point-label {
  372. position: absolute;
  373. z-index: 1;
  374. padding: 1px 4px;
  375. color: #0a1a2e;
  376. font-size: 11px;
  377. line-height: 14px;
  378. white-space: nowrap;
  379. transform: translate(-50%, -50%);
  380. background: rgba(1, 254, 252, 0.85);
  381. border-radius: 2px;
  382. pointer-events: none;
  383. }
  384. .region-rect {
  385. position: absolute;
  386. box-sizing: border-box;
  387. border: 2px dashed #3ed43e;
  388. background: rgba(62, 212, 62, 0.12);
  389. pointer-events: none;
  390. &.is-active {
  391. border-color: #ff6a1a;
  392. background: rgba(255, 106, 26, 0.14);
  393. }
  394. }
  395. .region-rect-coord {
  396. position: absolute;
  397. z-index: 1;
  398. padding: 1px 4px;
  399. color: #0a1a2e;
  400. font-size: 11px;
  401. line-height: 14px;
  402. white-space: nowrap;
  403. background: rgba(1, 254, 252, 0.85);
  404. border-radius: 2px;
  405. pointer-events: none;
  406. }
  407. .region-rect-coord-tl {
  408. top: 2px;
  409. left: 2px;
  410. }
  411. .region-rect-coord-br {
  412. right: 2px;
  413. bottom: 2px;
  414. }
  415. .region-rect-temp {
  416. position: absolute;
  417. top: 50%;
  418. right: 2px;
  419. z-index: 1;
  420. padding: 1px 6px;
  421. color: #ff7a3d;
  422. font-size: 12px;
  423. line-height: 16px;
  424. font-weight: 600;
  425. white-space: nowrap;
  426. transform: translateY(-50%);
  427. background: rgba(4, 18, 34, 0.82);
  428. border: 1px solid rgba(255, 122, 61, 0.6);
  429. border-radius: 2px;
  430. pointer-events: none;
  431. }
  432. .region-rect-preview {
  433. border-style: solid;
  434. border-color: #01fefc;
  435. background: rgba(1, 254, 252, 0.18);
  436. }
  437. .region-draw-tip {
  438. position: absolute;
  439. top: 12px;
  440. left: 50%;
  441. transform: translateX(-50%);
  442. padding: 3px 12px;
  443. color: #01fefc;
  444. font-size: 12px;
  445. white-space: nowrap;
  446. background: rgba(4, 18, 34, 0.82);
  447. border: 1px solid rgba(1, 254, 252, 0.5);
  448. border-radius: 2px;
  449. pointer-events: none;
  450. }
  451. </style>