2nzi's picture
first commit
2964111 verified
<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>