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