| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- <template>
- <div ref="layerEl" class="region-draw-layer" :class="{ 'is-drawing': isDrawingMode }" @mousedown="onMouseDown" @contextmenu.prevent="onContextMenu">
- <!-- SVG:绘制线与多边形 -->
- <svg class="region-draw-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
- <line
- v-for="line in lineList"
- :key="`${line.region}-${line.no}`"
- :x1="line.points[0].x * 100"
- :y1="line.points[0].y * 100"
- :x2="line.points[1].x * 100"
- :y2="line.points[1].y * 100"
- :stroke="drawColor(line.region)"
- stroke-width="2"
- vector-effect="non-scaling-stroke"
- />
- <polygon
- v-for="poly in polyList"
- :key="`${poly.region}-${poly.no}`"
- :points="svgPoints(poly.points)"
- :stroke="drawColor(poly.region)"
- :fill="drawColor(poly.region)"
- fill-opacity="0.12"
- stroke-width="2"
- vector-effect="non-scaling-stroke"
- />
- <!-- 线预览 -->
- <line
- v-if="previewLine"
- :x1="previewLine[0].x * 100"
- :y1="previewLine[0].y * 100"
- :x2="previewLine[1].x * 100"
- :y2="previewLine[1].y * 100"
- stroke="#01fefc"
- stroke-width="2"
- stroke-dasharray="4 3"
- vector-effect="non-scaling-stroke"
- />
- <!-- 多边形预览 -->
- <polyline
- v-if="previewPolyPoints"
- :points="previewPolyPoints"
- fill="none"
- stroke="#01fefc"
- stroke-width="2"
- stroke-dasharray="4 3"
- vector-effect="non-scaling-stroke"
- />
- </svg>
- <!-- 矩形(带坐标与温度标注) -->
- <div
- v-for="rect in rectList"
- :key="`${rect.region}-${rect.no}`"
- class="region-rect"
- :class="{ 'is-active': rect.region === activeRegion && rect.no === regionState[rect.region].no }"
- :style="rectStyle(rect)"
- >
- <span class="region-rect-coord region-rect-coord-tl">({{ rect.px }}, {{ rect.py }})</span>
- <span class="region-rect-coord region-rect-coord-br">({{ rect.px + rect.pw }}, {{ rect.py + rect.ph }})</span>
- <span v-if="isThermalItem(rect.region)" class="region-rect-temp">temp:{{ regionState[rect.region].temp }}</span>
- </div>
- <!-- 线端点坐标标注 -->
- <template v-for="line in lineList" :key="`labels-${line.region}-${line.no}`">
- <span class="region-point-label" :style="pointLabelStyle(line.points[0])">({{ line.points[0].px }}, {{ line.points[0].py }})</span>
- <span class="region-point-label" :style="pointLabelStyle(line.points[1])">({{ line.points[1].px }}, {{ line.points[1].py }})</span>
- </template>
- <!-- 矩形预览 -->
- <div v-if="drawingRect" class="region-rect region-rect-preview" :style="previewRectStyle"></div>
- <!-- 绘制提示 -->
- <div v-if="isDrawingMode" class="region-draw-tip">{{ shapeTip(activeShape) }}</div>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, reactive, ref, toRefs, onBeforeUnmount } from 'vue';
- import { drawableShapes, shapeTip } from './regionDraw';
- import type { RegionSection, RegionState, DrawItem, RectData, LineData, PolyData, DrawPoint, PxPoint } from './regionDraw';
- const props = defineProps<{
- /** 当前激活的区域 key */
- activeRegion: string;
- /** 当前激活的形状 */
- activeShape: string;
- /** 区域配置 */
- sections: RegionSection[];
- /** 各区域当前编辑值 */
- regionState: Record<string, RegionState>;
- /** 禁用绘制(预览无操作面板时置 true) */
- disabled?: boolean;
- }>();
- const emit = defineEmits<{
- (e: 'save', region: string, drawings: DrawItem[], latest?: DrawItem): void;
- }>();
- const { activeRegion, activeShape, sections, regionState } = toRefs(props);
- /** 绘制层根元素(铺满视频画面,作为坐标参考) */
- const layerEl = ref<HTMLElement | null>(null);
- /** 各区域已绘制的图形(`区域:形状` → 编号 → 图形) */
- const regionDrawings = reactive<Record<string, Record<number, DrawItem>>>({});
- /** 是否处于绘制模式 */
- const isDrawingMode = computed(() => !props.disabled && drawableShapes.includes(activeShape.value) && !!activeRegion.value);
- /** 绘制模式 */
- const drawMode = ref<'' | 'rect' | 'line' | 'poly'>('');
- /** 绘制起点(像素) */
- const drawStartPx = ref<PxPoint | null>(null);
- /** 当前鼠标点(像素) */
- const currentPointPx = ref<PxPoint | null>(null);
- /** 多边形已添加的顶点(像素) */
- const polyPointsPx = ref<PxPoint[]>([]);
- /** 矩形拖拽中的临时矩形(像素) */
- const drawingRect = ref<{ x: number; y: number; w: number; h: number } | null>(null);
- /** 矩形图形列表 */
- const rectList = computed(() =>
- Object.values(regionDrawings)
- .flatMap((map) => Object.values(map))
- .filter((it): it is RectData => it.type === 'rect')
- .sort((a, b) => a.no - b.no)
- );
- /** 线图形列表 */
- const lineList = computed(() =>
- Object.values(regionDrawings)
- .flatMap((map) => Object.values(map))
- .filter((it): it is LineData => it.type === 'line')
- .sort((a, b) => a.no - b.no)
- );
- /** 多边形图形列表 */
- const polyList = computed(() =>
- Object.values(regionDrawings)
- .flatMap((map) => Object.values(map))
- .filter((it): it is PolyData => it.type === 'poly' || it.type === 'polyEdge')
- .sort((a, b) => a.no - b.no)
- );
- /** 鼠标事件 → 画面内像素坐标 */
- function layerPoint(e: MouseEvent): PxPoint {
- const el = layerEl.value;
- if (!el) return { x: 0, y: 0 };
- const r = el.getBoundingClientRect();
- return { x: e.clientX - r.left, y: e.clientY - r.top };
- }
- /** 像素点 → 绘制点(归一化 + 像素) */
- function toDrawPoint(p: PxPoint): DrawPoint {
- const el = layerEl.value;
- const box = el?.getBoundingClientRect();
- const w = box?.width || 1;
- const h = box?.height || 1;
- return { x: p.x / w, y: p.y / h, px: Math.round(p.x), py: Math.round(p.y) };
- }
- type DrawItemInput = Omit<RectData, 'no' | 'region'> | Omit<LineData, 'no' | 'region'> | Omit<PolyData, 'no' | 'region'>;
- /** 区域 + 形状 的组合 key */
- function drawKey(region: string, shape: string) {
- return `${region}:${shape}`;
- }
- /** 保存图形到当前激活区域(编号取面板当前值) */
- function saveDrawItem(item: DrawItemInput) {
- const region = activeRegion.value;
- if (!region) return;
- const no = regionState.value[region]?.no ?? 1;
- const saved = { ...item, no, region } as DrawItem;
- const key = drawKey(region, activeShape.value);
- if (!regionDrawings[key]) regionDrawings[key] = {};
- regionDrawings[key][no] = saved;
- emitSave(region, saved);
- }
- /** 通知父组件当前区域图形已变更(latest 为刚绘制的图形) */
- function emitSave(region: string, latest?: DrawItem) {
- const prefix = `${region}:`;
- const drawings = Object.keys(regionDrawings)
- .filter((k) => k.startsWith(prefix))
- .flatMap((k) => Object.values(regionDrawings[k]))
- .sort((a, b) => a.no - b.no);
- emit('save', region, drawings, latest);
- }
- /** 删除指定区域已绘制的所有图形 */
- function clearRegion(region: string) {
- const prefix = `${region}:`;
- Object.keys(regionDrawings).forEach((k) => {
- if (k.startsWith(prefix)) delete regionDrawings[k];
- });
- emitSave(region);
- }
- /** 设置指定区域指定形状指定编号的图形(外部查询回显),item 为 null 时删除 */
- function setRegionItem(region: string, shape: string, no: number, item: DrawItem | null) {
- const key = drawKey(region, shape);
- if (!regionDrawings[key]) regionDrawings[key] = {};
- if (item == null) {
- delete regionDrawings[key][no];
- } else {
- regionDrawings[key][no] = { ...item, no, region };
- }
- }
- defineExpose({ clearRegion, setRegionItem });
- function onMouseDown(e: MouseEvent) {
- if (!isDrawingMode.value || e.button !== 0) return;
- const p = layerPoint(e);
- const shape = activeShape.value;
- if (shape === 'rect') {
- drawMode.value = 'rect';
- drawStartPx.value = p;
- currentPointPx.value = p;
- drawingRect.value = { x: p.x, y: p.y, w: 0, h: 0 };
- } else if (shape === 'line') {
- // 单击确定起点(右键结束):已在绘线时忽略后续左键,终点以右键位置为准
- if (drawMode.value === 'line' && drawStartPx.value) {
- e.preventDefault();
- return;
- }
- drawMode.value = 'line';
- drawStartPx.value = p;
- currentPointPx.value = p;
- } else if (shape === 'poly' || shape === 'polyEdge') {
- drawMode.value = 'poly';
- polyPointsPx.value.push(p);
- currentPointPx.value = p;
- }
- window.addEventListener('mousemove', onMouseMove);
- window.addEventListener('mouseup', onMouseUp);
- e.preventDefault();
- }
- function onMouseMove(e: MouseEvent) {
- const p = layerPoint(e);
- currentPointPx.value = p;
- if (drawMode.value === 'rect' && drawStartPx.value) {
- const s = drawStartPx.value;
- drawingRect.value = {
- x: Math.min(s.x, p.x),
- y: Math.min(s.y, p.y),
- w: Math.abs(p.x - s.x),
- h: Math.abs(p.y - s.y),
- };
- }
- }
- function onMouseUp(_e: MouseEvent) {
- // 矩形:松手即完成;线 / 多边形:单击开始,右键结束(不在此结束)
- if (drawMode.value === 'rect') finishRect();
- }
- function onContextMenu(e: MouseEvent) {
- if (!isDrawingMode.value) return;
- e.preventDefault();
- if (drawMode.value === 'poly') {
- finishPoly();
- } else if (drawMode.value === 'line') {
- // 右键位置即线终点
- currentPointPx.value = layerPoint(e);
- finishLine();
- }
- }
- function finishRect() {
- const rect = drawingRect.value;
- resetDrawState();
- if (!rect || rect.w < 4 || rect.h < 4) return;
- const tl = toDrawPoint({ x: rect.x, y: rect.y });
- const br = toDrawPoint({ x: rect.x + rect.w, y: rect.y + rect.h });
- saveDrawItem({
- type: 'rect',
- x: tl.x,
- y: tl.y,
- w: br.x - tl.x,
- h: br.y - tl.y,
- px: tl.px,
- py: tl.py,
- pw: br.px - tl.px,
- ph: br.py - tl.py,
- });
- }
- function finishLine() {
- // 起点:单击位置;终点:右键位置(currentPointPx)
- const s = drawStartPx.value;
- const c = currentPointPx.value;
- resetDrawState();
- if (!s || !c) return;
- if (Math.hypot(c.x - s.x, c.y - s.y) < 4) return;
- saveDrawItem({
- type: 'line',
- points: [toDrawPoint(s), toDrawPoint(c)] as [DrawPoint, DrawPoint],
- });
- }
- function finishPoly() {
- // 右键结束:直接使用已单击添加的顶点(右键不会新增顶点)
- const pts = polyPointsPx.value;
- resetDrawState();
- if (pts.length < 2) return;
- saveDrawItem({
- type: activeShape.value === 'polyEdge' ? 'polyEdge' : 'poly',
- points: pts.map((p) => toDrawPoint(p)),
- });
- }
- function resetDrawState() {
- drawMode.value = '';
- drawStartPx.value = null;
- currentPointPx.value = null;
- polyPointsPx.value = [];
- drawingRect.value = null;
- window.removeEventListener('mousemove', onMouseMove);
- window.removeEventListener('mouseup', onMouseUp);
- }
- /** 图形所属区域颜色 */
- function drawColor(region: string) {
- return regionState.value[region]?.color || '#3ed43e';
- }
- /** 是否为测温区域图形 */
- function isThermalItem(region: string) {
- return sections.value.find((s) => s.key === region)?.thermal === true;
- }
- /** 矩形展示样式(归一化坐标 → 百分比) */
- function rectStyle(rect: RectData) {
- return {
- left: `${rect.x * 100}%`,
- top: `${rect.y * 100}%`,
- width: `${rect.w * 100}%`,
- height: `${rect.h * 100}%`,
- borderColor: drawColor(rect.region),
- };
- }
- /** 拖拽中临时矩形的展示样式 */
- const previewRectStyle = computed(() => {
- const r = drawingRect.value;
- const el = layerEl.value;
- if (!r || !el) return {};
- const box = el.getBoundingClientRect();
- return {
- left: `${(r.x / box.width) * 100}%`,
- top: `${(r.y / box.height) * 100}%`,
- width: `${(r.w / box.width) * 100}%`,
- height: `${(r.h / box.height) * 100}%`,
- borderColor: drawColor(activeRegion.value),
- };
- });
- /** 线预览(两端归一化点) */
- const previewLine = computed(() => {
- if (drawMode.value !== 'line' || !drawStartPx.value || !currentPointPx.value) return null;
- return [toDrawPoint(drawStartPx.value), toDrawPoint(currentPointPx.value)];
- });
- /** 多边形预览点串(SVG points,归一化×100) */
- const previewPolyPoints = computed(() => {
- if (drawMode.value !== 'poly' || !polyPointsPx.value.length) return '';
- const pts = [...polyPointsPx.value];
- if (currentPointPx.value) pts.push(currentPointPx.value);
- return pts
- .map((p) => {
- const d = toDrawPoint(p);
- return `${d.x * 100},${d.y * 100}`;
- })
- .join(' ');
- });
- /** SVG points 工具:归一化点 → "x,y" 串(×100 映射到 viewBox 0 0 100 100) */
- function svgPoints(points: DrawPoint[]) {
- return points.map((p) => `${p.x * 100},${p.y * 100}`).join(' ');
- }
- /** 线端点坐标标注样式 */
- function pointLabelStyle(p: DrawPoint) {
- return { left: `${p.x * 100}%`, top: `${p.y * 100}%` };
- }
- onBeforeUnmount(() => {
- resetDrawState();
- });
- </script>
- <style lang="less" scoped>
- .region-draw-layer {
- position: absolute;
- inset: 0;
- z-index: 5;
- pointer-events: none;
- &.is-drawing {
- pointer-events: auto;
- cursor: crosshair;
- }
- }
- .region-draw-svg {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- }
- .region-point-label {
- position: absolute;
- z-index: 1;
- padding: 1px 4px;
- color: #0a1a2e;
- font-size: 11px;
- line-height: 14px;
- white-space: nowrap;
- transform: translate(-50%, -50%);
- background: rgba(1, 254, 252, 0.85);
- border-radius: 2px;
- pointer-events: none;
- }
- .region-rect {
- position: absolute;
- box-sizing: border-box;
- border: 2px dashed #3ed43e;
- background: rgba(62, 212, 62, 0.12);
- pointer-events: none;
- &.is-active {
- border-color: #ff6a1a;
- background: rgba(255, 106, 26, 0.14);
- }
- }
- .region-rect-coord {
- position: absolute;
- z-index: 1;
- padding: 1px 4px;
- color: #0a1a2e;
- font-size: 11px;
- line-height: 14px;
- white-space: nowrap;
- background: rgba(1, 254, 252, 0.85);
- border-radius: 2px;
- pointer-events: none;
- }
- .region-rect-coord-tl {
- top: 2px;
- left: 2px;
- }
- .region-rect-coord-br {
- right: 2px;
- bottom: 2px;
- }
- .region-rect-temp {
- position: absolute;
- top: 50%;
- right: 2px;
- z-index: 1;
- padding: 1px 6px;
- color: #ff7a3d;
- font-size: 12px;
- line-height: 16px;
- font-weight: 600;
- white-space: nowrap;
- transform: translateY(-50%);
- background: rgba(4, 18, 34, 0.82);
- border: 1px solid rgba(255, 122, 61, 0.6);
- border-radius: 2px;
- pointer-events: none;
- }
- .region-rect-preview {
- border-style: solid;
- border-color: #01fefc;
- background: rgba(1, 254, 252, 0.18);
- }
- .region-draw-tip {
- position: absolute;
- top: 12px;
- left: 50%;
- transform: translateX(-50%);
- padding: 3px 12px;
- color: #01fefc;
- font-size: 12px;
- white-space: nowrap;
- background: rgba(4, 18, 34, 0.82);
- border: 1px solid rgba(1, 254, 252, 0.5);
- border-radius: 2px;
- pointer-events: none;
- }
- </style>
|