Spaces:
Sleeping
Sleeping
| <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> |