Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="plotly-chart"> | |
| <div class="chart-header"> | |
| <h3>{{ title }}</h3> | |
| <button v-if="loading" class="refresh-btn" disabled> | |
| Chargement... | |
| </button> | |
| </div> | |
| <div | |
| ref="plotlyDiv" | |
| :id="chartId" | |
| class="chart-container" | |
| :class="{ loading: loading }" | |
| > | |
| <div v-if="loading" class="loading-spinner"> | |
| <div class="spinner"></div> | |
| <p>Chargement des données...</p> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue' | |
| import Plotly from 'plotly.js-dist' | |
| import { csvParse } from 'd3-dsv' | |
| export default { | |
| name: 'PlotlyChart', | |
| props: { | |
| title: { | |
| type: String, | |
| default: '' | |
| }, | |
| csvUrl: { | |
| type: String, | |
| default: '' | |
| }, | |
| dataType: { | |
| type: String, | |
| default: 'csv', // 'csv' ou 'football-field' | |
| validator: (value) => ['csv', 'football-field'].includes(value) | |
| }, | |
| chartId: { | |
| type: String, | |
| default: () => `` | |
| }, | |
| height: { | |
| type: [String, Number], | |
| default: 500 | |
| }, | |
| customLayout: { | |
| type: Object, | |
| default: () => ({}) | |
| }, | |
| keypointsData: { | |
| type: Array, | |
| default: () => [] | |
| }, | |
| cameraParams: { | |
| type: Object, | |
| default: () => null | |
| } | |
| }, | |
| emits: ['data-loaded', 'error'], | |
| setup(props, { emit }) { | |
| const plotlyDiv = ref(null) | |
| const loading = ref(false) | |
| const error = ref(null) | |
| const chartData = ref([]) | |
| // Fonction pour décompresser les données du CSV | |
| const unpack = (rows, key) => { | |
| return rows.map(row => row[key]) | |
| } | |
| // Fonction pour charger les données depuis le CSV | |
| const loadData = async () => { | |
| loading.value = true | |
| error.value = null | |
| try { | |
| if (props.dataType === 'football-field') { | |
| // Générer les données du terrain de football | |
| await generateFootballFieldData() | |
| } else { | |
| // Charger depuis CSV (comportement original) | |
| await loadCsvData() | |
| } | |
| await renderChart() | |
| emit('data-loaded', { traces: chartData.value }) | |
| } catch (err) { | |
| console.error('Erreur lors du chargement des données:', err) | |
| error.value = err.message | |
| emit('error', err) | |
| } finally { | |
| loading.value = false | |
| } | |
| } | |
| // Fonction pour charger les données CSV (ancien comportement) | |
| const loadCsvData = async () => { | |
| const response = await fetch(props.csvUrl) | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`) | |
| } | |
| const csvText = await response.text() | |
| const rows = csvParse(csvText) | |
| if (!rows || rows.length === 0) { | |
| throw new Error('Aucune donnée trouvée dans le CSV') | |
| } | |
| // Création des traces comme dans votre exemple | |
| const trace1 = { | |
| x: unpack(rows, 'x1'), | |
| y: unpack(rows, 'y1'), | |
| z: unpack(rows, 'z1'), | |
| mode: 'markers', | |
| marker: { | |
| size: 12, | |
| line: { | |
| color: 'rgba(217, 217, 217, 0.14)', | |
| width: 0.5 | |
| }, | |
| opacity: 0.8 | |
| }, | |
| type: 'scatter3d', | |
| name: 'Série 1' | |
| } | |
| const trace2 = { | |
| x: unpack(rows, 'x2'), | |
| y: unpack(rows, 'y2'), | |
| z: unpack(rows, 'z2'), | |
| mode: 'markers', | |
| marker: { | |
| color: 'rgb(127, 127, 127)', | |
| size: 12, | |
| symbol: 'circle', | |
| line: { | |
| color: 'rgb(204, 204, 204)', | |
| width: 1 | |
| }, | |
| opacity: 0.8 | |
| }, | |
| type: 'scatter3d', | |
| name: 'Série 2' | |
| } | |
| chartData.value = [trace1, trace2] | |
| } | |
| // Fonction pour générer les données du terrain de football | |
| const generateFootballFieldData = async () => { | |
| const fieldLength = 105 // mètres | |
| const fieldWidth = 68 // mètres | |
| const traces = [] | |
| // 1. Contour principal du terrain | |
| const fieldCorners = { | |
| x: [-fieldLength/2, fieldLength/2, fieldLength/2, -fieldLength/2, -fieldLength/2], | |
| y: [-fieldWidth/2, -fieldWidth/2, fieldWidth/2, fieldWidth/2, -fieldWidth/2], | |
| z: [0, 0, 0, 0, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: '#00FF00', width: 6 }, | |
| name: 'Terrain principal', | |
| showlegend: true | |
| } | |
| traces.push(fieldCorners) | |
| // 2. Ligne médiane | |
| const midline = { | |
| x: [0, 0], | |
| y: [-fieldWidth/2, fieldWidth/2], | |
| z: [0, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: 'bleu', width: 4 }, | |
| name: 'Ligne médiane', | |
| showlegend: true | |
| } | |
| traces.push(midline) | |
| // 3. Surface de réparation gauche | |
| const leftPenaltyArea = { | |
| x: [-fieldLength/2, -fieldLength/2+16.5, -fieldLength/2+16.5, -fieldLength/2, -fieldLength/2], | |
| y: [-20.16, -20.16, 20.16, 20.16, -20.16], | |
| z: [0, 0, 0, 0, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: '#FF6B6B', width: 5 }, | |
| name: 'Surface de réparation', | |
| showlegend: true | |
| } | |
| traces.push(leftPenaltyArea) | |
| // 4. Surface de réparation droite | |
| const rightPenaltyArea = { | |
| x: [fieldLength/2, fieldLength/2-16.5, fieldLength/2-16.5, fieldLength/2, fieldLength/2], | |
| y: [-20.16, -20.16, 20.16, 20.16, -20.16], | |
| z: [0, 0, 0, 0, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: '#FF6B6B', width: 5 }, | |
| name: 'Surface de réparation droite', | |
| showlegend: false // Eviter la duplication dans la légende | |
| } | |
| traces.push(rightPenaltyArea) | |
| // 5. Surface de but gauche (6 yards) | |
| const leftGoalArea = { | |
| x: [-fieldLength/2, -fieldLength/2+5.5, -fieldLength/2+5.5, -fieldLength/2, -fieldLength/2], | |
| y: [-9.16, -9.16, 9.16, 9.16, -9.16], | |
| z: [0, 0, 0, 0, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: '#4ECDC4', width: 4 }, | |
| name: 'Surface de but', | |
| showlegend: true | |
| } | |
| traces.push(leftGoalArea) | |
| // 6. Surface de but droite | |
| const rightGoalArea = { | |
| x: [fieldLength/2, fieldLength/2-5.5, fieldLength/2-5.5, fieldLength/2, fieldLength/2], | |
| y: [-9.16, -9.16, 9.16, 9.16, -9.16], | |
| z: [0, 0, 0, 0, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: '#4ECDC4', width: 4 }, | |
| showlegend: false | |
| } | |
| traces.push(rightGoalArea) | |
| // 7. Cercle central | |
| const circlePoints = 50 | |
| const radius = 9.15 | |
| const theta = Array.from({length: circlePoints}, (_, i) => (i / (circlePoints - 1)) * 2 * Math.PI) | |
| const centerCircle = { | |
| x: theta.map(t => radius * Math.cos(t)), | |
| y: theta.map(t => radius * Math.sin(t)), | |
| z: theta.map(() => 0), | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { color: '#FFE66D', width: 4 }, | |
| name: 'Cercle central', | |
| showlegend: true | |
| } | |
| traces.push(centerCircle) | |
| // 8. Points de penalty | |
| const penaltySpots = { | |
| x: [-fieldLength/2 + 11, fieldLength/2 - 11], | |
| y: [0, 0], | |
| z: [0, 0], | |
| mode: 'markers', | |
| type: 'scatter3d', | |
| marker: { | |
| color: '#FFFFFF', | |
| size: 8, | |
| symbol: 'circle' | |
| }, | |
| name: 'Points de penalty', | |
| showlegend: true | |
| } | |
| traces.push(penaltySpots) | |
| // 9. Point central | |
| const centerSpot = { | |
| x: [0], | |
| y: [0], | |
| z: [0], | |
| mode: 'markers', | |
| type: 'scatter3d', | |
| marker: { | |
| color: '#FFFFFF', | |
| size: 6, | |
| symbol: 'circle' | |
| }, | |
| name: 'Point central', | |
| showlegend: true | |
| } | |
| traces.push(centerSpot) | |
| // 10. Keypoints détectés (si disponibles) | |
| if (props.keypointsData && props.keypointsData.length > 0) { | |
| const keypointsTrace = { | |
| x: props.keypointsData.map(kp => kp.world_coords?.x || 0), | |
| y: props.keypointsData.map(kp => kp.world_coords?.y || 0), | |
| z: props.keypointsData.map(() => 0.5), // Légèrement au-dessus du terrain | |
| mode: 'markers+text', | |
| type: 'scatter3d', | |
| marker: { | |
| color: '#FF1744', | |
| size: 6, | |
| symbol: 'circle', | |
| line: { | |
| color: '#FFFFFF', | |
| width: 2 | |
| } | |
| }, | |
| text: props.keypointsData.map(kp => `KP ${kp.id}`), | |
| textposition: 'top center', | |
| textfont: { | |
| color: 'black', | |
| size: 8, | |
| family: 'Arial, sans-serif' | |
| }, | |
| name: 'Keypoints détectés', | |
| showlegend: false, | |
| hovertemplate: | |
| '<b>Keypoint %{text}</b><br>' + | |
| 'X: %{x:.1f}m<br>' + | |
| 'Y: %{y:.1f}m<br>' + | |
| '<extra></extra>' | |
| } | |
| traces.push(keypointsTrace) | |
| } | |
| // 11. Position de la caméra (si disponible) | |
| if (props.cameraParams?.position_meters) { | |
| const [camX, camY, camZ] = props.cameraParams.position_meters | |
| const cameraTrace = { | |
| x: [camX], | |
| y: [camY], | |
| z: [camZ], | |
| mode: 'markers+text', | |
| type: 'scatter3d', | |
| marker: { | |
| color: '#2196F3', | |
| size: 15, | |
| symbol: 'square', | |
| line: { | |
| color: '#FFFFFF', | |
| width: 3 | |
| } | |
| }, | |
| text: ['📷 Caméra'], | |
| textposition: 'top center', | |
| textfont: { | |
| color: '#2196F3', | |
| size: 12, | |
| family: 'Arial, sans-serif' | |
| }, | |
| name: 'Position caméra', | |
| showlegend: false, | |
| hovertemplate: | |
| '<b>📷 Position de la caméra</b><br>' + | |
| 'X: %{x:.2f}m<br>' + | |
| 'Y: %{y:.2f}m<br>' + | |
| 'Z: %{z:.2f}m<br>' + | |
| '<extra></extra>' | |
| } | |
| traces.push(cameraTrace) | |
| // 12. Ligne de vue de la caméra vers le centre du terrain (optionnel) | |
| const sightLineTrace = { | |
| x: [camX, 0], | |
| y: [camY, 0], | |
| z: [camZ, 0], | |
| mode: 'lines', | |
| type: 'scatter3d', | |
| line: { | |
| color: '#2196F3', | |
| width: 2, | |
| dash: 'dot' | |
| }, | |
| name: 'Ligne de vue', | |
| showlegend: false, | |
| hoverinfo: 'skip' | |
| } | |
| traces.push(sightLineTrace) | |
| } | |
| chartData.value = traces | |
| } | |
| // Fonction pour rendre le graphique | |
| const renderChart = async () => { | |
| if (!plotlyDiv.value || chartData.value.length === 0) return | |
| let defaultLayout = { | |
| margin: { | |
| l: 0, | |
| r: 0, | |
| b: 0, | |
| t: 0 | |
| }, | |
| height: typeof props.height === 'number' ? props.height : parseInt(props.height), | |
| scene: { | |
| xaxis: { title: 'X Axis' }, | |
| yaxis: { title: 'Y Axis' }, | |
| zaxis: { title: 'Z Axis' } | |
| } | |
| } | |
| // Layout spécifique pour le terrain de football | |
| if (props.dataType === 'football-field') { | |
| const shift_l = 10; | |
| const shift_w = 40; | |
| // Ranges de base | |
| let baseLength = 52.5 + shift_l; // range X: [-baseLength, baseLength] | |
| let baseWidth = 34 + shift_w; // range Y: [-baseWidth, baseWidth] | |
| let baseHeight = 35; // range Z: [-baseHeight, baseHeight] | |
| // Position de la caméra | |
| const camX = props.cameraParams?.position_meters?.[0] || 0; | |
| const camY = props.cameraParams?.position_meters?.[1] || 0; | |
| const camZ = props.cameraParams?.position_meters?.[2] || 0; | |
| // Vérifier si la caméra dépasse les ranges et ajuster si nécessaire | |
| const maxCamX = Math.abs(camX); | |
| const maxCamY = Math.abs(camY); | |
| const maxCamZ = Math.abs(camZ); | |
| // Ratios actuels pour conserver les proportions | |
| const ratioXY = baseLength / baseWidth; // ratio X/Y | |
| const ratioXZ = baseLength / baseHeight; // ratio X/Z | |
| const ratioYZ = baseWidth / baseHeight; // ratio Y/Z | |
| // Ajuster les ranges si la caméra dépasse | |
| if (maxCamX > baseLength) { | |
| baseLength = maxCamX + 10; // marge de 10m | |
| baseWidth = baseLength / ratioXY; // conserver ratio X/Y | |
| baseHeight = baseLength / ratioXZ; // conserver ratio X/Z | |
| } | |
| if (maxCamY > baseWidth) { | |
| baseWidth = maxCamY + 10; // marge de 10m | |
| baseLength = baseWidth * ratioXY; // conserver ratio X/Y | |
| baseHeight = baseWidth / ratioYZ; // conserver ratio Y/Z | |
| } | |
| if (maxCamZ > baseHeight) { | |
| baseHeight = maxCamZ + 10; // marge de 10m | |
| baseLength = baseHeight * ratioXZ; // conserver ratio X/Z | |
| baseWidth = baseHeight * ratioYZ; // conserver ratio Y/Z | |
| } | |
| // Valeurs finales | |
| const length = baseLength; | |
| const witdh = baseWidth; | |
| const height = baseHeight; | |
| defaultLayout.scene = { | |
| xaxis: { | |
| title: '', | |
| range: [-length, length], | |
| showgrid: false, | |
| showticklabels: false, | |
| showline: false, | |
| zeroline: false, | |
| dtick: 20 | |
| }, | |
| yaxis: { | |
| title: '', | |
| range: [-witdh, witdh], | |
| showgrid: false, | |
| showticklabels: false, | |
| showline: false, | |
| zeroline: false, | |
| dtick: 20 | |
| }, | |
| zaxis: { | |
| title: '', | |
| range: [-height, height], | |
| showgrid: false, | |
| showticklabels: false, | |
| showline: false, | |
| zeroline: false, | |
| dtick: 0 | |
| }, | |
| aspectmode: 'manual', | |
| aspectratio: { x: 1., y: 1, z: 0.3 }, | |
| camera: { | |
| eye: { x: 0, y: 1, z: -0.6 }, | |
| center: { x: 0, y: 0, z: 0 }, | |
| up: { x: 0, y: -1, z: 0 } | |
| } | |
| } | |
| defaultLayout.margin.t = 10 | |
| defaultLayout.showlegend = false | |
| } | |
| const layout = { | |
| ...defaultLayout, | |
| ...props.customLayout | |
| } | |
| const config = { | |
| responsive: true, | |
| displayModeBar: true, | |
| modeBarButtonsToRemove: ['pan2d', 'lasso2d'], | |
| displaylogo: false | |
| } | |
| await nextTick() | |
| await Plotly.newPlot(plotlyDiv.value, chartData.value, layout, config) | |
| } | |
| // Fonction de nettoyage | |
| const cleanup = () => { | |
| if (plotlyDiv.value) { | |
| Plotly.purge(plotlyDiv.value) | |
| } | |
| } | |
| // Fonction pour redimensionner le graphique | |
| const resizeChart = () => { | |
| if (plotlyDiv.value) { | |
| Plotly.Plots.resize(plotlyDiv.value) | |
| } | |
| } | |
| // Watcher pour recharger quand les keypoints changent | |
| watch(() => props.keypointsData, () => { | |
| if (props.dataType === 'football-field') { | |
| loadData() | |
| } | |
| }, { deep: true }) | |
| // Watcher pour recharger quand les paramètres de la caméra changent | |
| watch(() => props.cameraParams, () => { | |
| if (props.dataType === 'football-field') { | |
| loadData() | |
| } | |
| }, { deep: true }) | |
| // Lifecycle hooks | |
| onMounted(() => { | |
| loadData() | |
| window.addEventListener('resize', resizeChart) | |
| }) | |
| onUnmounted(() => { | |
| cleanup() | |
| window.removeEventListener('resize', resizeChart) | |
| }) | |
| return { | |
| plotlyDiv, | |
| loading, | |
| error, | |
| loadData, | |
| resizeChart | |
| } | |
| } | |
| } | |
| </script> | |
| <style scoped> | |
| .plotly-chart { | |
| background: white; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); | |
| overflow: hidden; | |
| } | |
| .chart-container { | |
| position: relative; | |
| min-height: 200px; | |
| } | |
| .chart-container.loading { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .loading-spinner { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 4px solid #f3f3f3; | |
| border-top: 4px solid #007bff; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .error-message { | |
| text-align: center; | |
| padding: 2rem; | |
| color: #dc3545; | |
| } | |
| .retry-btn { | |
| background: #dc3545; | |
| color: white; | |
| border: none; | |
| padding: 0.5rem 1rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| margin-top: 1rem; | |
| } | |
| .retry-btn:hover { | |
| background: #c82333; | |
| } | |
| /* Correction de l'alignement de la modebar Plotly */ | |
| .plotly-chart :deep(.modebar) { | |
| display: flex ; | |
| align-items: center ; | |
| justify-content: flex-end ; | |
| padding: 5px ; | |
| background: none ; | |
| } | |
| .plotly-chart :deep(.modebar-group) { | |
| display: flex ; | |
| align-items: center ; | |
| margin: 0 2px ; | |
| background: none ; | |
| border: none ; | |
| } | |
| .plotly-chart :deep(.modebar-btn) { | |
| display: flex ; | |
| align-items: center ; | |
| justify-content: center ; | |
| margin: 0 1px ; | |
| background: none ; | |
| border: none ; | |
| } | |
| /* Personnalisation des icônes */ | |
| .plotly-chart :deep(.modebar-btn .icon path) { | |
| fill: black ; | |
| } | |
| .plotly-chart :deep(.modebar-btn:hover) { | |
| background: rgba(0, 0, 0, 0.1) ; | |
| } | |
| .plotly-chart :deep(.modebar-btn.active) { | |
| background: rgba(0, 0, 0, 0.2) ; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .chart-header { | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| text-align: center; | |
| } | |
| .chart-container { | |
| min-height: 300px; | |
| } | |
| } | |
| </style> |