@golstats/gsc-possession-heatmaps 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css/fonts.css +83 -0
- package/dist/fonts/BebasNeue-Bold.otf +0 -0
- package/dist/fonts/BebasNeue-Bold.ttf +0 -0
- package/dist/fonts/BebasNeue-Bold.woff2 +0 -0
- package/dist/fonts/BebasNeue-Book.otf +0 -0
- package/dist/fonts/BebasNeue-Book.ttf +0 -0
- package/dist/fonts/BebasNeue-Book.woff2 +0 -0
- package/dist/fonts/BebasNeue-Light.otf +0 -0
- package/dist/fonts/BebasNeue-Light.ttf +0 -0
- package/dist/fonts/BebasNeue-Light.woff2 +0 -0
- package/dist/fonts/BebasNeue-Regular.otf +0 -0
- package/dist/fonts/BebasNeue-Regular.ttf +0 -0
- package/dist/fonts/BebasNeue-Regular.woff2 +0 -0
- package/dist/fonts/BebasNeue-Thin.otf +0 -0
- package/dist/fonts/BebasNeue-Thin.ttf +0 -0
- package/dist/fonts/BebasNeue-Thin.woff2 +0 -0
- package/dist/fonts/Montserrat-Black.otf +0 -0
- package/dist/fonts/Montserrat-BlackItalic.otf +0 -0
- package/dist/fonts/Montserrat-Bold.otf +0 -0
- package/dist/fonts/Montserrat-BoldItalic.otf +0 -0
- package/dist/fonts/Montserrat-ExtraBold.otf +0 -0
- package/dist/fonts/Montserrat-ExtraBoldItalic.otf +0 -0
- package/dist/fonts/Montserrat-ExtraLight.otf +0 -0
- package/dist/fonts/Montserrat-ExtraLightItalic.otf +0 -0
- package/dist/fonts/Montserrat-Italic.otf +0 -0
- package/dist/fonts/Montserrat-Light.otf +0 -0
- package/dist/fonts/Montserrat-LightItalic.otf +0 -0
- package/dist/fonts/Montserrat-Medium.otf +0 -0
- package/dist/fonts/Montserrat-MediumItalic.otf +0 -0
- package/dist/fonts/Montserrat-Regular.otf +0 -0
- package/dist/fonts/Montserrat-SemiBold.otf +0 -0
- package/dist/fonts/Montserrat-SemiBoldItalic.otf +0 -0
- package/dist/fonts/Montserrat-Thin.otf +0 -0
- package/dist/fonts/Montserrat-ThinItalic.otf +0 -0
- package/dist/fonts/Oswald-Bold.ttf +0 -0
- package/dist/fonts/Oswald-ExtraLight.ttf +0 -0
- package/dist/fonts/Oswald-Light.ttf +0 -0
- package/dist/fonts/Oswald-Medium.ttf +0 -0
- package/dist/fonts/Oswald-Regular.ttf +0 -0
- package/dist/fonts/Oswald-SemiBold.ttf +0 -0
- package/dist/fonts/Poppins-Black.otf +0 -0
- package/dist/fonts/Poppins-BlackItalic.otf +0 -0
- package/dist/fonts/Poppins-Bold.otf +0 -0
- package/dist/fonts/Poppins-BoldItalic.otf +0 -0
- package/dist/fonts/Poppins-ExtraBold.otf +0 -0
- package/dist/fonts/Poppins-ExtraBoldItalic.otf +0 -0
- package/dist/fonts/Poppins-ExtraLight.otf +0 -0
- package/dist/fonts/Poppins-ExtraLightItalic.otf +0 -0
- package/dist/fonts/Poppins-Italic.otf +0 -0
- package/dist/fonts/Poppins-Light.otf +0 -0
- package/dist/fonts/Poppins-LightItalic.otf +0 -0
- package/dist/fonts/Poppins-Medium.otf +0 -0
- package/dist/fonts/Poppins-MediumItalic.otf +0 -0
- package/dist/fonts/Poppins-Regular.otf +0 -0
- package/dist/fonts/Poppins-SemiBold.otf +0 -0
- package/dist/fonts/Poppins-SemiBoldItalic.otf +0 -0
- package/dist/fonts/Poppins-Thin.otf +0 -0
- package/dist/fonts/Poppins-ThinItalic.otf +0 -0
- package/dist/gsc-possession-heatmaps.css +1 -0
- package/dist/gsc-possession-heatmaps.es.js +109348 -0
- package/dist/gsc-possession-heatmaps.umd.js +3858 -0
- package/dist/images/cancha-horizontal.jpg +0 -0
- package/dist/images/canchaRPH.svg +30 -0
- package/package.json +74 -0
- package/src/App.vue +46 -0
- package/src/TestApp.vue +30 -0
- package/src/components/gsc-possession-heatmaps.vue +1194 -0
- package/src/index.js +3 -0
- package/src/main.js +4 -0
- package/src/test-main.js +4 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="possession-heatmaps-container"
|
|
4
|
+
:class="[sizeClass, { 'content-fits': contentFits }]"
|
|
5
|
+
ref="containerRef"
|
|
6
|
+
>
|
|
7
|
+
<div class="heatmaps-box">
|
|
8
|
+
<!-- Título con línea separadora -->
|
|
9
|
+
<div class="heatmaps-header">
|
|
10
|
+
<h2 class="heatmaps-title">Heatmap</h2>
|
|
11
|
+
<div class="title-separator"></div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="heatmaps-wrapper">
|
|
15
|
+
<!-- Flecha izquierda (visible cuando no caben todas las canchas) -->
|
|
16
|
+
<button
|
|
17
|
+
v-if="showArrows"
|
|
18
|
+
class="carousel-arrow carousel-arrow-left"
|
|
19
|
+
@click="scrollLeft"
|
|
20
|
+
:disabled="currentIndex <= 0"
|
|
21
|
+
>
|
|
22
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
23
|
+
<path d="M15 18L9 12L15 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
24
|
+
</svg>
|
|
25
|
+
</button>
|
|
26
|
+
|
|
27
|
+
<!-- Contenedor de canchas -->
|
|
28
|
+
<div class="fields-container" ref="fieldsContainer">
|
|
29
|
+
<div
|
|
30
|
+
v-for="(heatmap, index) in heatmaps"
|
|
31
|
+
:key="index"
|
|
32
|
+
class="field-item"
|
|
33
|
+
>
|
|
34
|
+
<h3 class="field-title">{{ heatmap.title }}</h3>
|
|
35
|
+
<div class="field-with-arrow">
|
|
36
|
+
<div class="field-wrapper">
|
|
37
|
+
<svg
|
|
38
|
+
class="field-svg"
|
|
39
|
+
width="222"
|
|
40
|
+
height="291"
|
|
41
|
+
viewBox="0 0 177 253"
|
|
42
|
+
fill="none"
|
|
43
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
44
|
+
>
|
|
45
|
+
<path d="M0 0H177V253H0V0Z" fill="#1D2A35" />
|
|
46
|
+
<rect x="42.606" y="0.5" width="91.7885" height="35.2369" stroke="white" />
|
|
47
|
+
<rect x="63.6587" y="0.5" width="49.6828" height="19.4244" stroke="white" />
|
|
48
|
+
<path
|
|
49
|
+
d="M107.947 35.5781C103.301 40.4025 96.2172 43.4844 88.2819 43.4844C80.3467 43.4844 73.2633 40.4025 68.6167 35.5781"
|
|
50
|
+
stroke="white"
|
|
51
|
+
/>
|
|
52
|
+
<ellipse cx="88.5002" cy="26.0249" rx="1.1696" ry="0.98828" fill="white" />
|
|
53
|
+
<rect
|
|
54
|
+
x="0.5"
|
|
55
|
+
y="-0.5"
|
|
56
|
+
width="91.7885"
|
|
57
|
+
height="35.2369"
|
|
58
|
+
transform="matrix(1 6.77658e-09 -8.01991e-09 -1 42.106 252)"
|
|
59
|
+
stroke="white"
|
|
60
|
+
/>
|
|
61
|
+
<rect
|
|
62
|
+
x="0.5"
|
|
63
|
+
y="-0.5"
|
|
64
|
+
width="49.6828"
|
|
65
|
+
height="19.4244"
|
|
66
|
+
transform="matrix(1 6.77658e-09 -8.01991e-09 -1 63.1587 252)"
|
|
67
|
+
stroke="white"
|
|
68
|
+
/>
|
|
69
|
+
<path
|
|
70
|
+
d="M107.947 217.422C103.301 212.597 96.2172 209.516 88.2819 209.516C80.3467 209.516 73.2633 212.597 68.6167 217.422"
|
|
71
|
+
stroke="white"
|
|
72
|
+
/>
|
|
73
|
+
<ellipse
|
|
74
|
+
cx="1.1696"
|
|
75
|
+
cy="0.98828"
|
|
76
|
+
rx="1.1696"
|
|
77
|
+
ry="0.98828"
|
|
78
|
+
transform="matrix(1 0 0 -1 87.3306 227.963)"
|
|
79
|
+
fill="white"
|
|
80
|
+
/>
|
|
81
|
+
<rect
|
|
82
|
+
x="0.5"
|
|
83
|
+
y="-0.5"
|
|
84
|
+
width="47"
|
|
85
|
+
height="47"
|
|
86
|
+
rx="23.5"
|
|
87
|
+
transform="matrix(1 0 0 -1 65 149.709)"
|
|
88
|
+
stroke="white"
|
|
89
|
+
/>
|
|
90
|
+
<rect
|
|
91
|
+
width="3.11894"
|
|
92
|
+
height="2.63541"
|
|
93
|
+
rx="1.31771"
|
|
94
|
+
transform="matrix(1 0 0 -1 86.9404 127.817)"
|
|
95
|
+
fill="white"
|
|
96
|
+
/>
|
|
97
|
+
<rect x="0.5" y="0.5" width="176" height="252" stroke="white" />
|
|
98
|
+
<path d="M1 126.5H176.051" stroke="white" />
|
|
99
|
+
</svg>
|
|
100
|
+
<!-- Overlay para heatmap (Plotly) -->
|
|
101
|
+
<div class="heatmaps-overlay">
|
|
102
|
+
<div
|
|
103
|
+
:ref="(el) => { if (el) heatmapRefs[index] = el }"
|
|
104
|
+
class="heatmap-plot"
|
|
105
|
+
></div>
|
|
106
|
+
</div>
|
|
107
|
+
<!-- Loading indicator -->
|
|
108
|
+
<div v-if="(isLoading || (props.type === 'team' && !heatmapsLoaded))" class="loading-overlay">
|
|
109
|
+
<div class="loading-spinner"></div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<!-- Flecha hacia arriba (dirección del juego) -->
|
|
113
|
+
<div class="field-direction-arrow">
|
|
114
|
+
<svg width="20" height="220" viewBox="0 0 20 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
115
|
+
<path d="M10 204V16M10 16L4 24M10 16L16 24" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
116
|
+
</svg>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- Flecha derecha (visible cuando no caben todas las canchas) -->
|
|
123
|
+
<button
|
|
124
|
+
v-if="showArrows"
|
|
125
|
+
class="carousel-arrow carousel-arrow-right"
|
|
126
|
+
@click="scrollRight"
|
|
127
|
+
:disabled="isMobile ? currentIndex >= heatmaps.length - 1 : currentIndex >= heatmaps.length - 2"
|
|
128
|
+
>
|
|
129
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
130
|
+
<path d="M9 18L15 12L9 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
131
|
+
</svg>
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<script setup>
|
|
139
|
+
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
140
|
+
import Plotly from 'plotly.js-dist-min'
|
|
141
|
+
|
|
142
|
+
const props = defineProps({
|
|
143
|
+
/** 'player' | 'team' */
|
|
144
|
+
type: {
|
|
145
|
+
type: String,
|
|
146
|
+
required: true,
|
|
147
|
+
validator: (value) => ['player', 'team'].includes(value)
|
|
148
|
+
},
|
|
149
|
+
/** Objeto de filtros */
|
|
150
|
+
filters: {
|
|
151
|
+
type: Object,
|
|
152
|
+
default: () => ({})
|
|
153
|
+
},
|
|
154
|
+
/** ID del equipo (numérico) */
|
|
155
|
+
team: {
|
|
156
|
+
type: Number,
|
|
157
|
+
default: undefined
|
|
158
|
+
},
|
|
159
|
+
/** ID del jugador (numérico) */
|
|
160
|
+
player: {
|
|
161
|
+
type: Number,
|
|
162
|
+
default: undefined
|
|
163
|
+
},
|
|
164
|
+
/** Temporada */
|
|
165
|
+
season: {
|
|
166
|
+
type: [Number, String],
|
|
167
|
+
default: undefined
|
|
168
|
+
},
|
|
169
|
+
/** Array de game IDs para filtrar los datos */
|
|
170
|
+
games: {
|
|
171
|
+
type: Array,
|
|
172
|
+
default: () => []
|
|
173
|
+
},
|
|
174
|
+
/** Array de match_result para filtrar los datos */
|
|
175
|
+
results: {
|
|
176
|
+
type: Array,
|
|
177
|
+
default: () => []
|
|
178
|
+
},
|
|
179
|
+
/** Array de section para filtrar los datos */
|
|
180
|
+
sections: {
|
|
181
|
+
type: Array,
|
|
182
|
+
default: () => []
|
|
183
|
+
},
|
|
184
|
+
/** Array de playing_as para filtrar los datos */
|
|
185
|
+
playingas: {
|
|
186
|
+
type: Array,
|
|
187
|
+
default: () => []
|
|
188
|
+
},
|
|
189
|
+
/** Token de autenticación para las peticiones */
|
|
190
|
+
token: {
|
|
191
|
+
type: String,
|
|
192
|
+
default: undefined
|
|
193
|
+
},
|
|
194
|
+
/** Proveedor de datos: 1/0/null = GolStats (URLs originales), 2+ = proveedor externo */
|
|
195
|
+
provider: {
|
|
196
|
+
type: Number,
|
|
197
|
+
default: 1
|
|
198
|
+
},
|
|
199
|
+
/** Usar entorno de desarrollo de APIs externas (solo aplica cuando provider >= 2) */
|
|
200
|
+
isDevApis: {
|
|
201
|
+
type: Boolean,
|
|
202
|
+
default: false
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const containerRef = ref(null)
|
|
207
|
+
const fieldsContainer = ref(null)
|
|
208
|
+
const heatmapRefs = ref([])
|
|
209
|
+
const currentIndex = ref(0)
|
|
210
|
+
const isMobile = ref(false)
|
|
211
|
+
const isTablet = ref(false)
|
|
212
|
+
const showArrows = ref(false)
|
|
213
|
+
const contentFits = ref(false)
|
|
214
|
+
const sizeClass = ref('size-desktop')
|
|
215
|
+
const isLoading = ref(false)
|
|
216
|
+
const heatmapsLoaded = ref(false)
|
|
217
|
+
const originalFormattedData = ref([]) // Guardar datos originales para re-filtrar
|
|
218
|
+
let resizeObserver = null
|
|
219
|
+
|
|
220
|
+
const heatmaps = ref([
|
|
221
|
+
{ title: 'Pases acertados' },
|
|
222
|
+
{ title: 'Pases no acertados' },
|
|
223
|
+
{ title: 'Balones recuperados' },
|
|
224
|
+
{ title: 'Balones perdidos' }
|
|
225
|
+
])
|
|
226
|
+
|
|
227
|
+
// Función para filtrar y renderizar los heatmaps
|
|
228
|
+
const filterAndRenderHeatmaps = async () => {
|
|
229
|
+
if (originalFormattedData.value.length === 0) {
|
|
230
|
+
return // No hay datos para filtrar
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Filtrar por games, results y team_id si se proporcionan
|
|
234
|
+
let filteredData = originalFormattedData.value
|
|
235
|
+
if (props.type === 'player') {
|
|
236
|
+
console.log('Datos iniciales antes de filtros:', filteredData.length)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (props.games && props.games.length > 0) {
|
|
240
|
+
filteredData = filteredData.filter(p => props.games.includes(p.game_id))
|
|
241
|
+
if (props.type === 'player') {
|
|
242
|
+
console.log('Después de filtrar por games:', filteredData.length, 'games:', props.games)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (props.results && props.results.length > 0) {
|
|
246
|
+
filteredData = filteredData.filter(p => props.results.includes(p.match_result))
|
|
247
|
+
if (props.type === 'player') {
|
|
248
|
+
console.log('Después de filtrar por results:', filteredData.length, 'results:', props.results)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (props.team !== undefined && props.team !== null && props.type !== 'player') {
|
|
252
|
+
filteredData = filteredData.filter(p => p.team_id === props.team)
|
|
253
|
+
}
|
|
254
|
+
if (props.sections && props.sections.length > 0) {
|
|
255
|
+
filteredData = filteredData.filter(p => props.sections.includes(p.section))
|
|
256
|
+
if (props.type === 'player') {
|
|
257
|
+
console.log('Después de filtrar por sections:', filteredData.length, 'sections:', props.sections)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (props.playingas && props.playingas.length > 0) {
|
|
261
|
+
filteredData = filteredData.filter(p => props.playingas.includes(p.playing_as))
|
|
262
|
+
if (props.type === 'player') {
|
|
263
|
+
console.log('Después de filtrar por playingas:', filteredData.length, 'playingas:', props.playingas)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Mostrar datos filtrados solo para jugador
|
|
268
|
+
if (props.type === 'player') {
|
|
269
|
+
console.log('Datos filtrados de jugador:', filteredData)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Si no hay datos después del filtrado, limpiar todos los heatmaps
|
|
273
|
+
if (filteredData.length === 0) {
|
|
274
|
+
await nextTick()
|
|
275
|
+
heatmapRefs.value.forEach(ref => {
|
|
276
|
+
if (ref) {
|
|
277
|
+
Plotly.purge(ref)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Generar datos del heatmap para cada cancha
|
|
284
|
+
await nextTick()
|
|
285
|
+
|
|
286
|
+
// Pases acertados → ID's 2 y 20 → Coordenada 3
|
|
287
|
+
if (heatmapRefs.value[0]) {
|
|
288
|
+
const heatmapData = generateHeatmapDataFromFormatted(filteredData, [2, 20], 3)
|
|
289
|
+
renderHeatmap(heatmapRefs.value[0], heatmapData)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Pases no acertados → ID's 3 y 21 → Coordenada 3
|
|
293
|
+
if (heatmapRefs.value[1]) {
|
|
294
|
+
const heatmapData = generateHeatmapDataFromFormatted(filteredData, [3, 21], 3)
|
|
295
|
+
renderHeatmap(heatmapRefs.value[1], heatmapData)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Balones recuperados → ID's 483 y 484 (team) o 485-492 (player) → Coordenada 1
|
|
299
|
+
if (heatmapRefs.value[2]) {
|
|
300
|
+
const balonesRecuperadosCategories = props.type === 'player'
|
|
301
|
+
? [485, 486, 487, 488, 489, 490, 491, 492]
|
|
302
|
+
: [483, 484]
|
|
303
|
+
const heatmapData = generateHeatmapDataFromFormatted(filteredData, balonesRecuperadosCategories, 1)
|
|
304
|
+
renderHeatmap(heatmapRefs.value[2], heatmapData)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Balones perdidos → ID's 494 y 495 → Coordenada 3
|
|
308
|
+
if (heatmapRefs.value[3]) {
|
|
309
|
+
const heatmapData = generateHeatmapDataFromFormatted(filteredData, [494, 495], 3)
|
|
310
|
+
renderHeatmap(heatmapRefs.value[3], heatmapData)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Construye la URL según el proveedor:
|
|
315
|
+
// provider 1, 0 o null → URL original (GolStats)
|
|
316
|
+
// provider 2 o mayor → https://apis.golstats.com/{provider}/{path} (isDevApis false)
|
|
317
|
+
// → https://dev-apis.golstats.com/{provider}/{path} (isDevApis true)
|
|
318
|
+
// En ambos casos elimina el segmento /prod o /qa del path original.
|
|
319
|
+
const buildUrl = (originalUrl) => {
|
|
320
|
+
const provider = props.provider
|
|
321
|
+
if (!provider || provider === 1) return originalUrl
|
|
322
|
+
const withoutProtocol = originalUrl.replace('https://', '')
|
|
323
|
+
const slashIndex = withoutProtocol.indexOf('/')
|
|
324
|
+
let path = withoutProtocol.slice(slashIndex)
|
|
325
|
+
path = path.replace(/^\/(prod|qa)\//, '/')
|
|
326
|
+
const base = props.isDevApis
|
|
327
|
+
? `https://dev-apis.golstats.com/${provider}`
|
|
328
|
+
: `https://apis.golstats.com/${provider}`
|
|
329
|
+
return `${base}${path}`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Método que se ejecuta cuando type es 'team'
|
|
333
|
+
const handleTeamType = async () => {
|
|
334
|
+
if (!props.season || !props.team) {
|
|
335
|
+
console.warn('Season y team son requeridos para hacer la petición')
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
isLoading.value = true
|
|
340
|
+
heatmapsLoaded.value = false
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const headers = {}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
const response = await fetch(
|
|
347
|
+
buildUrl(`https://golstats-microservices.s3-us-west-2.amazonaws.com/statsTeamBySeason-${props.season}-${props.team}.json`),
|
|
348
|
+
{
|
|
349
|
+
headers
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
const data = await response.json()
|
|
353
|
+
|
|
354
|
+
// Procesar data.posession (o data.possession)
|
|
355
|
+
if (data.data?.posession || data.data?.possession) {
|
|
356
|
+
const possessionData = data.data.posession || data.data.possession
|
|
357
|
+
const formattedData = possessionData.map((p) => ({
|
|
358
|
+
play_id: p[0],
|
|
359
|
+
game_id: p[1],
|
|
360
|
+
team_id: p[2],
|
|
361
|
+
player_id: p[3],
|
|
362
|
+
moment_of_play: p[4],
|
|
363
|
+
category_id: p[5],
|
|
364
|
+
category_type: p[6],
|
|
365
|
+
matchlapse: p[7],
|
|
366
|
+
section: p[8],
|
|
367
|
+
match_result: p[9],
|
|
368
|
+
playing_as: p[10],
|
|
369
|
+
total: p[11],
|
|
370
|
+
coordinate1_x: p[12],
|
|
371
|
+
coordinate1_y: p[13],
|
|
372
|
+
coordinate2_x: p[14],
|
|
373
|
+
coordinate2_y: p[15],
|
|
374
|
+
coordinate3_x: p[16],
|
|
375
|
+
coordinate3_y: p[17],
|
|
376
|
+
probability_1: p[18],
|
|
377
|
+
probability_2: p[19],
|
|
378
|
+
date_time_utc: p[20]
|
|
379
|
+
}))
|
|
380
|
+
|
|
381
|
+
// Obtener game_id únicos
|
|
382
|
+
const uniqueGameIds = [...new Set(formattedData.map(p => p.game_id))]
|
|
383
|
+
|
|
384
|
+
// Guardar datos originales para poder re-filtrar cuando cambie games
|
|
385
|
+
originalFormattedData.value = formattedData
|
|
386
|
+
|
|
387
|
+
// Filtrar y renderizar heatmaps
|
|
388
|
+
await filterAndRenderHeatmaps()
|
|
389
|
+
|
|
390
|
+
heatmapsLoaded.value = true
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.error('Error al obtener los datos del equipo:', error)
|
|
394
|
+
} finally {
|
|
395
|
+
isLoading.value = false
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Método que se ejecuta cuando type es 'player'
|
|
400
|
+
const handlePlayerType = async () => {
|
|
401
|
+
if (!props.season || !props.player) {
|
|
402
|
+
console.warn('Season y player son requeridos para hacer la petición')
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
isLoading.value = true
|
|
407
|
+
heatmapsLoaded.value = false
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const headers = {}
|
|
411
|
+
if (props.token) {
|
|
412
|
+
headers['Authorization'] = `${props.token}`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const response = await fetch(
|
|
416
|
+
buildUrl(`https://fn0z0dd0u3.execute-api.us-west-2.amazonaws.com/prod/statsPlayerBySeason/${props.season}/${props.player}`),
|
|
417
|
+
{
|
|
418
|
+
headers
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
const data = await response.json()
|
|
422
|
+
console.log('Data completa del jugador:', data)
|
|
423
|
+
console.log('Data.data:', data.data)
|
|
424
|
+
|
|
425
|
+
// Procesar data.posession (o data.possession)
|
|
426
|
+
// Para jugador, los índices son diferentes: play_id[0], game_id[1], player_id[2], etc.
|
|
427
|
+
if (data.data?.posession || data.data?.posession) {
|
|
428
|
+
const possessionData = data.data.posession || data.data.possession
|
|
429
|
+
console.log('Possession data raw:', possessionData)
|
|
430
|
+
console.log('Primer registro raw:', possessionData[0])
|
|
431
|
+
console.log('Cantidad de registros:', possessionData.length)
|
|
432
|
+
|
|
433
|
+
const formattedData = possessionData.map((p) => ({
|
|
434
|
+
play_id: p[0],
|
|
435
|
+
game_id: p[1],
|
|
436
|
+
player_id: p[2],
|
|
437
|
+
moment_of_play: p[3],
|
|
438
|
+
category_id: p[4],
|
|
439
|
+
category_type: p[5],
|
|
440
|
+
matchlapse: p[6],
|
|
441
|
+
section: p[7],
|
|
442
|
+
match_result: p[8],
|
|
443
|
+
playing_as: p[9],
|
|
444
|
+
total: p[10],
|
|
445
|
+
coordinate1_x: p[11],
|
|
446
|
+
coordinate1_y: p[12],
|
|
447
|
+
coordinate2_x: p[13],
|
|
448
|
+
coordinate2_y: p[14],
|
|
449
|
+
coordinate3_x: p[15],
|
|
450
|
+
coordinate3_y: p[16],
|
|
451
|
+
probability_1: p[17],
|
|
452
|
+
probability_2: p[18],
|
|
453
|
+
section_id: p[19],
|
|
454
|
+
team_id: p[20],
|
|
455
|
+
next_player: p[21],
|
|
456
|
+
start_time: p[22],
|
|
457
|
+
end_time: p[23]
|
|
458
|
+
}))
|
|
459
|
+
|
|
460
|
+
console.log('Datos formateados:', formattedData)
|
|
461
|
+
console.log('Primer registro formateado:', formattedData[0])
|
|
462
|
+
|
|
463
|
+
// Obtener game_id únicos
|
|
464
|
+
const uniqueGameIds = [...new Set(formattedData.map(p => p.game_id))]
|
|
465
|
+
console.log('Game IDs únicos:', uniqueGameIds)
|
|
466
|
+
|
|
467
|
+
// Guardar datos originales para poder re-filtrar cuando cambie games
|
|
468
|
+
originalFormattedData.value = formattedData
|
|
469
|
+
console.log('Datos guardados en originalFormattedData:', originalFormattedData.value.length)
|
|
470
|
+
|
|
471
|
+
// Filtrar y renderizar heatmaps
|
|
472
|
+
await filterAndRenderHeatmaps()
|
|
473
|
+
|
|
474
|
+
heatmapsLoaded.value = true
|
|
475
|
+
} else {
|
|
476
|
+
console.log('No se encontró data.data.posession o data.data.possession')
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
console.error('Error al obtener los datos del jugador:', error)
|
|
480
|
+
} finally {
|
|
481
|
+
isLoading.value = false
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Watch para observar cambios en type, season, team y player
|
|
486
|
+
watch([() => props.type, () => props.season, () => props.team, () => props.player], (newValues) => {
|
|
487
|
+
const [newType, newSeason, newTeam, newPlayer] = newValues || []
|
|
488
|
+
if (props.type === 'team') {
|
|
489
|
+
handleTeamType()
|
|
490
|
+
} else if (props.type === 'player') {
|
|
491
|
+
handlePlayerType()
|
|
492
|
+
}
|
|
493
|
+
}, { immediate: true })
|
|
494
|
+
|
|
495
|
+
// Watch para observar cambios en games y re-filtrar los datos
|
|
496
|
+
watch(() => props.games, (newGames, oldGames) => {
|
|
497
|
+
if ((props.type === 'team' || props.type === 'player') && originalFormattedData.value.length > 0) {
|
|
498
|
+
filterAndRenderHeatmaps()
|
|
499
|
+
}
|
|
500
|
+
}, { deep: true })
|
|
501
|
+
|
|
502
|
+
// Watch para observar cambios en results y re-filtrar los datos
|
|
503
|
+
watch(() => props.results, (newResults, oldResults) => {
|
|
504
|
+
if ((props.type === 'team' || props.type === 'player') && originalFormattedData.value.length > 0) {
|
|
505
|
+
filterAndRenderHeatmaps()
|
|
506
|
+
}
|
|
507
|
+
}, { deep: true })
|
|
508
|
+
|
|
509
|
+
// Watch para observar cambios en team y re-filtrar los datos
|
|
510
|
+
watch(() => props.team, (newTeam, oldTeam) => {
|
|
511
|
+
// Solo re-filtrar si ya tenemos datos cargados (no hacer nueva petición)
|
|
512
|
+
if ((props.type === 'team' || props.type === 'player') && originalFormattedData.value.length > 0 && newTeam !== oldTeam) {
|
|
513
|
+
filterAndRenderHeatmaps()
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
// Watch para observar cambios en sections y re-filtrar los datos
|
|
518
|
+
watch(() => props.sections, (newSections, oldSections) => {
|
|
519
|
+
if ((props.type === 'team' || props.type === 'player') && originalFormattedData.value.length > 0) {
|
|
520
|
+
filterAndRenderHeatmaps()
|
|
521
|
+
}
|
|
522
|
+
}, { deep: true })
|
|
523
|
+
|
|
524
|
+
// Watch para observar cambios en playingas y re-filtrar los datos
|
|
525
|
+
watch(() => props.playingas, (newPlayingas, oldPlayingas) => {
|
|
526
|
+
if ((props.type === 'team' || props.type === 'player') && originalFormattedData.value.length > 0) {
|
|
527
|
+
filterAndRenderHeatmaps()
|
|
528
|
+
}
|
|
529
|
+
}, { deep: true })
|
|
530
|
+
|
|
531
|
+
// Watch para observar cambios en player y re-filtrar los datos
|
|
532
|
+
watch(() => props.player, (newPlayer, oldPlayer) => {
|
|
533
|
+
// Solo re-filtrar si ya tenemos datos cargados (no hacer nueva petición)
|
|
534
|
+
if ((props.type === 'team' || props.type === 'player') && originalFormattedData.value.length > 0 && newPlayer !== oldPlayer) {
|
|
535
|
+
filterAndRenderHeatmaps()
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
const checkContainerSize = async () => {
|
|
540
|
+
if (containerRef.value) {
|
|
541
|
+
const containerWidth = containerRef.value.offsetWidth
|
|
542
|
+
|
|
543
|
+
isMobile.value = containerWidth < 600
|
|
544
|
+
isTablet.value = containerWidth >= 600 && containerWidth < 768
|
|
545
|
+
|
|
546
|
+
// Clase según tamaño del contenedor (sin media queries)
|
|
547
|
+
if (containerWidth >= 768) {
|
|
548
|
+
sizeClass.value = 'size-desktop'
|
|
549
|
+
} else if (containerWidth >= 600) {
|
|
550
|
+
sizeClass.value = 'size-tablet'
|
|
551
|
+
} else {
|
|
552
|
+
sizeClass.value = 'size-mobile'
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Verificar si las canchas caben usando nextTick para asegurar que el DOM esté actualizado
|
|
556
|
+
await nextTick()
|
|
557
|
+
if (fieldsContainer.value) {
|
|
558
|
+
const fieldsScrollWidth = fieldsContainer.value.scrollWidth
|
|
559
|
+
const fieldsClientWidth = fieldsContainer.value.clientWidth
|
|
560
|
+
|
|
561
|
+
// Mostrar flechas si el contenido no cabe (scrollWidth > clientWidth + margen de 2px) o si estamos en modo móvil/tablet
|
|
562
|
+
const needsScroll = isMobile.value || isTablet.value || fieldsScrollWidth > fieldsClientWidth + 2
|
|
563
|
+
showArrows.value = needsScroll
|
|
564
|
+
contentFits.value = !needsScroll
|
|
565
|
+
} else {
|
|
566
|
+
contentFits.value = false
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const scrollLeft = () => {
|
|
572
|
+
if (currentIndex.value > 0) {
|
|
573
|
+
currentIndex.value = isMobile.value
|
|
574
|
+
? currentIndex.value - 1
|
|
575
|
+
: Math.max(0, currentIndex.value - 2)
|
|
576
|
+
scrollToIndex()
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const scrollRight = () => {
|
|
581
|
+
const maxIndex = isMobile.value
|
|
582
|
+
? heatmaps.value.length - 1
|
|
583
|
+
: heatmaps.value.length - 2
|
|
584
|
+
if (currentIndex.value < maxIndex) {
|
|
585
|
+
currentIndex.value = isMobile.value
|
|
586
|
+
? currentIndex.value + 1
|
|
587
|
+
: currentIndex.value + 2
|
|
588
|
+
scrollToIndex()
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const scrollToIndex = () => {
|
|
593
|
+
if (fieldsContainer.value) {
|
|
594
|
+
const fieldItem = fieldsContainer.value.children[currentIndex.value]
|
|
595
|
+
if (fieldItem) {
|
|
596
|
+
fieldItem.scrollIntoView({
|
|
597
|
+
behavior: 'smooth',
|
|
598
|
+
block: 'nearest',
|
|
599
|
+
inline: isTablet.value ? 'start' : 'center'
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Generar datos de ejemplo para el heatmap (grid 18x26 aprox. campo 177x253)
|
|
606
|
+
function generateHeatmapData() {
|
|
607
|
+
const rows = 18
|
|
608
|
+
const cols = 26
|
|
609
|
+
const z = []
|
|
610
|
+
for (let i = 0; i < rows; i++) {
|
|
611
|
+
const row = []
|
|
612
|
+
for (let j = 0; j < cols; j++) {
|
|
613
|
+
row.push(Math.random() * 100)
|
|
614
|
+
}
|
|
615
|
+
z.push(row)
|
|
616
|
+
}
|
|
617
|
+
return z
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Convertir coordenadas a grid del heatmap con sombra más amplia
|
|
621
|
+
function coordinatesToHeatmapGrid(coordinates, rows = 18, cols = 26) {
|
|
622
|
+
const grid = Array(rows).fill(0).map(() => Array(cols).fill(0))
|
|
623
|
+
|
|
624
|
+
// Radio de sombra más amplio (afecta más celdas alrededor de cada punto)
|
|
625
|
+
const shadowRadius = 5 // Aumentado para sombra mucho más amplia
|
|
626
|
+
|
|
627
|
+
coordinates.forEach(({ x, y }) => {
|
|
628
|
+
// Las coordenadas están normalizadas (0-1), convertir a índices del grid
|
|
629
|
+
const colIndex = Math.floor(x * cols)
|
|
630
|
+
const rowIndex = Math.floor((1 - y) * rows) // Invertir Y porque el origen está abajo
|
|
631
|
+
|
|
632
|
+
// Aplicar sombra amplia: afectar celdas alrededor del punto
|
|
633
|
+
for (let dy = -shadowRadius; dy <= shadowRadius; dy++) {
|
|
634
|
+
for (let dx = -shadowRadius; dx <= shadowRadius; dx++) {
|
|
635
|
+
const newRowIndex = rowIndex + dy
|
|
636
|
+
const newColIndex = colIndex + dx
|
|
637
|
+
|
|
638
|
+
// Calcular peso basado en distancia (más cerca = más peso)
|
|
639
|
+
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
640
|
+
const weight = distance <= shadowRadius ? (shadowRadius + 1 - distance) / (shadowRadius + 1) : 0
|
|
641
|
+
|
|
642
|
+
if (newRowIndex >= 0 && newRowIndex < rows && newColIndex >= 0 && newColIndex < cols) {
|
|
643
|
+
grid[newRowIndex][newColIndex] += weight
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
return grid
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Generar datos del heatmap desde datos formateados según la categoría
|
|
653
|
+
function generateHeatmapDataFromFormatted(formattedData, categoryIds, coordinateType) {
|
|
654
|
+
// Filtrar por category_id
|
|
655
|
+
const filtered = formattedData.filter(p => categoryIds.includes(p.category_id))
|
|
656
|
+
|
|
657
|
+
// Si no hay datos filtrados, retornar null
|
|
658
|
+
if (filtered.length === 0) {
|
|
659
|
+
return null
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Extraer coordenadas según el tipo de los datos formateados
|
|
663
|
+
// Ajustar Y según la categoría: subir hacia arriba normalmente, bajar para Balones recuperados, subir más para Balones perdidos
|
|
664
|
+
const isBalonesRecuperados = categoryIds.includes(483) || categoryIds.includes(484) ||
|
|
665
|
+
categoryIds.includes(485) || categoryIds.includes(486) || categoryIds.includes(487) ||
|
|
666
|
+
categoryIds.includes(488) || categoryIds.includes(489) || categoryIds.includes(490) ||
|
|
667
|
+
categoryIds.includes(491) || categoryIds.includes(492)
|
|
668
|
+
const isBalonesPerdidos = categoryIds.includes(494) || categoryIds.includes(495)
|
|
669
|
+
const isPasesAcertados = categoryIds.includes(2) || categoryIds.includes(20)
|
|
670
|
+
const yOffsetUp = 0.05 // Offset para mover coordenadas hacia arriba (normal)
|
|
671
|
+
const yOffsetUpMore = 0.16 // Offset adicional para Balones perdidos (subir más)
|
|
672
|
+
const yOffsetUpPasesAcertados = 0.03 // Offset adicional para Pases acertados (subir más)
|
|
673
|
+
const yOffsetDown = 0.05 // Offset para mover coordenadas hacia abajo (Balones recuperados)
|
|
674
|
+
|
|
675
|
+
const originalCoordinates = filtered.map(p => {
|
|
676
|
+
let x, y
|
|
677
|
+
if (coordinateType === 1) {
|
|
678
|
+
x = p.coordinate1_x
|
|
679
|
+
if (isBalonesRecuperados) {
|
|
680
|
+
y = Math.min(1, p.coordinate1_y + yOffsetDown) // Aumentar Y para mover hacia abajo
|
|
681
|
+
} else if (isBalonesPerdidos) {
|
|
682
|
+
y = Math.max(0, p.coordinate1_y - yOffsetUpMore) // Reducir Y más para mover hacia arriba
|
|
683
|
+
} else if (isPasesAcertados) {
|
|
684
|
+
y = Math.max(0, p.coordinate1_y - yOffsetUpPasesAcertados) // Reducir Y más para Pases acertados
|
|
685
|
+
} else {
|
|
686
|
+
y = Math.max(0, p.coordinate1_y - yOffsetUp) // Reducir Y para mover hacia arriba
|
|
687
|
+
}
|
|
688
|
+
} else if (coordinateType === 2) {
|
|
689
|
+
x = p.coordinate2_x
|
|
690
|
+
if (isBalonesRecuperados) {
|
|
691
|
+
y = Math.min(1, p.coordinate2_y + yOffsetDown) // Aumentar Y para mover hacia abajo
|
|
692
|
+
} else if (isBalonesPerdidos) {
|
|
693
|
+
y = Math.max(0, p.coordinate2_y - yOffsetUpMore) // Reducir Y más para mover hacia arriba
|
|
694
|
+
} else if (isPasesAcertados) {
|
|
695
|
+
y = Math.max(0, p.coordinate2_y - yOffsetUpPasesAcertados) // Reducir Y más para Pases acertados
|
|
696
|
+
} else {
|
|
697
|
+
y = Math.max(0, p.coordinate2_y - yOffsetUp) // Reducir Y para mover hacia arriba
|
|
698
|
+
}
|
|
699
|
+
} else { // coordinateType === 3
|
|
700
|
+
x = p.coordinate3_x
|
|
701
|
+
if (isBalonesRecuperados) {
|
|
702
|
+
y = Math.min(1, p.coordinate3_y + yOffsetDown) // Aumentar Y para mover hacia abajo
|
|
703
|
+
} else if (isBalonesPerdidos) {
|
|
704
|
+
y = Math.max(0, p.coordinate3_y - yOffsetUpMore) // Reducir Y más para mover hacia arriba
|
|
705
|
+
} else if (isPasesAcertados) {
|
|
706
|
+
y = Math.max(0, p.coordinate3_y - yOffsetUpPasesAcertados) // Reducir Y más para Pases acertados
|
|
707
|
+
} else {
|
|
708
|
+
y = Math.max(0, p.coordinate3_y - yOffsetUp) // Reducir Y para mover hacia arriba
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return [x, y] // Retornar como array [x, y] para compatibilidad
|
|
712
|
+
}).filter(c => c[0] != null && c[1] != null && !isNaN(c[0]) && !isNaN(c[1]))
|
|
713
|
+
|
|
714
|
+
// Agregar puntos dummy cerca de los puntos reales para mejorar la visibilidad del heatmap
|
|
715
|
+
const coordinates = []
|
|
716
|
+
const radius = 0.000// Radio aumentado mucho más para sombra muy amplia
|
|
717
|
+
const dummyPointsPerRealPoint = 0 // Número de puntos dummy aumentado significativamente
|
|
718
|
+
|
|
719
|
+
originalCoordinates.forEach(([x, y]) => {
|
|
720
|
+
// Agregar el punto real
|
|
721
|
+
coordinates.push([x, y])
|
|
722
|
+
|
|
723
|
+
// Agregar puntos dummy alrededor del punto real en círculo (primer anillo)
|
|
724
|
+
for (let i = 0; i < dummyPointsPerRealPoint; i++) {
|
|
725
|
+
const angle = (Math.PI * 2 * i) / dummyPointsPerRealPoint
|
|
726
|
+
const offsetX = Math.cos(angle) * radius
|
|
727
|
+
const offsetY = Math.sin(angle) * radius
|
|
728
|
+
|
|
729
|
+
// Asegurar que los puntos dummy estén dentro del rango [0, 1]
|
|
730
|
+
const dummyX = Math.max(0, Math.min(1, x + offsetX))
|
|
731
|
+
const dummyY = Math.max(0, Math.min(1, y + offsetY))
|
|
732
|
+
|
|
733
|
+
coordinates.push([dummyX, dummyY])
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
// Convertir a formato {x, y} para coordinatesToHeatmapGrid
|
|
742
|
+
const coordinatesObj = coordinates.map(c => ({ x: c[0], y: c[1] }))
|
|
743
|
+
|
|
744
|
+
// Convertir a grid
|
|
745
|
+
const grid = coordinatesToHeatmapGrid(coordinatesObj)
|
|
746
|
+
|
|
747
|
+
// Normalizar valores para el heatmap (0-100)
|
|
748
|
+
const maxValue = Math.max(...grid.flat(), 1)
|
|
749
|
+
return grid.map(row => row.map(val => (val / maxValue) * 100))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function renderHeatmap(container, heatmapData) {
|
|
753
|
+
if (!container) return
|
|
754
|
+
|
|
755
|
+
// Si es tipo 'team' o 'player', solo renderizar si hay datos reales
|
|
756
|
+
if (props.type === 'team' || props.type === 'player') {
|
|
757
|
+
if (!heatmapData) {
|
|
758
|
+
// Limpiar el heatmap si no hay datos
|
|
759
|
+
Plotly.purge(container)
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
// Verificar si todos los valores son 0 o muy pequeños (datos vacíos)
|
|
763
|
+
const allValues = heatmapData.flat()
|
|
764
|
+
const maxValue = Math.max(...allValues)
|
|
765
|
+
if (maxValue === 0 || maxValue < 0.01) {
|
|
766
|
+
// Limpiar el heatmap si no hay datos significativos
|
|
767
|
+
Plotly.purge(container)
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const z = heatmapData || generateHeatmapData()
|
|
773
|
+
const trace = {
|
|
774
|
+
z,
|
|
775
|
+
type: 'heatmap',
|
|
776
|
+
connectgaps: true,
|
|
777
|
+
opacity: 1,
|
|
778
|
+
colorscale: [
|
|
779
|
+
[0, 'rgba(20, 50, 30, 0.07)'], // Fondo transparente
|
|
780
|
+
[0.05, 'rgba(20, 50, 30, 0.10)'], // Verde muy oscuro
|
|
781
|
+
[0.10, 'rgba(30, 70, 40, 0.20)'], // Verde oscuro
|
|
782
|
+
[0.15, 'rgba(40, 90, 50, 0.30)'], // Verde medio-oscuro
|
|
783
|
+
[0.20, 'rgba(50, 110, 60, 0.31)'], // Verde medio-oscuro claro
|
|
784
|
+
[0.25, 'rgba(60, 130, 70, 0.35)'], // Verde medio
|
|
785
|
+
[0.30, 'rgba(70, 150, 80, 0.45)'], // Verde medio-claro
|
|
786
|
+
[0.35, 'rgba(80, 170, 90, 0.50)'], // Verde claro-oscuro
|
|
787
|
+
[0.40, 'rgba(90, 190, 100, 0.55)'], // Verde claro
|
|
788
|
+
[0.45, 'rgba(100, 210, 110, 0.60)'], // Verde claro brillante
|
|
789
|
+
[0.50, 'rgba(120, 220, 100, 0.65)'], // Verde-amarillo claro
|
|
790
|
+
[0.55, 'rgba(150, 230, 90, 0.67)'], // Verde-amarillo
|
|
791
|
+
[0.60, 'rgba(180, 225, 85, 0.70)'], // Amarillo-verde
|
|
792
|
+
[0.65, 'rgba(210, 220, 80, 0.73)'], // Amarillo claro
|
|
793
|
+
[0.70, 'rgba(240, 215, 75, 0.75)'], // Amarillo
|
|
794
|
+
[0.75, 'rgba(255, 200, 70, 0.77)'], // Amarillo-naranja muy claro
|
|
795
|
+
[0.80, 'rgba(255, 185, 65, 0.80)'], // Amarillo-naranja claro
|
|
796
|
+
[0.85, 'rgba(255, 170, 60, 0.83)'], // Amarillo-naranja
|
|
797
|
+
[0.88, 'rgba(255, 150, 55, 0.85)'], // Naranja claro
|
|
798
|
+
[0.90, 'rgba(255, 140, 50, 0.87)'], // Naranja medio-claro
|
|
799
|
+
[0.92, 'rgba(255, 130, 45, 0.89)'], // Naranja medio
|
|
800
|
+
[0.94, 'rgba(255, 120, 40, 0.91)'], // Naranja medio-intenso
|
|
801
|
+
[0.96, 'rgba(255, 110, 38, 0.91)'], // Naranja intenso
|
|
802
|
+
[0.98, 'rgba(255, 95, 35, 0.92)'], // Naranja muy intenso
|
|
803
|
+
[1, 'rgba(255, 80, 30, 0.97)'] // Naranja máxim
|
|
804
|
+
],
|
|
805
|
+
showscale: false,
|
|
806
|
+
autoscale: false,
|
|
807
|
+
zsmooth: 'best',
|
|
808
|
+
showgrid: false,
|
|
809
|
+
showline: false
|
|
810
|
+
}
|
|
811
|
+
const layout = {
|
|
812
|
+
margin: { t: 0, r: 0, b: 0, l: 5 },
|
|
813
|
+
paper_bgcolor: 'rgba(0,0,0,0)',
|
|
814
|
+
plot_bgcolor: 'rgba(0,0,0,0)',
|
|
815
|
+
xaxis: { visible: false },
|
|
816
|
+
yaxis: { visible: false },
|
|
817
|
+
autosize: true
|
|
818
|
+
}
|
|
819
|
+
const config = {
|
|
820
|
+
responsive: true,
|
|
821
|
+
displayModeBar: false,
|
|
822
|
+
hovermode: false, // Desactivar hover
|
|
823
|
+
staticPlot: true // Desactivar interacciones incluyendo click
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Si ya existe un plot, actualizarlo; si no, crear uno nuevo
|
|
827
|
+
if (container.data && container.data.length > 0) {
|
|
828
|
+
Plotly.update(container, { z: [z] }, layout, config)
|
|
829
|
+
} else {
|
|
830
|
+
Plotly.newPlot(container, [trace], layout, config)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
onMounted(async () => {
|
|
835
|
+
// Esperar un momento para que el DOM se renderice completamente
|
|
836
|
+
await nextTick()
|
|
837
|
+
setTimeout(async () => {
|
|
838
|
+
await checkContainerSize()
|
|
839
|
+
}, 100)
|
|
840
|
+
|
|
841
|
+
// Renderizar heatmaps de Plotly en cada cancha solo si no es tipo 'team' o 'player'
|
|
842
|
+
// Si es tipo 'team' o 'player', los heatmaps se renderizarán cuando se carguen los datos reales
|
|
843
|
+
if (props.type !== 'team' && props.type !== 'player') {
|
|
844
|
+
await nextTick()
|
|
845
|
+
setTimeout(() => {
|
|
846
|
+
heatmapRefs.value.forEach((el) => renderHeatmap(el))
|
|
847
|
+
}, 150)
|
|
848
|
+
} else {
|
|
849
|
+
// Si es tipo 'team' o 'player', establecer loading desde el inicio
|
|
850
|
+
isLoading.value = true
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Usar ResizeObserver para detectar cambios en el tamaño del contenedor padre
|
|
854
|
+
if (containerRef.value && window.ResizeObserver) {
|
|
855
|
+
resizeObserver = new ResizeObserver(() => {
|
|
856
|
+
checkContainerSize()
|
|
857
|
+
})
|
|
858
|
+
resizeObserver.observe(containerRef.value)
|
|
859
|
+
|
|
860
|
+
// También observar el contenedor de campos para detectar cambios en su tamaño
|
|
861
|
+
await nextTick()
|
|
862
|
+
if (fieldsContainer.value) {
|
|
863
|
+
resizeObserver.observe(fieldsContainer.value)
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
// Fallback para navegadores que no soportan ResizeObserver
|
|
867
|
+
window.addEventListener('resize', checkContainerSize)
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
onUnmounted(() => {
|
|
872
|
+
heatmapRefs.value.forEach((el) => {
|
|
873
|
+
if (el && typeof Plotly.purge === 'function') {
|
|
874
|
+
Plotly.purge(el)
|
|
875
|
+
}
|
|
876
|
+
})
|
|
877
|
+
if (resizeObserver) {
|
|
878
|
+
resizeObserver.disconnect()
|
|
879
|
+
} else {
|
|
880
|
+
window.removeEventListener('resize', checkContainerSize)
|
|
881
|
+
}
|
|
882
|
+
})
|
|
883
|
+
</script>
|
|
884
|
+
|
|
885
|
+
<style>
|
|
886
|
+
@import '/css/fonts.css';
|
|
887
|
+
</style>
|
|
888
|
+
|
|
889
|
+
<style scoped>
|
|
890
|
+
.possession-heatmaps-container {
|
|
891
|
+
width: 100%;
|
|
892
|
+
height: 100%;
|
|
893
|
+
display: flex;
|
|
894
|
+
justify-content: center;
|
|
895
|
+
align-items: center;
|
|
896
|
+
box-sizing: border-box;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
.heatmaps-box {
|
|
900
|
+
width: 100%;
|
|
901
|
+
height: 100%;
|
|
902
|
+
|
|
903
|
+
padding: 10px 40px 30px 30px;
|
|
904
|
+
|
|
905
|
+
box-sizing: border-box;
|
|
906
|
+
display: flex;
|
|
907
|
+
flex-direction: column;
|
|
908
|
+
border-radius: 9px;
|
|
909
|
+
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.25);
|
|
910
|
+
background-color: rgba(38, 50, 60, 0.4);
|
|
911
|
+
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
.heatmaps-title {
|
|
917
|
+
font-family: Poppins-Medium;
|
|
918
|
+
font-size: 16px;
|
|
919
|
+
font-weight: 500;
|
|
920
|
+
font-stretch: normal;
|
|
921
|
+
font-style: normal;
|
|
922
|
+
line-height: 1.25;
|
|
923
|
+
letter-spacing: normal;
|
|
924
|
+
text-align: center;
|
|
925
|
+
color: #fff;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
.title-separator {
|
|
929
|
+
width: 100%;
|
|
930
|
+
height: 0;
|
|
931
|
+
border-bottom: 1px dotted rgba(255, 255, 255, 0.2);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
.heatmaps-wrapper {
|
|
935
|
+
position: relative;
|
|
936
|
+
width: 100%;
|
|
937
|
+
flex: 1;
|
|
938
|
+
display: flex;
|
|
939
|
+
align-items: center;
|
|
940
|
+
gap: 20px;
|
|
941
|
+
min-height: 0;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
.fields-container {
|
|
945
|
+
display: flex;
|
|
946
|
+
gap: 30px;
|
|
947
|
+
padding-left: 5px;
|
|
948
|
+
padding-right: 5px;
|
|
949
|
+
width: 100%;
|
|
950
|
+
overflow-x: auto;
|
|
951
|
+
scroll-behavior: smooth;
|
|
952
|
+
scrollbar-width: none; /* Firefox */
|
|
953
|
+
-ms-overflow-style: none; /* IE and Edge */
|
|
954
|
+
scroll-snap-type: x mandatory;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.possession-heatmaps-container.size-tablet .fields-container,
|
|
958
|
+
.possession-heatmaps-container.size-mobile .fields-container {
|
|
959
|
+
gap: 15px;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.fields-container::-webkit-scrollbar {
|
|
963
|
+
display: none; /* Chrome, Safari, Opera */
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.field-item {
|
|
967
|
+
flex: 0 0 auto;
|
|
968
|
+
display: flex;
|
|
969
|
+
flex-direction: column;
|
|
970
|
+
align-items: center;
|
|
971
|
+
scroll-snap-align: center;
|
|
972
|
+
min-width: 200px;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.field-title {
|
|
976
|
+
font-family: 'Poppins-Medium', sans-serif;
|
|
977
|
+
font-size: 14px !important;
|
|
978
|
+
font-weight: 500;
|
|
979
|
+
font-stretch: normal;
|
|
980
|
+
font-style: normal;
|
|
981
|
+
letter-spacing: 0.3px;
|
|
982
|
+
text-align: center;
|
|
983
|
+
margin-right: 26px;
|
|
984
|
+
color: rgba(255, 255, 255, 0.7);
|
|
985
|
+
text-align: center;
|
|
986
|
+
white-space: nowrap;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.field-with-arrow {
|
|
990
|
+
display: flex;
|
|
991
|
+
align-items: center;
|
|
992
|
+
gap: 4px;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.field-wrapper {
|
|
996
|
+
position: relative;
|
|
997
|
+
display: inline-block;
|
|
998
|
+
overflow: hidden;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.field-direction-arrow {
|
|
1002
|
+
display: flex;
|
|
1003
|
+
align-items: center;
|
|
1004
|
+
justify-content: center;
|
|
1005
|
+
flex-shrink: 0;
|
|
1006
|
+
color: white;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.field-svg {
|
|
1010
|
+
display: block;
|
|
1011
|
+
width: 222px;
|
|
1012
|
+
height: 291px;
|
|
1013
|
+
max-width: 222px;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.heatmaps-overlay {
|
|
1017
|
+
position: absolute;
|
|
1018
|
+
top: 2px;
|
|
1019
|
+
left: 2px;
|
|
1020
|
+
right: 2px;
|
|
1021
|
+
bottom: 2px;
|
|
1022
|
+
width: calc(100% - 4px);
|
|
1023
|
+
height: calc(100% - 4px);
|
|
1024
|
+
pointer-events: none;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
.heatmap-plot {
|
|
1028
|
+
width: 95%;
|
|
1029
|
+
height: 100%;
|
|
1030
|
+
min-height: 1px;
|
|
1031
|
+
margin-left: 3px;
|
|
1032
|
+
box-sizing: border-box;
|
|
1033
|
+
margin-top: -1px;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.loading-overlay {
|
|
1037
|
+
position: absolute;
|
|
1038
|
+
top: 0;
|
|
1039
|
+
left: 0;
|
|
1040
|
+
right: 0;
|
|
1041
|
+
bottom: 0;
|
|
1042
|
+
width: 100%;
|
|
1043
|
+
height: 100%;
|
|
1044
|
+
display: flex;
|
|
1045
|
+
align-items: center;
|
|
1046
|
+
justify-content: center;
|
|
1047
|
+
background-color: rgba(29, 42, 53, 0.95);
|
|
1048
|
+
z-index: 100;
|
|
1049
|
+
pointer-events: none;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.loading-spinner {
|
|
1053
|
+
width: 50px;
|
|
1054
|
+
height: 50px;
|
|
1055
|
+
border: 5px solid rgba(255, 255, 255, 0.3);
|
|
1056
|
+
border-top-color: #fff;
|
|
1057
|
+
border-radius: 50%;
|
|
1058
|
+
animation: spin 0.8s linear infinite;
|
|
1059
|
+
box-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
@keyframes spin {
|
|
1063
|
+
to {
|
|
1064
|
+
transform: rotate(360deg);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.carousel-arrow {
|
|
1069
|
+
position: absolute;
|
|
1070
|
+
top: 50%;
|
|
1071
|
+
transform: translateY(-50%);
|
|
1072
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1073
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
1074
|
+
border-radius: 50%;
|
|
1075
|
+
width: 40px;
|
|
1076
|
+
height: 40px;
|
|
1077
|
+
display: flex;
|
|
1078
|
+
align-items: center;
|
|
1079
|
+
justify-content: center;
|
|
1080
|
+
cursor: pointer;
|
|
1081
|
+
z-index: 10;
|
|
1082
|
+
transition: all 0.3s ease;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
.carousel-arrow:disabled {
|
|
1087
|
+
opacity: 0.3;
|
|
1088
|
+
cursor: not-allowed;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
.carousel-arrow-left {
|
|
1092
|
+
left: 0px;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
.carousel-arrow-right {
|
|
1096
|
+
right: 0px;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/* Desktop (≥768px): mostrar todas las canchas horizontalmente solo cuando caben */
|
|
1100
|
+
.possession-heatmaps-container.size-desktop.content-fits .fields-container {
|
|
1101
|
+
overflow-x: visible;
|
|
1102
|
+
scroll-snap-type: none;
|
|
1103
|
+
justify-content: center;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.possession-heatmaps-container.size-desktop .field-item {
|
|
1107
|
+
min-width: auto;
|
|
1108
|
+
flex: 1;
|
|
1109
|
+
max-width: 222px;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/* Cuando no caben en desktop, permitir scroll (no aplicar content-fits) */
|
|
1113
|
+
.possession-heatmaps-container.size-desktop:not(.content-fits) .fields-container {
|
|
1114
|
+
overflow-x: auto;
|
|
1115
|
+
scroll-snap-type: x mandatory;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
.possession-heatmaps-container.size-desktop .field-svg {
|
|
1119
|
+
width: 222px;
|
|
1120
|
+
height: 291px;
|
|
1121
|
+
max-width: 222px;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.possession-heatmaps-container.size-desktop .field-title {
|
|
1125
|
+
font-size: 18px;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/* Tablet (600px - 767px): 2 canchas visibles */
|
|
1129
|
+
.possession-heatmaps-container.size-tablet .heatmaps-box {
|
|
1130
|
+
padding: 20px 15px;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
.possession-heatmaps-container.size-tablet .heatmaps-title {
|
|
1134
|
+
font-size: 20px;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.possession-heatmaps-container.size-tablet .fields-container {
|
|
1138
|
+
overflow-x: auto;
|
|
1139
|
+
scroll-snap-type: x mandatory;
|
|
1140
|
+
-webkit-overflow-scrolling: touch;
|
|
1141
|
+
scroll-padding: 0 50%;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
.possession-heatmaps-container.size-tablet .field-item {
|
|
1145
|
+
min-width: calc(50% - 7.5px);
|
|
1146
|
+
width: calc(50% - 7.5px);
|
|
1147
|
+
flex: 0 0 calc(50% - 7.5px);
|
|
1148
|
+
scroll-snap-align: start;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.possession-heatmaps-container.size-tablet .field-svg {
|
|
1152
|
+
width: 222px;
|
|
1153
|
+
height: 291px;
|
|
1154
|
+
max-width: 100%;
|
|
1155
|
+
height: auto;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
.possession-heatmaps-container.size-tablet .carousel-arrow {
|
|
1159
|
+
display: flex;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/* Móvil (<600px): 1 cancha visible */
|
|
1163
|
+
.possession-heatmaps-container.size-mobile .heatmaps-box {
|
|
1164
|
+
padding: 20px 15px;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
.possession-heatmaps-container.size-mobile .heatmaps-title {
|
|
1168
|
+
font-size: 18px;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
.possession-heatmaps-container.size-mobile .fields-container {
|
|
1172
|
+
overflow-x: auto;
|
|
1173
|
+
scroll-snap-type: x mandatory;
|
|
1174
|
+
-webkit-overflow-scrolling: touch;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
.possession-heatmaps-container.size-mobile .field-item {
|
|
1178
|
+
min-width: 100%;
|
|
1179
|
+
width: 100%;
|
|
1180
|
+
flex: 0 0 100%;
|
|
1181
|
+
scroll-snap-align: center;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
.possession-heatmaps-container.size-mobile .field-svg {
|
|
1185
|
+
width: 222px;
|
|
1186
|
+
height: 291px;
|
|
1187
|
+
max-width: 100%;
|
|
1188
|
+
height: auto;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
.possession-heatmaps-container.size-mobile .carousel-arrow {
|
|
1192
|
+
display: flex;
|
|
1193
|
+
}
|
|
1194
|
+
</style>
|