@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.
Files changed (70) hide show
  1. package/dist/css/fonts.css +83 -0
  2. package/dist/fonts/BebasNeue-Bold.otf +0 -0
  3. package/dist/fonts/BebasNeue-Bold.ttf +0 -0
  4. package/dist/fonts/BebasNeue-Bold.woff2 +0 -0
  5. package/dist/fonts/BebasNeue-Book.otf +0 -0
  6. package/dist/fonts/BebasNeue-Book.ttf +0 -0
  7. package/dist/fonts/BebasNeue-Book.woff2 +0 -0
  8. package/dist/fonts/BebasNeue-Light.otf +0 -0
  9. package/dist/fonts/BebasNeue-Light.ttf +0 -0
  10. package/dist/fonts/BebasNeue-Light.woff2 +0 -0
  11. package/dist/fonts/BebasNeue-Regular.otf +0 -0
  12. package/dist/fonts/BebasNeue-Regular.ttf +0 -0
  13. package/dist/fonts/BebasNeue-Regular.woff2 +0 -0
  14. package/dist/fonts/BebasNeue-Thin.otf +0 -0
  15. package/dist/fonts/BebasNeue-Thin.ttf +0 -0
  16. package/dist/fonts/BebasNeue-Thin.woff2 +0 -0
  17. package/dist/fonts/Montserrat-Black.otf +0 -0
  18. package/dist/fonts/Montserrat-BlackItalic.otf +0 -0
  19. package/dist/fonts/Montserrat-Bold.otf +0 -0
  20. package/dist/fonts/Montserrat-BoldItalic.otf +0 -0
  21. package/dist/fonts/Montserrat-ExtraBold.otf +0 -0
  22. package/dist/fonts/Montserrat-ExtraBoldItalic.otf +0 -0
  23. package/dist/fonts/Montserrat-ExtraLight.otf +0 -0
  24. package/dist/fonts/Montserrat-ExtraLightItalic.otf +0 -0
  25. package/dist/fonts/Montserrat-Italic.otf +0 -0
  26. package/dist/fonts/Montserrat-Light.otf +0 -0
  27. package/dist/fonts/Montserrat-LightItalic.otf +0 -0
  28. package/dist/fonts/Montserrat-Medium.otf +0 -0
  29. package/dist/fonts/Montserrat-MediumItalic.otf +0 -0
  30. package/dist/fonts/Montserrat-Regular.otf +0 -0
  31. package/dist/fonts/Montserrat-SemiBold.otf +0 -0
  32. package/dist/fonts/Montserrat-SemiBoldItalic.otf +0 -0
  33. package/dist/fonts/Montserrat-Thin.otf +0 -0
  34. package/dist/fonts/Montserrat-ThinItalic.otf +0 -0
  35. package/dist/fonts/Oswald-Bold.ttf +0 -0
  36. package/dist/fonts/Oswald-ExtraLight.ttf +0 -0
  37. package/dist/fonts/Oswald-Light.ttf +0 -0
  38. package/dist/fonts/Oswald-Medium.ttf +0 -0
  39. package/dist/fonts/Oswald-Regular.ttf +0 -0
  40. package/dist/fonts/Oswald-SemiBold.ttf +0 -0
  41. package/dist/fonts/Poppins-Black.otf +0 -0
  42. package/dist/fonts/Poppins-BlackItalic.otf +0 -0
  43. package/dist/fonts/Poppins-Bold.otf +0 -0
  44. package/dist/fonts/Poppins-BoldItalic.otf +0 -0
  45. package/dist/fonts/Poppins-ExtraBold.otf +0 -0
  46. package/dist/fonts/Poppins-ExtraBoldItalic.otf +0 -0
  47. package/dist/fonts/Poppins-ExtraLight.otf +0 -0
  48. package/dist/fonts/Poppins-ExtraLightItalic.otf +0 -0
  49. package/dist/fonts/Poppins-Italic.otf +0 -0
  50. package/dist/fonts/Poppins-Light.otf +0 -0
  51. package/dist/fonts/Poppins-LightItalic.otf +0 -0
  52. package/dist/fonts/Poppins-Medium.otf +0 -0
  53. package/dist/fonts/Poppins-MediumItalic.otf +0 -0
  54. package/dist/fonts/Poppins-Regular.otf +0 -0
  55. package/dist/fonts/Poppins-SemiBold.otf +0 -0
  56. package/dist/fonts/Poppins-SemiBoldItalic.otf +0 -0
  57. package/dist/fonts/Poppins-Thin.otf +0 -0
  58. package/dist/fonts/Poppins-ThinItalic.otf +0 -0
  59. package/dist/gsc-possession-heatmaps.css +1 -0
  60. package/dist/gsc-possession-heatmaps.es.js +109348 -0
  61. package/dist/gsc-possession-heatmaps.umd.js +3858 -0
  62. package/dist/images/cancha-horizontal.jpg +0 -0
  63. package/dist/images/canchaRPH.svg +30 -0
  64. package/package.json +74 -0
  65. package/src/App.vue +46 -0
  66. package/src/TestApp.vue +30 -0
  67. package/src/components/gsc-possession-heatmaps.vue +1194 -0
  68. package/src/index.js +3 -0
  69. package/src/main.js +4 -0
  70. 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>