Spaces:
Running
Running
<template> | |
<div | |
class="video-frame-container" | |
tabindex="0" | |
ref="container" | |
@keydown="handleKeyDown" | |
@focus="handleFocus" | |
@blur="handleBlur"> | |
<div class="video-frame" | |
ref="imageContainer" | |
:style="frameStyle" | |
@contextmenu.prevent | |
@wheel.prevent="handleZoom" | |
@mousedown="handleMouseDown" | |
@mousemove="handleMouseMove" | |
@mouseup="stopPan" | |
@mouseleave="stopPan"> | |
<div class="image-container" :style="transformStyle"> | |
<img v-if="thumbnail" | |
:src="thumbnail" | |
alt="Video frame" | |
@load="initializeImage" | |
ref="image" | |
class="video-image" /> | |
<div v-for="(point, index) in calibrationPoints" | |
:key="index" | |
class="calibration-point" | |
:class="{ 'selected-point': selectedFieldPoint && Number(selectedFieldPoint.index) === Number(index) }" | |
:style="{ | |
left: `${point.x}px`, | |
top: `${point.y}px` | |
}"> | |
</div> | |
<div v-for="(line, id) in calibrationLines" | |
:key="'line-'+id" | |
class="calibration-polyline"> | |
<svg class="polyline-svg"> | |
<g v-for="(line, id) in calibrationLines" :key="id"> | |
<polyline | |
:points="formatPoints(line.points)" | |
:class="{ 'selected-line': selectedFieldLine && selectedFieldLine.id === id }" | |
fill="none" | |
stroke="#00FF15" | |
stroke-width="2" | |
/> | |
<circle v-for="(point, index) in line.points" | |
:key="'point-'+index" | |
:cx="point.x" | |
:cy="point.y" | |
r="2" | |
class="polyline-point" | |
:class="{ | |
'selected-line-point': selectedFieldLine && selectedFieldLine.id === id, | |
'dragging': isDraggingPoint && draggedLineId === id && selectedPointIndex === index, | |
'shared-point': sharedPoints.has(`${id}-${index}`), | |
'hoverable': isCtrlPressed && selectedFieldLine | |
}" | |
/> | |
</g> | |
</svg> | |
</div> | |
<div v-for="(point, index) in currentLinePoints" | |
:key="'temp-'+index" | |
class="calibration-point temp-point" | |
:style="{ | |
left: `${point.x}px`, | |
top: `${point.y}px` | |
}" /> | |
<svg class="temp-polyline-svg" v-if="currentLinePoints.length > 0"> | |
<polyline | |
:points="getPolylinePoints(currentLinePoints)" | |
stroke="#FFC107" | |
stroke-dasharray="5,5" | |
fill="none" | |
stroke-width="2" | |
/> | |
</svg> | |
</div> | |
</div> | |
<div class="save-section"> | |
<KeyboardShortcuts /> | |
<button | |
class="action-btn clear-btn" | |
:disabled="Object.keys(calibrationLines).length === 0 && Object.keys(calibrationPoints).length === 0" | |
@click="clearCalibration" | |
> | |
Clear | |
</button> | |
<button | |
class="action-btn process-btn" | |
:disabled="Object.keys(calibrationLines).length === 0 && Object.keys(calibrationPoints).length === 0" | |
@click="processCalibration" | |
> | |
Traiter la calibration | |
</button> | |
</div> | |
</div> | |
</template> | |
<script setup> | |
import { ref, computed, onMounted, onUnmounted } from 'vue' | |
import KeyboardShortcuts from './KeyboardShortcuts.vue' | |
// Props | |
const props = defineProps({ | |
thumbnail: { | |
type: String, | |
default: null | |
}, | |
calibrationPoints: { | |
type: Object, | |
default: () => ({}) | |
}, | |
calibrationLines: { | |
type: Object, | |
default: () => ({}) | |
}, | |
selectedFieldPoint: { | |
type: Object, | |
default: null | |
}, | |
selectedFieldLine: { | |
type: Object, | |
default: null | |
} | |
}) | |
// Emits | |
const emit = defineEmits([ | |
'update:calibrationPoints', | |
'update:calibrationLines', | |
'update:selectedFieldPoint', | |
'update:selectedFieldLine', | |
'clear-calibration', | |
'process-calibration' | |
]) | |
// Refs | |
const container = ref(null) | |
const imageContainer = ref(null) | |
const image = ref(null) | |
// Data | |
const scale = ref(1) | |
const translation = ref({ x: 0, y: 0 }) | |
const aspectRatio = ref(1) | |
const imageSize = ref({ width: 0, height: 0 }) | |
const isPanning = ref(false) | |
const isMiddleMouseDown = ref(false) | |
const lastMousePosition = ref({ x: 0, y: 0 }) | |
const currentLinePoints = ref([]) | |
const isDrawingLine = ref(false) | |
const isDraggingPoint = ref(false) | |
const selectedPointIndex = ref(null) | |
const draggedLineId = ref(null) | |
const proximityThreshold = ref(10) | |
const tempPoint = ref(null) | |
const isCtrlPressed = ref(false) | |
const sharedPoints = ref(new Set()) | |
const draggedPoints = ref([]) | |
const isCreatingLine = ref(false) | |
// Computed | |
const frameStyle = computed(() => { | |
if (!aspectRatio.value) return {} | |
const container = imageContainer.value?.parentElement | |
if (!container) return {} | |
const parentWidth = container.clientWidth | |
const parentHeight = container.clientHeight | |
let width, height | |
if (parentWidth / parentHeight > aspectRatio.value) { | |
height = parentHeight | |
width = height * aspectRatio.value | |
} else { | |
width = parentWidth | |
height = width / aspectRatio.value | |
} | |
return { | |
width: `${width}px`, | |
height: `${height}px` | |
} | |
}) | |
const transformStyle = computed(() => { | |
return { | |
transform: `translate(${translation.value.x}px, ${translation.value.y}px) scale(${scale.value})`, | |
transformOrigin: '0 0' | |
} | |
}) | |
// Methods | |
const initializeImage = (event) => { | |
const img = event.target | |
imageSize.value = { | |
width: img.naturalWidth, | |
height: img.naturalHeight | |
} | |
aspectRatio.value = imageSize.value.width / imageSize.value.height | |
} | |
const handleZoom = (event) => { | |
const zoomFactor = 0.1 | |
const delta = Math.sign(event.deltaY) * -1 | |
const newScale = scale.value + delta * zoomFactor | |
const oldScale = scale.value | |
scale.value = Math.min(Math.max(newScale, 1), 15) | |
if (scale.value === 1) { | |
translation.value = { x: 0, y: 0 } | |
return | |
} | |
if (scale.value !== oldScale) { | |
const rect = imageContainer.value.getBoundingClientRect() | |
const mouseX = event.clientX - rect.left | |
const mouseY = event.clientY - rect.top | |
const pointX = (mouseX - translation.value.x) / oldScale | |
const pointY = (mouseY - translation.value.y) / oldScale | |
translation.value = { | |
x: mouseX - (pointX * scale.value), | |
y: mouseY - (pointY * scale.value) | |
} | |
} | |
} | |
const handleMouseDown = (event) => { | |
if (event.button === 1) { // Clic molette | |
isMiddleMouseDown.value = true | |
lastMousePosition.value = { | |
x: event.clientX, | |
y: event.clientY | |
} | |
event.preventDefault() | |
return | |
} | |
// Gestion des points | |
if (event.button === 0 && props.selectedFieldPoint) { | |
const rect = imageContainer.value.getBoundingClientRect() | |
const x = event.clientX - rect.left | |
const y = event.clientY - rect.top | |
const newPoints = { ...props.calibrationPoints } | |
newPoints[props.selectedFieldPoint.index] = { | |
x: (x - translation.value.x) / scale.value, | |
y: (y - translation.value.y) / scale.value | |
} | |
emit('update:calibrationPoints', newPoints) | |
return | |
} | |
// Gestion des lignes | |
if (event.button === 2 && props.selectedFieldLine) { | |
if (currentLinePoints.value.length >= 2) { | |
const newLines = { ...props.calibrationLines } | |
newLines[props.selectedFieldLine.id] = { | |
points: [...currentLinePoints.value] | |
} | |
emit('update:calibrationLines', newLines) | |
currentLinePoints.value = [] | |
isCreatingLine.value = false | |
} | |
return | |
} | |
if (event.button === 0) { | |
const rect = imageContainer.value.getBoundingClientRect() | |
const mouseX = event.clientX - rect.left | |
const mouseY = event.clientY - rect.top | |
const x = (mouseX - translation.value.x) / scale.value | |
const y = (mouseY - translation.value.y) / scale.value | |
if (isDraggingPoint.value) { | |
const newLines = { ...props.calibrationLines } | |
draggedPoints.value.forEach(({ lineId, pointIndex }) => { | |
if (newLines[lineId] && Array.isArray(newLines[lineId].points)) { | |
newLines[lineId].points[pointIndex] = { x, y } | |
} | |
}) | |
emit('update:calibrationLines', newLines) | |
isDraggingPoint.value = false | |
draggedPoints.value = [] | |
tempPoint.value = null | |
return | |
} | |
if (!isCreatingLine.value) { | |
if (isCtrlPressed.value) { | |
const nearestPoint = findLineByPoint(x, y) | |
if (nearestPoint) { | |
if (currentLinePoints.value.length === 0) { | |
isCreatingLine.value = true | |
currentLinePoints.value.push(nearestPoint.point) | |
sharedPoints.value.add(`${nearestPoint.lineId}-${nearestPoint.pointIndex}`) | |
return | |
} | |
} | |
} | |
const nearestPoint = findLineByPoint(x, y) | |
if (nearestPoint) { | |
isDraggingPoint.value = true | |
const sharedLines = findAllLinesWithPoint(nearestPoint.point.x, nearestPoint.point.y) | |
draggedPoints.value = sharedLines.map(line => ({ | |
lineId: line.lineId, | |
pointIndex: line.pointIndex | |
})) | |
tempPoint.value = { ...nearestPoint.point } | |
return | |
} else if (props.selectedFieldLine) { | |
isCreatingLine.value = true | |
currentLinePoints.value = [{ x, y }] | |
} | |
} else { | |
if (isCtrlPressed.value) { | |
const nearestPoint = findLineByPoint(x, y) | |
if (nearestPoint && nearestPoint.lineId !== props.selectedFieldLine.id) { | |
currentLinePoints.value.push(nearestPoint.point) | |
sharedPoints.value.add(`${nearestPoint.lineId}-${nearestPoint.pointIndex}`) | |
return | |
} | |
} | |
currentLinePoints.value.push({ x, y }) | |
} | |
} | |
} | |
const handleMouseMove = (event) => { | |
if (isMiddleMouseDown.value) { | |
const dx = event.clientX - lastMousePosition.value.x | |
const dy = event.clientY - lastMousePosition.value.y | |
const container = imageContainer.value | |
const img = image.value | |
if (!container || !img) return | |
const containerRect = container.getBoundingClientRect() | |
const imageRect = img.getBoundingClientRect() | |
const minX = containerRect.width - imageRect.width * scale.value | |
const minY = containerRect.height - imageRect.height * scale.value | |
const newX = Math.min(0, Math.max(minX, translation.value.x + dx)) | |
const newY = Math.min(0, Math.max(minY, translation.value.y + dy)) | |
translation.value.x = newX | |
translation.value.y = newY | |
lastMousePosition.value = { | |
x: event.clientX, | |
y: event.clientY | |
} | |
return | |
} | |
if (isDraggingPoint.value && draggedPoints.value.length > 0) { | |
const rect = imageContainer.value.getBoundingClientRect() | |
const mouseX = event.clientX - rect.left | |
const mouseY = event.clientY - rect.top | |
const x = (mouseX - translation.value.x) / scale.value | |
const y = (mouseY - translation.value.y) / scale.value | |
const newLines = { ...props.calibrationLines } | |
draggedPoints.value.forEach(({ lineId, pointIndex }) => { | |
if (newLines[lineId] && Array.isArray(newLines[lineId].points)) { | |
newLines[lineId].points[pointIndex] = { x, y } | |
} | |
}) | |
emit('update:calibrationLines', newLines) | |
} | |
} | |
const stopPan = () => { | |
isMiddleMouseDown.value = false | |
} | |
const handleKeyDown = (event) => { | |
if (event.key === 'Control') { | |
isCtrlPressed.value = true | |
} else if (event.key === 'Delete' || event.key === 'Backspace') { | |
deleteLine() | |
} | |
} | |
const handleKeyUp = (event) => { | |
if (event.key === 'Control') { | |
isCtrlPressed.value = false | |
} | |
} | |
const handleFocus = () => { | |
console.log('Container focused') | |
} | |
const handleBlur = () => { | |
console.log('Container lost focus') | |
} | |
const getPolylinePoints = (points) => { | |
if (!points) return '' | |
return points.map(p => `${p.x},${p.y}`).join(' ') | |
} | |
const formatPoints = (points) => { | |
if (!points) return '' | |
return points.map(p => `${p.x},${p.y}`).join(' ') | |
} | |
const findLineByPoint = (x, y) => { | |
for (const [lineId, line] of Object.entries(props.calibrationLines)) { | |
const points = line.points | |
for (let i = 0; i < points.length; i++) { | |
const point = points[i] | |
const dx = point.x - x | |
const dy = point.y - y | |
const distance = Math.sqrt(dx * dx + dy * dy) | |
if (distance < proximityThreshold.value) { | |
return { | |
lineId, | |
pointIndex: i, | |
point | |
} | |
} | |
} | |
} | |
return null | |
} | |
const findAllLinesWithPoint = (x, y) => { | |
const sharedLines = [] | |
for (const [lineId, line] of Object.entries(props.calibrationLines)) { | |
const points = line.points | |
for (let i = 0; i < points.length; i++) { | |
const point = points[i] | |
const dx = point.x - x | |
const dy = point.y - y | |
const distance = Math.sqrt(dx * dx + dy * dy) | |
if (distance < proximityThreshold.value) { | |
sharedLines.push({ | |
lineId, | |
pointIndex: i, | |
point | |
}) | |
} | |
} | |
} | |
return sharedLines | |
} | |
const deleteLine = () => { | |
if (props.selectedFieldLine) { | |
const newLines = { ...props.calibrationLines } | |
if (newLines[props.selectedFieldLine.id]) { | |
const points = newLines[props.selectedFieldLine.id].points | |
points.forEach((_, index) => { | |
sharedPoints.value.delete(`${props.selectedFieldLine.id}-${index}`) | |
}) | |
} | |
delete newLines[props.selectedFieldLine.id] | |
emit('update:calibrationLines', newLines) | |
emit('update:selectedFieldLine', null) | |
currentLinePoints.value = [] | |
isCreatingLine.value = false | |
} | |
} | |
const clearCalibration = () => { | |
emit('clear-calibration') | |
} | |
const processCalibration = () => { | |
emit('process-calibration') | |
} | |
// Lifecycle | |
onMounted(() => { | |
window.addEventListener('keydown', handleKeyDown) | |
window.addEventListener('keyup', handleKeyUp) | |
}) | |
onUnmounted(() => { | |
window.removeEventListener('keydown', handleKeyDown) | |
window.removeEventListener('keyup', handleKeyUp) | |
}) | |
// Expose imageSize for parent component | |
defineExpose({ | |
imageSize | |
}) | |
</script> | |
<style scoped> | |
.video-frame-container { | |
flex: 2; | |
position: relative; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
min-height: 0; | |
border-radius: 4px; | |
padding: 10px; | |
outline: none; | |
} | |
.video-frame { | |
position: relative; | |
overflow: hidden; | |
width: 100%; | |
height: calc(100% - 50px); | |
margin-bottom: 45px; | |
} | |
.image-container { | |
position: absolute; | |
top: 0; | |
left: 0; | |
will-change: transform; | |
} | |
.video-image { | |
display: block; | |
max-width: 100%; | |
height: auto; | |
} | |
.calibration-point { | |
position: absolute; | |
width: 12px; | |
height: 12px; | |
background-color: rgb(0, 255, 21); | |
border-radius: 50%; | |
transform: translate(-50%, -50%); | |
pointer-events: none; | |
box-shadow: 0 0 6px rgba(76, 175, 80, 0.8), | |
0 0 12px rgba(76, 175, 80, 0.5); | |
} | |
.selected-point { | |
background-color: #FFC107; | |
box-shadow: 0 0 8px rgba(255, 193, 7, 0.8), | |
0 0 15px rgba(255, 193, 7, 0.5); | |
} | |
.save-section { | |
position: absolute; | |
bottom: 10px; | |
left: 10px; | |
display: flex; | |
gap: 10px; | |
} | |
.action-btn { | |
display: flex; | |
align-items: center; | |
padding: 0.5rem 1rem; | |
background: rgba(255, 255, 255, 0.1); | |
color: #ffffff; | |
border: 1px solid #555; | |
border-radius: 6px; | |
font-weight: 600; | |
font-size: 0.9rem; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
} | |
.action-btn:hover:not(:disabled) { | |
background: rgba(255, 255, 255, 0.15); | |
border-color: var(--color-primary); | |
color: var(--color-primary); | |
transform: translateY(-2px); | |
box-shadow: 0 4px 15px rgba(217, 255, 4, 0.2); | |
} | |
.action-btn:disabled { | |
background: rgba(255, 255, 255, 0.05); | |
color: #666; | |
border-color: #333; | |
cursor: not-allowed; | |
transform: none; | |
box-shadow: none; | |
} | |
.process-btn { | |
background: var(--color-primary); | |
color: var(--color-secondary); | |
border-color: var(--color-primary); | |
} | |
.process-btn:hover:not(:disabled) { | |
background: var(--color-primary-soft); | |
border-color: var(--color-primary-soft); | |
color: var(--color-secondary); | |
box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4); | |
} | |
.calibration-polyline { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
pointer-events: none; | |
} | |
.polyline-svg { | |
width: 100%; | |
height: 100%; | |
position: absolute; | |
top: 0; | |
left: 0; | |
} | |
.polyline-svg polyline { | |
transition: stroke-width 0.2s; | |
} | |
.selected-line { | |
stroke-width: 2; | |
stroke: #FFC107 !important; | |
} | |
.polyline-point { | |
fill: #00FF15; | |
stroke: white; | |
stroke-width: 0.5; | |
transition: all 0.2s ease; | |
pointer-events: all; | |
cursor: pointer; | |
} | |
.polyline-point:hover { | |
fill: #FFC107; | |
r: 3; | |
stroke-width: 2; | |
} | |
.selected-line .polyline-point { | |
cursor: grab; | |
} | |
.polyline-point.dragging { | |
fill: #FF4081; | |
r: 4; | |
stroke-width: 3; | |
} | |
.selected-line-point { | |
fill: #FFC107; | |
r: 2; | |
stroke-width: 1; | |
} | |
.temp-point { | |
background-color: #FFC107; | |
width: 8px; | |
height: 8px; | |
border-radius: 50%; | |
border: 2px solid white; | |
z-index: 2; | |
} | |
.temp-polyline-svg { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
pointer-events: none; | |
} | |
.polyline-point.shared-point { | |
fill: #FFC107; | |
stroke: #FF4081; | |
stroke-width: 2; | |
r: 4; | |
animation: pulse 2s infinite; | |
} | |
.polyline-point.hoverable { | |
cursor: pointer; | |
filter: brightness(1.2); | |
} | |
@keyframes pulse { | |
0% { | |
stroke-width: 2; | |
stroke-opacity: 1; | |
} | |
50% { | |
stroke-width: 3; | |
stroke-opacity: 0.5; | |
} | |
100% { | |
stroke-width: 2; | |
stroke-opacity: 1; | |
} | |
} | |
</style> |