feat: 重建 Sense 区域与警戒线配置 (#69)

This commit is contained in:
QiuSW
2026-08-15 09:10:43 +08:00
parent 020bf3fe5a
commit 17bd383229
21 changed files with 1948 additions and 2 deletions
@@ -0,0 +1,55 @@
export const POLYGON = 'polygon'
export const DIRECTION_LINE = 'direction_line'
export function clamp(value) {
return Math.min(1, Math.max(0, Number(value)))
}
export function geometryError(kind, points, direction = '') {
if (!Array.isArray(points)) return '请在画面中添加坐标点'
if (kind === POLYGON && (points.length < 3 || points.length > 64)) return '多边形需要 3 到 64 个点'
if (kind === DIRECTION_LINE && points.length !== 2) return '方向警戒线需要恰好 2 个点'
if (kind === DIRECTION_LINE && !['forward', 'reverse'].includes(direction)) return '请选择警戒方向'
if (![POLYGON, DIRECTION_LINE].includes(kind)) return '请选择配置类型'
if (points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1)) return '坐标必须位于画面范围内'
if (kind === POLYGON && selfIntersects(points)) return '多边形边线不能交叉,请调整顶点'
if (kind === POLYGON && Math.abs(polygonArea(points)) < 0.000001) return '多边形面积过小,请重新绘制'
if (kind === DIRECTION_LINE && samePoint(points[0], points[1])) return '警戒线起点和终点不能重合'
return ''
}
function samePoint(a, b) {
return Math.abs(a.x - b.x) < 0.0000001 && Math.abs(a.y - b.y) < 0.0000001
}
function polygonArea(points) {
return points.reduce((total, point, index) => {
const next = points[(index + 1) % points.length]
return total + point.x * next.y - next.x * point.y
}, 0) / 2
}
function selfIntersects(points) {
for (let first = 0; first < points.length; first += 1) {
const a = points[first]
const b = points[(first + 1) % points.length]
for (let second = first + 1; second < points.length; second += 1) {
if (second === first || second === (first + 1) % points.length || first === (second + 1) % points.length) continue
const c = points[second]
const d = points[(second + 1) % points.length]
if (segmentsIntersect(a, b, c, d)) return true
}
}
return false
}
function segmentsIntersect(a, b, c, d) {
const orientation = (p, q, r) => (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
const values = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)]
if (((values[0] > 0 && values[1] < 0) || (values[0] < 0 && values[1] > 0)) && ((values[2] > 0 && values[3] < 0) || (values[2] < 0 && values[3] > 0))) return true
const onSegment = (p, q, r) => q.x <= Math.max(p.x, r.x) + 0.0000001 && q.x >= Math.min(p.x, r.x) - 0.0000001 && q.y <= Math.max(p.y, r.y) + 0.0000001 && q.y >= Math.min(p.y, r.y) - 0.0000001
return (Math.abs(values[0]) < 0.0000001 && onSegment(a, c, b)) ||
(Math.abs(values[1]) < 0.0000001 && onSegment(a, d, b)) ||
(Math.abs(values[2]) < 0.0000001 && onSegment(c, a, d)) ||
(Math.abs(values[3]) < 0.0000001 && onSegment(c, b, d))
}
@@ -0,0 +1,307 @@
<template>
<section class="geometry-editor" aria-labelledby="geometry-editor-title">
<div class="editor-toolbar">
<div>
<strong id="geometry-editor-title">画面坐标</strong>
<span>{{ instruction }}</span>
</div>
<div class="editor-actions">
<el-button :disabled="disabled || !canAdd" @click="addCenterPoint">添加中心点</el-button>
<el-button :disabled="disabled || undoStack.length === 0" @click="undo">撤销</el-button>
<el-button :disabled="disabled || points.length === 0" @click="clear">清空</el-button>
</div>
</div>
<div class="canvas-shell" :class="{ 'is-disabled': disabled }">
<iframe
v-if="playerUrl"
:src="playerUrl"
title="区域校准实时视频"
allow="autoplay; fullscreen"
referrerpolicy="no-referrer"
tabindex="-1"
/>
<div v-else class="canvas-placeholder">选择可用视频后,可在实时画面上绘制</div>
<svg
ref="canvas"
class="geometry-canvas"
viewBox="0 0 1000 562.5"
role="application"
:aria-label="instruction"
tabindex="0"
@click="addFromPointer"
@pointermove="dragPoint"
@pointerup="stopDrag"
@pointercancel="stopDrag"
@keydown.enter.prevent="addCenterPoint"
@keydown.space.prevent="addCenterPoint"
>
<defs>
<marker id="sense-area-arrow" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,8 L10,4 z" class="arrow-head" />
</marker>
</defs>
<polygon v-if="kind === 'polygon' && points.length >= 2" :points="svgPoints" class="area-shape" />
<line
v-if="kind === 'direction_line' && points.length === 2"
:x1="scaledPoints[0].x"
:y1="scaledPoints[0].y"
:x2="scaledPoints[1].x"
:y2="scaledPoints[1].y"
class="direction-line"
:marker-start="direction === 'reverse' ? 'url(#sense-area-arrow)' : undefined"
:marker-end="direction === 'forward' ? 'url(#sense-area-arrow)' : undefined"
/>
<g
v-for="(point, index) in scaledPoints"
:key="index"
class="point-control"
role="button"
tabindex="0"
:aria-label="`坐标点 ${index + 1},横向 ${Math.round(points[index].x * 100)}%,纵向 ${Math.round(points[index].y * 100)}%`"
@click.stop
@pointerdown.stop.prevent="startDrag(index, $event)"
@keydown="movePointByKeyboard(index, $event)"
>
<circle :cx="point.x" :cy="point.y" r="12" />
<text :x="point.x" :y="point.y + 4" text-anchor="middle">{{ index + 1 }}</text>
</g>
</svg>
</div>
<div class="point-summary" aria-live="polite">
<span>已添加 {{ points.length }} 个点</span>
<span>键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。</span>
</div>
<p v-if="error" class="geometry-error" role="alert">{{ error }}</p>
</section>
</template>
<script>
import { clamp, geometryError } from './geometry'
export default {
name: 'SenseGeometryEditor',
props: {
modelValue: { type: Array, default: () => [] },
kind: { type: String, required: true },
direction: { type: String, default: '' },
playerUrl: { type: String, default: '' },
disabled: { type: Boolean, default: false }
},
emits: ['update:modelValue'],
data() {
return { undoStack: [], draggingIndex: -1 }
},
computed: {
points() {
return this.modelValue || []
},
scaledPoints() {
return this.points.map(point => ({ x: point.x * 1000, y: point.y * 562.5 }))
},
svgPoints() {
return this.scaledPoints.map(point => `${point.x},${point.y}`).join(' ')
},
canAdd() {
return this.kind === 'polygon' ? this.points.length < 64 : this.points.length < 2
},
instruction() {
return this.kind === 'polygon' ? '点击画面添加顶点,拖动顶点调整危险区域' : '依次添加起点和终点,箭头表示警戒方向'
},
error() {
if (this.points.length === 0) return ''
return geometryError(this.kind, this.points, this.direction)
}
},
methods: {
commit(next, remember = true) {
if (this.disabled) return
if (remember) this.undoStack.push(this.points.map(point => ({ ...point })))
this.$emit('update:modelValue', next.map(point => ({ x: clamp(point.x), y: clamp(point.y) })))
},
addFromPointer(event) {
if (this.disabled || !this.canAdd || event.target.closest('.point-control')) return
this.commit([...this.points, this.eventPoint(event)])
},
addCenterPoint() {
if (!this.disabled && this.canAdd) this.commit([...this.points, { x: 0.5, y: 0.5 }])
},
clear() {
if (this.points.length) this.commit([])
},
undo() {
if (!this.undoStack.length || this.disabled) return
const previous = this.undoStack.pop()
this.commit(previous, false)
},
startDrag(index, event) {
if (this.disabled) return
this.undoStack.push(this.points.map(point => ({ ...point })))
this.draggingIndex = index
event.currentTarget.setPointerCapture?.(event.pointerId)
},
dragPoint(event) {
if (this.draggingIndex < 0 || this.disabled) return
const next = this.points.map(point => ({ ...point }))
next[this.draggingIndex] = this.eventPoint(event)
this.commit(next, false)
},
stopDrag() {
this.draggingIndex = -1
},
movePointByKeyboard(index, event) {
if (this.disabled) return
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
this.commit(this.points.filter((_, pointIndex) => pointIndex !== index))
return
}
const movement = { ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0], ArrowUp: [0, -0.01], ArrowDown: [0, 0.01] }[event.key]
if (!movement) return
event.preventDefault()
const next = this.points.map(point => ({ ...point }))
next[index] = { x: next[index].x + movement[0], y: next[index].y + movement[1] }
this.commit(next)
},
eventPoint(event) {
const rect = this.$refs.canvas.getBoundingClientRect()
return { x: clamp((event.clientX - rect.left) / rect.width), y: clamp((event.clientY - rect.top) / rect.height) }
}
}
}
</script>
<style scoped>
.geometry-editor {
width: 100%;
}
.editor-toolbar,
.point-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.editor-toolbar > div:first-child {
display: flex;
flex-direction: column;
gap: 4px;
}
.editor-toolbar span,
.point-summary {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.editor-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editor-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.canvas-shell {
position: relative;
width: 100%;
margin-top: 12px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-darker);
}
.canvas-shell iframe,
.geometry-canvas,
.canvas-placeholder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.canvas-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.geometry-canvas {
cursor: crosshair;
outline-offset: -3px;
touch-action: none;
}
.geometry-canvas:focus-visible {
outline: 3px solid var(--el-color-primary);
}
.area-shape {
fill: color-mix(in srgb, var(--el-color-warning) 24%, transparent);
stroke: var(--el-color-warning-dark-2);
stroke-width: 4;
}
.direction-line {
stroke: var(--el-color-danger);
stroke-width: 6;
}
.arrow-head {
fill: var(--el-color-danger);
}
.point-control {
cursor: grab;
outline: none;
}
.point-control circle {
fill: var(--el-color-primary);
stroke: var(--el-color-white);
stroke-width: 3;
}
.point-control text {
fill: var(--el-color-white);
font-size: 12px;
pointer-events: none;
}
.point-control:focus-visible circle {
stroke: var(--el-color-warning);
stroke-width: 6;
}
.point-summary {
margin-top: 8px;
flex-wrap: wrap;
}
.geometry-error {
margin: 8px 0 0;
color: var(--el-color-danger);
line-height: 1.5;
}
.is-disabled .geometry-canvas {
cursor: not-allowed;
}
@media (max-width: 767px) {
.editor-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>