2nzi's picture
update graphe for video static
dff1a0a verified
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useCalibrationStore } from '../stores/calibration'
import { useUploadStore } from '../stores/upload'
import api from '../services/api'
import CalibrationArea from '@/components/CalibrationArea.vue'
import FootballField from '@/components/FootballField.vue'
import PlotlyChart from '@/components/PlotlyChart.vue'
import fieldTestImage from '@/assets/field_test_thumb.jpg'
const router = useRouter()
const calibrationStore = useCalibrationStore()
const uploadStore = useUploadStore()
// Refs pour les sections
const modeSection = ref(null)
const uploadSection = ref(null)
const videoTypeSection = ref(null)
const parametersSection = ref(null)
const processingSection = ref(null)
const errorSection = ref(null)
const resultsSection = ref(null)
const manualSection = ref(null)
// États locaux
const dragActive = ref(false)
const selectedTestImage = ref(null)
// Image de test
const testImageUrl = ref(fieldTestImage)
// États pour la vue manuelle
const thumbnail = ref(null)
const calibrationPoints = ref({})
const selectedFieldPoint = ref(null)
const calibrationLines = ref({})
const selectedFieldLine = ref(null)
// Refs pour les composants manuels
const calibrationArea = ref(null)
const footballField = ref(null)
// Computed
const showUpload = computed(() => calibrationStore.mode !== null && !uploadStore.isFileSelected)
const showVideoType = computed(() => uploadStore.isVideo && uploadStore.isFileSelected && uploadStore.videoType === null)
const showParameters = computed(() => uploadStore.needsParameters && !showManualCalibration.value)
const showProcessing = computed(() => calibrationStore.isProcessingStep)
const showResults = computed(() => calibrationStore.isResultsStep)
const showError = computed(() => calibrationStore.error !== null && !showManualCalibration.value)
// Computed pour afficher la vue manuelle
const showManualCalibration = computed(() => {
return calibrationStore.isManualMode &&
uploadStore.isFileSelected &&
(uploadStore.isImage || (uploadStore.isVideo && uploadStore.videoType === 'static'))
})
// Watchers pour le scroll automatique
watch(() => calibrationStore.mode, async (newMode) => {
if (newMode) {
await nextTick()
scrollToSection(uploadSection.value)
}
})
watch(() => uploadStore.isVideo, async (isVideo) => {
if (isVideo && uploadStore.isFileSelected) {
await nextTick()
scrollToSection(videoTypeSection.value)
}
})
watch(() => uploadStore.videoType, async (videoType) => {
if (calibrationStore.isManualMode) {
if (videoType === 'static') {
// Vidéo statique en mode manuel : charger la vue manuelle intégrée
await loadThumbnail()
await nextTick()
scrollToSection(manualSection.value)
} else if (videoType === 'dynamic') {
// Vidéo dynamique en mode manuel : afficher message d'avertissement
calibrationStore.setError("Le mode manuel pour les vidéos dynamiques n'est pas encore disponible. Cette fonctionnalité nécessite l'implémentation de nouvelles features. Veuillez utiliser le mode automatique ou sélectionner une vidéo statique.")
await nextTick()
scrollToSection(errorSection.value)
}
} else if (videoType === 'dynamic') {
await nextTick()
scrollToSection(parametersSection.value)
}
// Suppression du lancement automatique - sera géré par le watcher extractedFrame
})
// Watcher spécifique pour l'extraction de frame terminée
watch(() => uploadStore.extractedFrame, async (extractedFrame) => {
// Lancement automatique quand la frame est extraite en mode auto + vidéo statique
if (extractedFrame &&
calibrationStore.isAutoMode &&
uploadStore.isStaticVideo) {
console.log('🔥 Frame extracted, starting automatic processing...')
startProcessing()
}
})
watch(() => calibrationStore.isProcessingStep, async (isProcessing) => {
if (isProcessing) {
await nextTick()
scrollToSection(processingSection.value)
}
})
watch(() => calibrationStore.isResultsStep, async (isResults) => {
if (isResults) {
await nextTick()
scrollToSection(resultsSection.value)
}
})
// Watcher spécial pour les résultats en mode manuel
watch(() => calibrationStore.results, async (results) => {
if (results && calibrationStore.isManualMode) {
await nextTick()
scrollToSection(resultsSection.value)
}
})
watch(() => calibrationStore.error, async (error) => {
if (error) {
await nextTick()
scrollToSection(errorSection.value)
}
})
// Méthodes
const scrollToSection = (element) => {
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
const selectMode = (mode) => {
calibrationStore.setMode(mode)
}
const selectTestImage = async () => {
try {
selectedTestImage.value = 'test'
// Créer un objet File à partir de l'URL de l'image de test
const response = await fetch(testImageUrl.value)
const blob = await response.blob()
const file = new File([blob], 'field_test_thumb.jpg', { type: 'image/jpeg' })
uploadStore.setFile(file)
if (calibrationStore.isManualMode) {
// En mode manuel avec image, charger la vue manuelle intégrée
await loadThumbnail()
await nextTick()
scrollToSection(manualSection.value)
} else if (calibrationStore.isAutoMode) {
// En mode auto, lancement automatique pour les images
startProcessing()
}
} catch (error) {
console.error('Erreur lors du chargement de l\'image de test:', error)
// Fallback: utiliser l'URL directement pour la preview
uploadStore.filePreview = testImageUrl.value
uploadStore.selectedFile = { name: 'test-image.jpg', size: 0 }
uploadStore.fileType = 'image'
}
}
const resetToStart = () => {
// Réinitialiser tous les stores
calibrationStore.reset()
uploadStore.clearFile()
// Scroll vers le haut
scrollToSection(modeSection.value)
}
const handleFileSelect = async (event) => {
const file = event.target.files[0]
if (file) {
uploadStore.setFile(file)
if (calibrationStore.isManualMode) {
if (uploadStore.isImage) {
// En mode manuel avec image, charger la vue manuelle intégrée
await loadThumbnail()
await nextTick()
scrollToSection(manualSection.value)
} else if (uploadStore.isVideo) {
// En mode manuel avec vidéo, aller au choix statique/dynamique
await nextTick()
scrollToSection(videoTypeSection.value)
}
} else if (calibrationStore.isAutoMode && uploadStore.isImage) {
// En mode auto, lancement automatique pour les images
startProcessing()
}
// Pour les vidéos en mode auto, on laisse le flow normal (choix statique/dynamique)
}
}
const handleDrop = async (event) => {
event.preventDefault()
dragActive.value = false
const files = event.dataTransfer.files
if (files.length > 0) {
uploadStore.setFile(files[0])
if (calibrationStore.isManualMode) {
if (uploadStore.isImage) {
// En mode manuel avec image, charger la vue manuelle intégrée
await loadThumbnail()
await nextTick()
scrollToSection(manualSection.value)
} else if (uploadStore.isVideo) {
// En mode manuel avec vidéo, aller au choix statique/dynamique
await nextTick()
scrollToSection(videoTypeSection.value)
}
} else if (calibrationStore.isAutoMode && uploadStore.isImage) {
// En mode auto, lancement automatique pour les images
startProcessing()
}
// Pour les vidéos en mode auto, on laisse le flow normal (choix statique/dynamique)
}
}
const handleDragOver = (event) => {
event.preventDefault()
dragActive.value = true
}
const handleDragLeave = () => {
dragActive.value = false
}
const selectVideoType = async (type) => {
await uploadStore.setVideoType(type)
}
const updateParameter = (param, value) => {
calibrationStore.setVideoParams({ [param]: value })
}
const startProcessing = async () => {
calibrationStore.setProcessing(true, 'Initialisation...')
try {
let result
if (uploadStore.isImage || uploadStore.isStaticVideo) {
// Traitement d'image ou de frame extraite
const fileToProcess = uploadStore.isImage ?
uploadStore.selectedFile :
uploadStore.extractedFrame
// Vérification que le fichier existe
if (!fileToProcess) {
const errorMsg = uploadStore.isImage ?
'Aucun fichier image sélectionné' :
'Frame non extraite de la vidéo. Essayez de recharger la vidéo.'
throw new Error(errorMsg)
}
// Logs détaillés pour debug
console.log('🔥 File to process details:', {
name: fileToProcess?.name,
type: fileToProcess?.type,
size: fileToProcess?.size,
constructor: fileToProcess?.constructor?.name
})
console.log('🔥 Processing params:', {
kpThreshold: calibrationStore.videoProcessingParams.kpThreshold,
lineThreshold: calibrationStore.videoProcessingParams.lineThreshold,
typeOfKp: typeof calibrationStore.videoProcessingParams.kpThreshold,
typeOfLine: typeof calibrationStore.videoProcessingParams.lineThreshold
})
// Test de diagnostic si c'est une vidéo statique
if (uploadStore.isStaticVideo && uploadStore.extractedFrame) {
console.log('🔥 Testing extracted frame:', {
isFile: fileToProcess instanceof File,
isBlob: fileToProcess instanceof Blob,
hasName: !!fileToProcess.name,
hasType: !!fileToProcess.type
})
}
result = await processWithProgress(() =>
api.inferenceImage(fileToProcess, {
kpThreshold: calibrationStore.videoProcessingParams.kpThreshold,
lineThreshold: calibrationStore.videoProcessingParams.lineThreshold
})
)
} else if (uploadStore.isDynamicVideo) {
// Traitement de vidéo dynamique
result = await processWithProgress(() =>
api.inferenceVideo(uploadStore.selectedFile, calibrationStore.videoProcessingParams)
)
}
// Console log de la réponse API
console.log('🔥 Réponse API reçue:', result)
if (result.status === 'success') {
calibrationStore.setResults(result)
} else if (result.status === 'failed') {
throw new Error(result.message || "Échec de l'extraction des paramètres")
} else {
throw new Error(result.error || 'Erreur de traitement')
}
} catch (error) {
console.error('❌ Erreur lors du traitement:', error)
calibrationStore.setError(error.message)
}
}
const processWithProgress = async (processFunction) => {
// Activer le mode processing
calibrationStore.setProcessing(true, 'Initialisation...')
const tasks = [
'Chargement du fichier...',
'Détection des lignes du terrain...',
'Analyse des points clés...',
'Calcul des paramètres de caméra...',
'Finalisation...'
]
// Simulation du progrès
for (let i = 0; i < tasks.length; i++) {
calibrationStore.updateProgress((i / tasks.length) * 100, tasks[i])
await new Promise(resolve => setTimeout(resolve, 500))
}
// Traitement réel
const result = await processFunction()
calibrationStore.updateProgress(100, 'Terminé !')
return result
}
const goToManual = async () => {
calibrationStore.switchToManual()
await loadThumbnail()
await nextTick()
scrollToSection(manualSection.value)
}
const backToCalibration = async () => {
// Réinitialiser les erreurs et résultats
calibrationStore.setError(null)
calibrationStore.setResults(null)
calibrationStore.setProcessing(false)
// Retourner à la section de calibration manuelle
await nextTick()
scrollToSection(manualSection.value)
}
const restart = () => {
calibrationStore.reset()
uploadStore.clearFile()
scrollToSection(modeSection.value)
}
const exportResults = () => {
// Encapsuler les données de calibration dans une clé "calibration"
const data = {
calibration: calibrationStore.results
}
// Générer le nom de fichier basé sur le fichier original
let filename = 'football_vision_config.json'
if (uploadStore.selectedFile && uploadStore.selectedFile.name) {
// Enlever l'extension du fichier original et ajouter .json
const originalName = uploadStore.selectedFile.name
const nameWithoutExtension = originalName.substring(0, originalName.lastIndexOf('.')) || originalName
filename = `${nameWithoutExtension}_config.json`
}
const content = JSON.stringify(data, null, 2)
const blob = new Blob([content], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
// Méthodes pour la vue manuelle
const loadThumbnail = async () => {
try {
thumbnail.value = null
if (uploadStore.isImage) {
// Pour les images, utiliser directement la preview
thumbnail.value = uploadStore.filePreview
} else if (uploadStore.isStaticVideo && uploadStore.extractedFrame) {
// Pour les vidéos statiques, utiliser la frame déjà extraite
console.log('Using extracted frame from static video:', uploadStore.selectedFile.name)
thumbnail.value = URL.createObjectURL(uploadStore.extractedFrame)
} else if (uploadStore.isVideo) {
// Pour les autres vidéos, extraire la première frame
console.log('Extracting first frame from video:', uploadStore.selectedFile.name)
// Créer une URL pour le fichier vidéo
const videoUrl = URL.createObjectURL(uploadStore.selectedFile)
// Extraire la première frame avec un canvas
const video = document.createElement('video')
video.src = videoUrl
video.muted = true // Important pour éviter les problèmes d'autoplay
await new Promise((resolve, reject) => {
video.addEventListener('loadedmetadata', () => {
video.currentTime = 0 // Aller à la première frame
})
video.addEventListener('seeked', () => {
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0)
thumbnail.value = canvas.toDataURL('image/jpeg', 0.9)
URL.revokeObjectURL(videoUrl)
resolve()
} catch (err) {
URL.revokeObjectURL(videoUrl)
reject(err)
}
})
video.addEventListener('error', (err) => {
URL.revokeObjectURL(videoUrl)
reject(err)
})
})
}
} catch (error) {
console.error('Erreur lors du chargement du thumbnail:', error)
thumbnail.value = null
}
}
const handleFieldPointSelected = (pointData) => {
selectedFieldLine.value = null
selectedFieldPoint.value = pointData
}
const handleFieldLineSelected = (lineData) => {
selectedFieldPoint.value = null
selectedFieldLine.value = lineData
}
const updateThumbnail = (newThumbnail) => {
thumbnail.value = newThumbnail
}
const updateCalibrationPoints = (newPoints) => {
calibrationPoints.value = { ...newPoints }
}
const updateCalibrationLines = (newLines) => {
calibrationLines.value = { ...newLines }
}
const updateSelectedFieldPoint = (newPoint) => {
selectedFieldPoint.value = newPoint
}
const updateSelectedFieldLine = (newLine) => {
selectedFieldLine.value = newLine
if (footballField.value) {
footballField.value.selectedLine = newLine ? newLine.id : null
}
}
const clearCalibration = () => {
calibrationPoints.value = {}
calibrationLines.value = {}
selectedFieldPoint.value = null
selectedFieldLine.value = null
}
const processCalibration = async () => {
if (!uploadStore.selectedFile || Object.keys(calibrationLines.value).length === 0) {
alert('Veuillez créer au moins une ligne de calibration')
return
}
// Scroll immédiat vers la section de processing
scrollToSection(processingSection.value)
await nextTick()
try {
// Utiliser le même système de progress que la version automatique
const result = await processWithProgress(async () => {
if (!calibrationArea.value) {
throw new Error('CalibrationArea component is not mounted.')
}
const imageContainer = document.querySelector('.video-frame')
const imageSize = calibrationArea.value.imageSize
if (!imageSize) {
throw new Error('Image size is not available.')
}
const containerWidth = imageContainer.clientWidth
const containerHeight = imageContainer.clientHeight
// Préparer les données des lignes pour l'API /calibrate
const linesData = {}
// Traitement des lignes - conversion des coordonnées container vers image
for (const [lineName, line] of Object.entries(calibrationLines.value)) {
linesData[lineName] = line.points.map(point => {
return {
x: point.x / containerWidth * imageSize.width,
y: point.y / containerHeight * imageSize.height
}
})
}
console.log('🔥 Données de lignes pour calibration:', linesData)
// Déterminer quel fichier envoyer à l'API
let fileToSend
if (uploadStore.isImage) {
// Pour les images, utiliser le fichier original
fileToSend = uploadStore.selectedFile
} else if (uploadStore.isStaticVideo && uploadStore.extractedFrame) {
// Pour les vidéos statiques, utiliser la frame extraite
fileToSend = uploadStore.extractedFrame
} else {
throw new Error('Aucune image disponible pour la calibration')
}
console.log('🔥 Fichier envoyé pour calibration:', fileToSend.name || 'extracted_frame.jpg', 'Type:', fileToSend.type)
// Appeler l'API /calibrate avec l'image et les lignes
return await api.calibrateCamera(fileToSend, linesData)
})
// Console log de la réponse API
console.log('🔥 Réponse API calibration reçue:', result)
// Toujours afficher les résultats, même en cas d'erreur
calibrationStore.setResults(result)
} catch (error) {
console.error('❌ Erreur lors du traitement manuel:', error)
// En cas d'erreur de réseau ou autre, créer un objet de résultat d'erreur
calibrationStore.setResults({
status: 'failed',
message: error.message,
error: error.message
})
}
}
// Méthodes pour les événements du graphique Plotly
const onChartDataLoaded = (data) => {
if (data.rows) {
// Pour les données CSV
console.log('📊 Données CSV chargées:', data.rows.length, 'points')
} else {
// Pour le terrain de football ou autres
console.log('📊 Graphique chargé:', data.traces.length, 'éléments')
}
}
const onChartError = (error) => {
console.error('❌ Erreur dans le graphique:', error)
}
</script>
<template>
<div class="home-container">
<!-- Bouton retour subtil (visible si on a déjà fait des actions) -->
<button
v-if="showUpload || showResults || showError"
@click="resetToStart"
class="btn-back-home"
title="Recommencer"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M3 21v-5h5"/>
</svg>
</button>
<!-- Section 1: Choix du mode -->
<section ref="modeSection" class="section mode-section">
<div class="hero">
<h1>Football Vision</h1>
<p class="hero-subtitle">Analysez automatiquement les paramètres de caméra de vos vidéos de football</p>
</div>
<div class="mode-selection">
<div class="mode-cards">
<div
class="mode-card auto"
:class="{ selected: calibrationStore.isAutoMode }"
@click="selectMode('auto')"
>
<h3>Mode Automatique</h3>
<p>Détection automatique des lignes du terrain</p>
<ul>
<li>Rapide et simple</li>
<li>Précision élevée</li>
<li>Recommandé</li>
</ul>
</div>
<div
class="mode-card manual"
:class="{ selected: calibrationStore.isManualMode }"
@click="selectMode('manual')"
>
<h3>Mode Manuel</h3>
<p>Contrôle total sur la définition des lignes</p>
<ul>
<li>Contrôle précis</li>
<li>Personnalisable</li>
<li>Cas spéciaux</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Section 2: Upload de fichier -->
<section v-if="showUpload" ref="uploadSection" class="section upload-section">
<div class="upload-grid">
<!-- Côté gauche: Upload de fichier -->
<div class="upload-left">
<div
class="drop-zone"
:class="{
active: dragActive,
'has-file': uploadStore.isFileSelected,
'processing': uploadStore.isUploading
}"
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
>
<div v-if="!uploadStore.isFileSelected" class="drop-content">
<div class="upload-icon">+</div>
<h4>Glissez-déposez votre fichier ici</h4>
<p class="or-text">ou</p>
<label class="file-input-label">
<input
type="file"
accept="image/*,video/*"
@change="handleFileSelect"
hidden
>
Choisir un fichier
</label>
</div>
<div v-else class="file-preview">
<div class="file-info">
<h4>Fichier sélectionné</h4>
<p class="file-name">{{ uploadStore.selectedFile.name }}</p>
<p class="file-details">
<span>Type: {{ uploadStore.fileType === 'image' ? 'Image' : 'Vidéo' }}</span>
<span>Taille: {{ Math.round(uploadStore.selectedFile.size / 1024) }} KB</span>
</p>
</div>
<div class="preview" v-if="uploadStore.filePreview">
<img
v-if="uploadStore.isImage"
:src="uploadStore.filePreview"
alt="Preview"
class="preview-media"
>
<video
v-else
:src="uploadStore.filePreview"
controls
class="preview-media"
>
</video>
</div>
<button @click="uploadStore.clearFile()" class="btn-secondary">
Changer de fichier
</button>
</div>
</div>
</div>
<!-- Côté droit: Images de test -->
<div class="upload-right">
<div class="test-image-container">
<div
class="test-image-card single"
@click="selectTestImage"
:class="{ selected: selectedTestImage === 'test' }"
>
<img :src="testImageUrl" :alt="'Test Image'" class="test-image-thumb">
<div class="test-image-overlay">
<div class="overlay-content">
<p>Vue Drone</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Section 3: Type de vidéo (statique/dynamique) -->
<section v-if="showVideoType" ref="videoTypeSection" class="section video-type-section">
<h2>Type de vidéo</h2>
<div class="video-type-cards">
<div
class="type-card static"
:class="{ selected: uploadStore.videoType === 'static' }"
@click="selectVideoType('static')"
>
<h3>Vidéo Statique</h3>
<p>Caméra fixe, extraction de la première image</p>
<ul>
<li>Traitement rapide</li>
<li>Analyse comme une image</li>
<li>Recommandé pour caméra fixe</li>
</ul>
</div>
<div
class="type-card dynamic"
:class="{ selected: uploadStore.videoType === 'dynamic' }"
@click="selectVideoType('dynamic')"
>
<h3>Vidéo Dynamique</h3>
<p>Caméra en mouvement, analyse de plusieurs frames</p>
<ul>
<li>Analyse complète</li>
<li>Traitement avancé</li>
<li>Plus de données collectées</li>
</ul>
</div>
</div>
</section>
<!-- Section 4: Paramètres pour vidéo dynamique -->
<section v-if="showParameters" ref="parametersSection" class="section parameters-section">
<h2>Paramètres de traitement</h2>
<div class="parameters-form">
<div class="param-group">
<label>Seuil de détection des points clés</label>
<input
type="range"
min="0.1"
max="0.5"
step="0.05"
:value="calibrationStore.videoProcessingParams.kpThreshold"
@input="updateParameter('kpThreshold', parseFloat($event.target.value))"
>
<span class="param-value">{{ calibrationStore.videoProcessingParams.kpThreshold }}</span>
</div>
<div class="param-group">
<label>Seuil de détection des lignes</label>
<input
type="range"
min="0.1"
max="0.5"
step="0.05"
:value="calibrationStore.videoProcessingParams.lineThreshold"
@input="updateParameter('lineThreshold', parseFloat($event.target.value))"
>
<span class="param-value">{{ calibrationStore.videoProcessingParams.lineThreshold }}</span>
</div>
<div class="param-group">
<label>Pas entre les frames (traiter 1 frame sur X)</label>
<input
type="range"
min="1"
max="300"
step="1"
:value="calibrationStore.videoProcessingParams.frameStep"
@input="updateParameter('frameStep', parseInt($event.target.value))"
>
<span class="param-value">{{ calibrationStore.videoProcessingParams.frameStep }}</span>
</div>
<button @click="startProcessing" class="btn-primary launch-btn">
Lancer le traitement
</button>
</div>
</section>
<!-- Section 5: Vue manuelle intégrée -->
<section v-if="showManualCalibration" ref="manualSection" class="section manual-section">
<div class="manual-container">
<div class="manual-content">
<div class="calibration-container">
<CalibrationArea
ref="calibrationArea"
:thumbnail="thumbnail"
:calibrationPoints="calibrationPoints"
:calibrationLines="calibrationLines"
:selectedFieldPoint="selectedFieldPoint"
:selectedFieldLine="selectedFieldLine"
@update:thumbnail="updateThumbnail"
@update:calibrationPoints="updateCalibrationPoints"
@update:calibrationLines="updateCalibrationLines"
@update:selectedFieldPoint="updateSelectedFieldPoint"
@update:selectedFieldLine="updateSelectedFieldLine"
@clear-calibration="clearCalibration"
@process-calibration="processCalibration"
/>
</div>
<div class="field-container">
<FootballField
ref="footballField"
@point-selected="handleFieldPointSelected"
@line-selected="handleFieldLineSelected"
:positionedPoints="calibrationPoints"
:positionedLines="calibrationLines"
/>
</div>
</div>
</div>
</section>
<!-- Section 6: Traitement en cours -->
<section v-if="showProcessing" ref="processingSection" class="section processing-section">
<h2>Traitement en cours</h2>
<div class="processing-card">
<div class="processing-spinner"></div>
<h3>{{ calibrationStore.processingTask }}</h3>
<div class="progress-container">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: calibrationStore.processingProgress + '%' }"
></div>
</div>
<span class="progress-text">{{ Math.round(calibrationStore.processingProgress) }}%</span>
</div>
<div class="processing-info">
<div class="info-item">
<span class="label">Fichier:</span>
<span class="value">{{ uploadStore.selectedFile?.name }}</span>
</div>
<div class="info-item">
<span class="label">Mode:</span>
<span class="value">{{ calibrationStore.isAutoMode ? 'Automatique' : 'Manuel' }}</span>
</div>
<div class="info-item" v-if="uploadStore.isVideo">
<span class="label">Type:</span>
<span class="value">{{ uploadStore.videoType === 'static' ? 'Vidéo Statique' : 'Vidéo Dynamique' }}</span>
</div>
</div>
</div>
</section>
<!-- Section 7: Erreur -->
<section v-if="showError" ref="errorSection" class="section error-section">
<h2 v-if="!calibrationStore.isManualMode">Mode manuel recommandé</h2>
<h2 v-else>Échec de la calibration</h2>
<div class="error-card">
<p class="error-message">{{ calibrationStore.error }}</p>
<div v-if="calibrationStore.isManualMode" class="manual-error-actions">
<button @click="backToCalibration" class="btn-manual-primary">
Modifier la calibration
</button>
<button @click="restart" class="btn-restart-small">
Recommencer
</button>
</div>
<div v-else class="auto-error-actions">
<button @click="goToManual" class="btn-manual-primary">
Passer en mode manuel
</button>
<button @click="restart" class="btn-restart-small">
Ou essayer une autre image
</button>
</div>
</div>
</section>
<!-- Section 8: Résultats -->
<section v-if="showResults" ref="resultsSection" class="section results-section">
<!-- Succès -->
<div v-if="calibrationStore.results?.status === 'success'" class="results-container">
<div class="result-status">
<div class="status-success">
<h2>Analyse réussie</h2>
</div>
</div>
<div class="result-message">
<p>{{ calibrationStore.results?.message || 'Paramètres de caméra extraits avec succès' }}</p>
</div>
<div class="result-actions">
<button @click="exportResults()" class="btn-primary">
Télécharger les résultats
</button>
<button v-if="calibrationStore.isManualMode" @click="backToCalibration" class="btn-secondary">
Modifier la calibration
</button>
<button @click="restart" class="btn-tertiary">
Nouvelle analyse
</button>
</div>
<!-- Graphique 3D pour les images et vidéos statiques avec calibration réussie -->
<div v-if="uploadStore.isImage || uploadStore.isStaticVideo" class="result-chart">
<h3>Terrain de Football - Vue 3D</h3>
<PlotlyChart
data-type="football-field"
:height="550"
:keypoints-data="calibrationStore.results?.detections?.keypoints || []"
:camera-params="calibrationStore.results?.camera_parameters?.cam_params"
:custom-layout="{
title: {
text: 'Visualisation 3D du terrain de football',
font: { size: 16, color: 'white' }
},
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)'
}"
@data-loaded="onChartDataLoaded"
@error="onChartError"
/>
</div>
<details class="result-details">
<summary>Données complètes</summary>
<pre class="result-data">{{ JSON.stringify(calibrationStore.results, null, 2) }}</pre>
</details>
</div>
<!-- Échec en mode manuel -->
<div v-else-if="calibrationStore.isManualMode" class="error-card-simple">
<h2>Calibration échouée</h2>
<p class="error-message">Les lignes définies ne permettent pas de calculer les paramètres de caméra.</p>
<div class="error-actions">
<button
@click="backToCalibration"
class="btn-primary"
title="Modifiez vos lignes de calibration : ajoutez plus de points, utilisez des lignes variées (droites, cercles), répartissez-les sur l'image"
>
Ajuster la calibration
</button>
<button
@click="restart"
class="btn-secondary"
title="Essayez avec une image de meilleure qualité ou un angle de vue différent"
>
Changer d'image
</button>
</div>
</div>
<!-- Échec en mode auto -->
<div v-else class="results-container">
<div class="result-status">
<div class="status-error">
<h2>Analyse échouée</h2>
</div>
</div>
<div class="result-message">
<p>L'analyse automatique n'a pas pu extraire les paramètres de caméra.</p>
</div>
<div class="result-actions">
<button @click="goToManual" class="btn-secondary">
Essayer en mode manuel
</button>
<button @click="restart" class="btn-tertiary">
Nouvelle analyse
</button>
</div>
<details class="result-details">
<summary>Données complètes</summary>
<pre class="result-data">{{ JSON.stringify(calibrationStore.results, null, 2) }}</pre>
</details>
</div>
</section>
</div>
</template>
<style scoped>
.btn-back-home {
position: fixed;
top: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.1);
color: #888;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 10px;
cursor: pointer;
transition: all 0.3s ease;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.btn-back-home:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--color-primary);
border-color: var(--color-primary);
transform: scale(1.05);
}
.btn-back-home svg {
transition: all 0.3s ease;
}
.home-container {
margin: 0 auto;
background: var(--color-secondary);
min-height: 100vh;
}
.section {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
/* Section Mode */
.hero h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1rem;
color: white;
letter-spacing: -0.025em;
position: relative;
}
.hero h1::after {
content: '';
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 4px;
background: var(--color-primary);
border-radius: 2px;
}
.hero-subtitle {
font-size: 1.1rem;
color: #b0b0b0;
margin-bottom: 4rem;
max-width: 600px;
line-height: 1.6;
font-weight: 500;
}
.mode-selection h2 {
font-size: 1.5rem;
margin-bottom: 3rem;
color: white;
font-weight: 600;
}
.mode-cards, .video-type-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem;
width: 100%;
max-width: 800px;
}
.mode-card, .type-card {
background: var(--color-secondary-soft);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid #333;
position: relative;
overflow: hidden;
}
.mode-card::before, .type-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--color-primary);
transform: translateY(-4px);
transition: transform 0.3s ease;
}
.mode-card:hover::before, .type-card:hover::before {
transform: translateY(0);
}
.mode-card:hover, .type-card:hover {
transform: translateY(-8px);
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
border-color: var(--color-primary);
background: #2a2a2a;
}
.mode-card.selected, .type-card.selected {
border-color: var(--color-primary);
background: #2a2a2a;
transform: translateY(-4px);
box-shadow: 0 6px 25px rgba(255, 255, 255, 0.3);
}
.mode-card.selected::before, .type-card.selected::before {
transform: translateY(0);
}
.mode-card h3, .type-card h3 {
font-size: 1.25rem;
font-weight: 700;
color: white;
margin-bottom: 1rem;
}
.mode-card p, .type-card p {
color: #b0b0b0;
margin-bottom: 1.5rem;
line-height: 1.5;
font-weight: 500;
}
.mode-card ul, .type-card ul {
list-style: none;
padding: 0;
margin: 0;
}
.mode-card li, .type-card li {
padding: 0.5rem 0;
color: #d0d0d0;
font-size: 0.9rem;
position: relative;
padding-left: 1.5rem;
font-weight: 500;
}
.mode-card li::before, .type-card li::before {
content: '●';
color: var(--color-primary);
font-weight: bold;
position: absolute;
left: 0;
}
/* Section Upload */
.upload-section h2, .video-type-section h2, .parameters-section h2, .processing-section h2, .results-section h2 {
font-size: 1.5rem;
margin-bottom: 3rem;
color: white;
font-weight: 600;
}
/* Grid Upload */
.upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
width: 100%;
max-width: 1200px;
align-items: start;
padding: 2rem;
}
.upload-left, .upload-right {
display: flex;
flex-direction: column;
height: 100%;
}
.upload-right {
justify-content: center;
align-items: center;
}
.upload-title {
font-size: 1.25rem;
font-weight: 600;
color: white;
margin-bottom: 2rem;
text-align: center;
}
.drop-zone {
border: 3px dashed #555;
border-radius: 12px;
padding: 3rem;
transition: all 0.3s ease;
background: var(--color-secondary-soft);
width: 100%;
max-width: 600px;
position: relative;
}
.drop-zone.active {
border-color: var(--color-primary);
background: #2a2a2a;
box-shadow: 0 4px 20px rgba(217, 255, 4, 0.2);
}
.drop-zone.has-file {
border-color: var(--color-primary);
border-style: solid;
background: #2a2a2a;
}
.upload-icon {
font-size: 3rem;
color: #888;
margin-bottom: 1rem;
font-weight: 300;
}
.drop-content h4 {
font-size: 1.25rem;
color: white;
margin-bottom: 1rem;
font-weight: 600;
}
/* Image de test */
.test-image-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.test-image-card.single {
background: var(--color-secondary-soft);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
position: relative;
width: 100%;
height: 100%;
display: flex;
}
.test-image-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.test-image-card.selected {
border-color: var(--color-primary);
box-shadow: 0 4px 20px rgba(217, 255, 4, 0.3);
}
.test-image-card.selected::after {
content: '✓';
position: absolute;
top: 8px;
right: 8px;
background: var(--color-primary);
color: var(--color-secondary);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.8rem;
}
.test-image-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.test-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.3s ease;
backdrop-filter: blur(2px);
}
.test-image-card.single:hover .test-image-overlay {
opacity: 0;
}
.overlay-content {
text-align: center;
color: white;
}
.overlay-content p {
font-size: 0.9rem;
margin: 0;
color: #e0e0e0;
font-weight: 500;
}
.or-text {
color: #888;
margin: 1.5rem 0;
font-size: 0.9rem;
font-weight: 500;
}
.file-input-label {
display: inline-block;
background: var(--color-primary);
color: var(--color-secondary);
padding: 0.875rem 2rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 700;
font-size: 0.9rem;
position: relative;
overflow: hidden;
}
.file-input-label::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transition: left 0.5s ease;
}
.file-input-label:hover::before {
left: 100%;
}
.file-input-label:hover {
background: white;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(255, 255, 255, 0.4);
}
.format-info {
color: #888;
font-size: 0.85rem;
margin-top: 1.5rem;
font-weight: 500;
}
.file-preview {
text-align: center;
}
.file-info h4 {
color: var(--color-primary);
margin-bottom: 1.5rem;
font-weight: 600;
}
.file-name {
font-weight: 700;
color: white;
margin-bottom: 1rem;
}
.file-details {
color: #b0b0b0;
font-size: 0.9rem;
margin-bottom: 2rem;
font-weight: 500;
}
.file-details span {
margin-right: 1rem;
}
.preview-media {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
margin: 2rem 0;
/* border: 2px solid var(--color-primary); */
}
/* Section Paramètres */
.parameters-form {
background: var(--color-secondary-soft);
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
width: 100%;
max-width: 500px;
text-align: left;
border: 1px solid #333;
}
.param-group {
margin-bottom: 2rem;
}
.param-group label {
display: block;
margin-bottom: 0.75rem;
font-weight: 600;
color: white;
font-size: 0.9rem;
}
.param-group input[type="range"] {
width: 100%;
height: 6px;
background: #333;
border-radius: 3px;
outline: none;
margin: 0.5rem 0;
-webkit-appearance: none;
}
.param-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: 3px solid var(--color-secondary);
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.param-group input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: 3px solid var(--color-secondary);
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.param-value {
font-weight: 700;
float: right;
background: var(--color-primary);
color: var(--color-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.launch-btn {
width: 100%;
margin-top: 1rem;
font-size: 1rem;
padding: 1rem;
font-weight: 700;
}
/* Section Erreur */
.error-section h2 {
font-size: 1.5rem;
margin-bottom: 3rem;
color: white;
font-weight: 600;
}
.error-card {
background: var(--color-secondary-soft);
border-radius: 12px;
padding: 3rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
width: 100%;
max-width: 400px;
border: 1px solid #333;
text-align: center;
display: flex;
flex-direction: column;
gap: 2rem;
}
.error-message {
color: #b0b0b0;
font-size: 0.95rem;
font-weight: 500;
margin: 0;
}
.btn-manual-primary {
background: var(--color-primary);
color: var(--color-secondary);
border: none;
padding: 1rem 2rem;
border-radius: 8px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-manual-primary:hover {
background: var(--color-primary-soft);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4);
}
.btn-restart-small {
background: transparent;
color: #888;
border: none;
padding: 0.5rem;
font-size: 0.85rem;
cursor: pointer;
transition: color 0.3s ease;
text-decoration: underline;
}
.btn-restart-small:hover {
color: white;
}
/* Section Traitement */
.processing-card {
background: var(--color-secondary-soft);
border-radius: 12px;
padding: 3rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
width: 100%;
max-width: 500px;
border: 1px solid #333;
}
.processing-spinner {
width: 48px;
height: 48px;
border: 4px solid #333;
border-top: 4px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 2rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.processing-card h3 {
font-size: 1.1rem;
color: white;
margin-bottom: 2rem;
font-weight: 600;
}
.progress-container {
margin: 2rem 0;
}
.progress-bar {
width: 100%;
height: 10px;
background: #333;
border-radius: 5px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-soft));
transition: width 0.3s ease;
border-radius: 5px;
}
.progress-text {
font-weight: 700;
color: white;
font-size: 0.9rem;
}
.processing-info {
margin-top: 2rem;
text-align: left;
}
.info-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.info-item .label {
color: #b0b0b0;
font-weight: 500;
}
.info-item .value {
color: white;
font-weight: 600;
}
/* Section Résultats */
.results-summary {
background: var(--color-secondary-soft);
border-radius: 12px;
margin-bottom: 3rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
width: 100%;
max-width: 600px;
border: 1px solid #333;
}
/* Section Résultats */
.results-container {
max-width: 800px;
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
.result-status h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.status-success h2 {
color: var(--color-primary);
}
.status-error h2 {
color: #dc3545;
}
.result-message {
background: var(--color-secondary-soft);
border-radius: 8px;
padding: 1.5rem;
border-left: 4px solid var(--color-primary);
}
.result-message p {
margin: 0;
color: #e0e0e0;
line-height: 1.5;
font-size: 0.95rem;
}
.result-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn-primary, .btn-secondary, .btn-tertiary {
padding: 0.875rem 1.5rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s ease;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-secondary);
}
.btn-primary:hover {
background: var(--color-primary-soft);
transform: translateY(-1px);
}
.btn-secondary {
background: #555;
color: white;
border: 1px solid #666;
}
.btn-secondary:hover {
background: #666;
transform: translateY(-1px);
}
.btn-tertiary {
background: transparent;
color: #888;
border: 1px solid #555;
}
.btn-tertiary:hover {
color: white;
border-color: #777;
background: #333;
}
.result-details {
margin-top: 1rem;
}
.result-details summary {
color: #888;
font-size: 0.9rem;
cursor: pointer;
padding: 0.5rem 0;
transition: color 0.3s ease;
}
.result-details summary:hover {
color: white;
}
.result-details[open] summary {
color: var(--color-primary);
margin-bottom: 1rem;
}
.result-data {
background: #0d1117;
color: #e6edf3;
padding: 1.5rem;
border-radius: 8px;
font-family: 'Monaco', 'Consolas', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
border: 1px solid #30363d;
max-height: 400px;
overflow-y: auto;
text-align: left;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
/* Syntax highlighting simulation */
color: #c9d1d9;
}
.result-details {
position: relative;
}
.confidence-score {
font-size: 1rem;
color: white;
font-weight: 600;
}
.quick-info {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
}
.quick-info .info-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.quick-info .info-item span:first-child {
color: #b0b0b0;
font-weight: 500;
}
.quick-info .info-item span:last-child {
color: white;
font-weight: 600;
font-family: monospace;
}
/* Error Card simple pour mode manuel */
.error-card-simple {
max-width: 500px;
width: 100%;
text-align: center;
}
.error-card-simple h2 {
color: #dc3545;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
.error-card-simple .error-message {
color: #b0b0b0;
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 300px;
margin: 0 auto;
}
.manual-error-actions, .auto-error-actions {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 300px;
margin: 0 auto;
}
.btn-manual-primary {
background: var(--color-primary);
color: var(--color-secondary);
border: none;
padding: 1rem 2rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-manual-primary:hover {
background: var(--color-primary-soft);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4);
}
.btn-restart-small {
background: transparent;
color: #888;
border: 1px solid #555;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-restart-small:hover {
color: white;
border-color: #777;
background: #333;
}
/* Boutons */
.btn-primary, .btn-secondary, .btn-export {
border: none;
padding: 0.875rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
position: relative;
overflow: hidden;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-secondary);
}
.btn-primary::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
transition: left 0.5s ease;
}
.btn-primary:hover::before {
left: 100%;
}
.btn-primary:hover {
background: var(--color-primary-soft);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4);
}
.btn-secondary {
background: #555;
color: white;
border: 1px solid #666;
}
.btn-secondary:hover {
background: #666;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(255, 255, 255, 0.1);
}
.btn-export {
background: var(--color-primary);
color: var(--color-secondary);
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 700;
}
.btn-export:hover {
background: var(--color-primary-soft);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4);
}
/* Section manuelle */
.manual-section {
height: 100svh;
min-height: 100svh;
padding: 0;
}
.manual-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
.manual-content {
flex: 1;
display: flex;
gap: 15px;
padding: 15px;
overflow: hidden;
height: 100%;
}
.manual-section .calibration-container {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.manual-section .field-container {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.manual-section .field-container :deep(svg) {
width: 100%;
height: 100%;
object-fit: contain;
margin-top: -10px;
}
.manual-section .calibration-container :deep(.video-frame-container) {
height: 100%;
}
.manual-section .calibration-container :deep(.video-frame) {
height: calc(100% - 80px);
}
/* Section graphique dans les résultats */
.result-chart {
width: 100%;
max-width: 1000px;
margin: 2rem 0;
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.result-chart h3 {
font-size: 1.25rem;
color: white;
margin-bottom: 1rem;
text-align: center;
font-weight: 600;
}
/* Responsive */
@media (max-width: 768px) {
.home-container {
padding: 0 1rem;
}
.section {
min-height: auto;
}
.mode-cards, .video-type-cards {
grid-template-columns: 1fr;
}
.main-actions {
grid-template-columns: 1fr;
}
.hero h1 {
font-size: 2.5rem;
}
.manual-content {
flex-direction: column;
}
.result-chart {
padding: 1rem;
margin: 1rem 0;
}
}
</style>