feat: 重建 Sense 区域与警戒线配置 (#69)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user