2nzi's picture
Plotly chart
62b5f3c verified
<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 !important;
align-items: center !important;
justify-content: flex-end !important;
padding: 5px !important;
background: none !important;
}
.plotly-chart :deep(.modebar-group) {
display: flex !important;
align-items: center !important;
margin: 0 2px !important;
background: none !important;
border: none !important;
}
.plotly-chart :deep(.modebar-btn) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 1px !important;
background: none !important;
border: none !important;
}
/* Personnalisation des icônes */
.plotly-chart :deep(.modebar-btn .icon path) {
fill: black !important;
}
.plotly-chart :deep(.modebar-btn:hover) {
background: rgba(0, 0, 0, 0.1) !important;
}
.plotly-chart :deep(.modebar-btn.active) {
background: rgba(0, 0, 0, 0.2) !important;
}
/* Responsive */
@media (max-width: 768px) {
.chart-header {
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.chart-container {
min-height: 300px;
}
}
</style>