daniel-saed commited on
Commit
fcad20e
·
verified ·
1 Parent(s): cb9648d

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +1 -12
  2. app.py +1091 -324
  3. assets/custom.css +63 -0
  4. assets/style.css +437 -0
  5. requirements.txt +0 -0
Dockerfile CHANGED
@@ -1,24 +1,13 @@
1
  FROM python:3.11-slim
2
 
3
- # 2. Establece el directorio de trabajo dentro del contenedor.
4
  WORKDIR /app
5
 
6
- # 3. Copia primero el archivo de requisitos.
7
- # Esto aprovecha el caché de Docker: si no cambias tus requisitos,
8
- # no se volverán a instalar en cada despliegue, haciéndolo más rápido.
9
  COPY requirements.txt .
10
 
11
- # 4. Instala las dependencias.
12
  RUN pip install --no-cache-dir -r requirements.txt
13
 
14
- # 5. Copia el resto de los archivos de tu proyecto al contenedor.
15
- # Esto incluye app.py, los archivos .csv y la carpeta 'assets'.
16
  COPY . .
17
 
18
- # 6. Expón el puerto que usará la aplicación. Hugging Face usa 7860 por defecto.
19
  EXPOSE 7860
20
 
21
- # 7. El comando para iniciar la aplicación.
22
- # Usamos Gunicorn para ejecutar el 'server' de tu 'app.py'.
23
- # El host 0.0.0.0 es crucial para que sea accesible desde fuera del contenedor.
24
- CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "app:server"]
 
1
  FROM python:3.11-slim
2
 
 
3
  WORKDIR /app
4
 
 
 
 
5
  COPY requirements.txt .
6
 
 
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
 
 
9
  COPY . .
10
 
 
11
  EXPOSE 7860
12
 
13
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--threads", "4", "app:server"]
 
 
 
app.py CHANGED
@@ -7,6 +7,10 @@ import plotly.graph_objects as go
7
  import pycountry_convert as pc
8
  import pycountry
9
  import gunicorn
 
 
 
 
10
 
11
  iracing_ragions = {
12
  'US':['US'],
@@ -31,10 +35,25 @@ iracing_ragions = {
31
  'Benelux':['NL','BE','LU']
32
  }
33
 
 
 
 
34
  def load_and_process_data(filename):
35
  """Función para cargar y pre-procesar un archivo de disciplina."""
36
  print(f"Loading and processing {filename}...")
37
  df = pd.read_csv(filename)
 
 
 
 
 
 
 
 
 
 
 
 
38
  '''filename_parquet = filename.replace('.csv', '.parquet')
39
  df = pd.read_parquet(filename_parquet)'''
40
  df = df[df['IRATING'] > 1]
@@ -52,6 +71,104 @@ def load_and_process_data(filename):
52
  print(f"Finished processing {filename}.")
53
  return df
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def create_irating_trend_line_chart(df):
56
  """
57
  Crea un gráfico de líneas que muestra el promedio de carreras corridas
@@ -101,12 +218,6 @@ def create_irating_trend_line_chart(df):
101
  ))
102
 
103
  fig.update_layout(
104
- title=dict(
105
- text='<b>iRating Range By Avg. Races</b>',
106
- font=dict(color='white', size=14),
107
- x=0.5,
108
- xanchor='center'
109
- ),
110
  template='plotly_dark',
111
  paper_bgcolor='rgba(11,11,19,1)',
112
  plot_bgcolor='rgba(11,11,19,1)',
@@ -125,7 +236,7 @@ def create_irating_trend_line_chart(df):
125
  gridwidth=1,
126
  gridcolor='rgba(255,255,255,0.1)'
127
  ),
128
- margin=dict(l=10, r=10, t=50, b=10),
129
  )
130
  return fig
131
 
@@ -226,14 +337,16 @@ def create_region_bubble_chart(df):
226
  )
227
  ))
228
 
 
 
 
 
 
 
 
 
229
  fig.update_layout(
230
- title=dict(
231
- text='<b>Regions (Avg. iRating, Avg. Races, Qty. Drivers)</b>',
232
- font=dict(color='white', size=14),
233
- x=0.5,
234
- xanchor='center'
235
- ),
236
- font=GLOBAL_FONT,
237
  #xaxis_title='Avg. iRating',
238
  #yaxis_title='Avg. Races',
239
  template='plotly_dark',
@@ -257,7 +370,7 @@ def create_region_bubble_chart(df):
257
  gridcolor='rgba(255,255,255,0.1)'
258
  ),
259
  # --- FIN DEL ESTILO DE GRID ---
260
- margin=dict(l=10, r=10, t=50, b=10),
261
  )
262
  return fig
263
 
@@ -270,9 +383,9 @@ def create_kpi_global(filtered_df, filter_context="World"):
270
  fig = go.Figure()
271
  kpis = [
272
  {'value': total_pilots, 'title': f"Drivers {filter_context}", 'format': ',.0f'},
273
- {'value': avg_irating, 'title': "Average iRating", 'format': ',.0f'},
274
- {'value': avg_starts, 'title': "Average Starts", 'format': '.1f'},
275
- {'value': avg_wins, 'title': "Average Wins", 'format': '.2f'}
276
  ]
277
  for i, kpi in enumerate(kpis):
278
  fig.add_trace(go.Indicator(
@@ -287,7 +400,7 @@ def create_kpi_global(filtered_df, filter_context="World"):
287
  grid={'rows': 1, 'columns': 4, 'pattern': "independent"},
288
  template='plotly_dark',
289
  paper_bgcolor='rgba(0,0,0,0)',
290
- plot_bgcolor='#323232',
291
  margin=dict(l=20, r=20, t=50, b=10),
292
  height=60,
293
  font=GLOBAL_FONT
@@ -306,7 +419,7 @@ def create_kpi_pilot(filtered_df, pilot_info=None, filter_context="World"):
306
  plot_bgcolor='rgba(0,0,0,0)',
307
  xaxis_visible=False,
308
  yaxis_visible=False,
309
- height=50,
310
  annotations=[
311
  dict(
312
  text="<b>Select or search a driver</b>",
@@ -323,7 +436,6 @@ def create_kpi_pilot(filtered_df, pilot_info=None, filter_context="World"):
323
  ]
324
  )
325
  return fig
326
- # --- FIN DE LA
327
 
328
  # Si SÍ hay información del piloto, procedemos como antes.
329
  pilot_name = pilot_info.get('DRIVER', 'Piloto')
@@ -340,32 +452,42 @@ def create_kpi_pilot(filtered_df, pilot_info=None, filter_context="World"):
340
 
341
  kpis_piloto = [
342
  {'rank': rank_world, 'percentil': percentil_world, 'title': "World Rank"},
343
- {'rank': rank_region, 'percentil': percentil_region, 'title': "Region Rank "},
344
  {'rank': rank_country, 'percentil': percentil_country, 'title': "Country Rank"}
345
  ]
346
 
 
347
  for i, kpi in enumerate(kpis_piloto):
348
  fig.add_trace(go.Indicator(
349
  mode="number",
350
  value=kpi['rank'],
351
- number={'prefix': "#", 'font': {'size': 12}},
352
- # Eliminamos el <br> y ajustamos el texto para que esté en una línea
353
- title={"text": f"{kpi['title']} <span style='font-size:12px;color:gray'>(Top {100-kpi['percentil']:.2f}%)</span>", 'font': {'size': 12}},
354
- domain={'row': 0, 'column': i}
 
 
 
 
 
 
 
355
  ))
 
356
  fig.update_layout(
357
  title={
358
  'text': title_text,
359
- 'y':0.95, 'x':0.5, 'xanchor': 'center', 'yanchor': 'top',
360
- 'font': {'size': 14}
361
  },
362
- grid={'rows': 1, 'columns': 3, 'pattern': "independent"},
363
  template='plotly_dark',
364
  paper_bgcolor='rgba(0,0,0,0)',
365
  plot_bgcolor='rgba(0,0,0,0)',
366
- margin=dict(l=20, r=20, t=40, b=10),
367
- height=60,
368
- font=GLOBAL_FONT
 
369
  )
370
  return fig
371
 
@@ -407,13 +529,13 @@ def create_density_heatmap(df):
407
 
408
  max_line_trace = go.Scatter(
409
  x=x_trend, y=y_trend_max, mode='lines',
410
- name='Tendencia Máximo AVG_INC',
411
  line=dict(color='red', width=1, dash='dash')
412
  )
413
 
414
  min_line_trace = go.Scatter(
415
  x=x_trend, y=y_trend_min, mode='lines',
416
- name='Tendencia Mínimo AVG_INC',
417
  line=dict(color='lime', width=1, dash='dash')
418
  )
419
 
@@ -422,7 +544,7 @@ def create_density_heatmap(df):
422
  x=x_trend,
423
  y=y_trend_mean,
424
  mode='lines',
425
- name='Tendencia Promedio AVG_INC',
426
  line=dict(color='black', width=2, dash='solid')
427
  )
428
 
@@ -431,7 +553,8 @@ def create_density_heatmap(df):
431
  fig = go.Figure(data=[heatmap_trace, max_line_trace, min_line_trace, mean_line_trace])
432
 
433
  fig.update_layout(
434
- title='Densidad de Pilotos: iRating vs. AVG_INC',
 
435
  xaxis_title='iRating',
436
  yaxis_title='Incidents Per Race',
437
  template='plotly_dark',
@@ -533,7 +656,7 @@ def create_continent_map(df, selected_region='ALL', selected_country='ALL'):
533
  showframe=False, # <-- Oculta el marco exterior del globo
534
  showcoastlines=False # <-- Oculta las líneas de la costa
535
  ),
536
- margin={"r":0,"t":40,"l":0,"b":0},
537
  coloraxis_showscale=show_scale,
538
  coloraxis_colorbar=dict(
539
  title='Drivers',
@@ -550,9 +673,14 @@ def create_continent_map(df, selected_region='ALL', selected_country='ALL'):
550
 
551
  def create_histogram_with_percentiles(df, column='IRATING', bin_width=100, highlight_irating=None, highlight_name=None):
552
  # Crear bins específicos de 100 en 100
553
- min_val = df[column].min()
554
  max_val = df[column].max()
555
- bin_edges = np.arange(min_val, max_val + bin_width, bin_width)
 
 
 
 
 
 
556
 
557
  hist, bin_edges = np.histogram(df[column], bins=bin_edges)
558
  bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
@@ -567,8 +695,10 @@ def create_histogram_with_percentiles(df, column='IRATING', bin_width=100, highl
567
  below = (df[column] < bin_edges[i+1]).sum()
568
  percentile = below / total * 100
569
  top_percent = 100 - percentile
 
 
570
  hover_text.append(
571
- f"Range: {int(bin_edges[i])}-{int(bin_edges[i+1])}<br>"
572
  f"Drivers: {hist[i]}<br>"
573
  f"Top: {top_percent:.2f}%"
574
  )
@@ -609,12 +739,6 @@ def create_histogram_with_percentiles(df, column='IRATING', bin_width=100, highl
609
  # --- FIN DEL BLOQUE NUEVO ---
610
 
611
  fig.update_layout(
612
- title=dict(
613
- text='<b>iRating Histogram</b>',
614
- font=dict(color='white', size=14),
615
- x=0.5,
616
- xanchor='center'
617
- ),
618
  font=GLOBAL_FONT,
619
  xaxis=dict(
620
  title_text='iRating', # Texto del título
@@ -632,12 +756,12 @@ def create_histogram_with_percentiles(df, column='IRATING', bin_width=100, highl
632
  ),
633
  template='plotly_dark',
634
  hovermode='x unified',
635
- paper_bgcolor='rgba(18,18,26,.5)',
636
  plot_bgcolor='rgba(255,255,255,0)',
637
 
638
 
639
  # --- MODIFICACIÓN: Reducir márgenes y tamaño de fuentes ---
640
- margin=dict(l=10, r=10, t=50, b=10) # Reduce los márgenes (izquierda, derecha, arriba, abajo) # Reduce el tamaño del título principal
641
 
642
  )
643
 
@@ -666,6 +790,47 @@ def create_correlation_heatmap(df):
666
  )
667
  return fig
668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  def flag_img(code):
670
  url = f"https://flagcdn.com/16x12/{code.lower()}.png"
671
  # La función ahora asume que si el código llega aquí, es válido.
@@ -674,14 +839,6 @@ def flag_img(code):
674
 
675
  GLOBAL_FONT = {'family': "Lato, sans-serif"}
676
 
677
- DISCIPLINE_DATAFRAMES = {
678
- 'ROAD.csv': load_and_process_data('ROAD.csv'),
679
- 'FORMULA.csv': load_and_process_data('FORMULA.csv'),
680
- 'OVAL.csv': load_and_process_data('OVAL.csv'),
681
- 'DROAD.csv': load_and_process_data('DROAD.csv'),
682
- 'DOVAL.csv': load_and_process_data('DOVAL.csv')
683
- }
684
-
685
  country_coords = {
686
  'ES': {'lat': 40.4, 'lon': -3.7}, 'US': {'lat': 39.8, 'lon': -98.5},
687
  'BR': {'lat': -14.2, 'lon': -51.9}, 'DE': {'lat': 51.1, 'lon': 10.4},
@@ -749,14 +906,18 @@ df = df[['DRIVER','IRATING','LOCATION','STARTS','WINS','AVG_START_POS','AVG_FINI
749
  #df['LOCATION'] = 'a'
750
  density_heatmap = dcc.Graph(
751
  id='density-heatmap',
752
- style={'height': '30vh', 'borderRadius': '15px', 'overflow': 'hidden'},
753
- figure=create_density_heatmap(df_for_graphs)
 
 
754
  )
755
  correlation_heatmap = dcc.Graph(
756
  id='correlation-heatmap',
757
- style={'height': '70vh'}, # Ajusta la altura para que quepan los 3 gráficos
 
758
  # Usamos las columnas numéricas del dataframe original
759
- figure=create_correlation_heatmap(df[['IRATING', 'STARTS', 'WINS','TOP25PCNT','AVG_INC','AVG_FINISH_POS']])
 
760
  )
761
 
762
 
@@ -786,49 +947,51 @@ interactive_table = dash_table.DataTable(
786
  sort_mode="single",
787
  page_action="custom",
788
  page_current=0,
789
- page_size=20,
790
- page_count=len(df_table) // 20 + (1 if len(df_table) % 20 > 0 else 0),
791
  virtualization=False,
792
  style_as_list_view=True,
793
  active_cell={'row': 21,'column':1},
794
 
795
-
796
- # --- ELIMINAMOS selected_rows Y AÑADIMOS active_cell ---
797
- # selected_rows=[], # <-- ELIMINAR ESTA LÍNEA
798
-
799
  style_table={
800
- #'tableLayout': 'fixed', # <-- DESCOMENTA O AÑADE ESTA LÍNEA
801
  'overflowX': 'auto',
802
- 'height': '70vh',
803
- 'minHeight': '0',
 
 
804
  'width': '100%',
805
  'borderRadius': '15px',
806
  'overflow': 'hidden',
807
  'backgroundColor': 'rgba(11,11,19,1)',
808
- 'textOverflow': 'ellipsis',
809
  'border': '1px solid #4A4A4A'
810
-
811
  },
812
 
813
  style_cell={
814
  'textAlign': 'center',
815
- 'padding': '1px',
816
  'backgroundColor': 'rgba(11,11,19,1)',
817
  'color': 'rgb(255, 255, 255,.8)',
818
- 'border': '1px solid rgba(255, 255, 255, 0)',
819
  'overflow': 'hidden',
820
  'textOverflow': 'ellipsis',
821
- 'whiteSpace': 'nowrap', # <-- AÑADE ESTA LÍNEA
822
- 'maxWidth': 0
 
823
  },
 
824
  style_header={
825
  'backgroundColor': 'rgba(30,30,38,1)',
826
  'fontWeight': 'bold',
827
  'color': 'white',
828
- 'border': 'none',
829
  'textAlign': 'center',
830
- 'fontSize': 10
 
 
 
 
831
  },
 
832
  # --- AÑADIMOS ESTILO PARA LA FILA SELECCIONADA Y LAS CLASES ---
833
  style_data_conditional=[
834
  {
@@ -882,7 +1045,7 @@ interactive_table = dash_table.DataTable(
882
 
883
  scatter_irating_starts = dcc.Graph(
884
  id='scatter-irating',
885
- style={'height': '20vh','borderRadius': '15px','overflow': 'hidden'},
886
  # Usamos go.Scattergl en lugar de px.scatter para un rendimiento masivo
887
  figure=go.Figure(data=go.Scattergl(
888
  x=df['IRATING'],
@@ -927,191 +1090,700 @@ region_bubble_chart = dcc.Graph(
927
  app = dash.Dash(__name__)
928
  server = app.server # <-- AÑADE ESTA LÍNEA
929
 
930
- # Layout principal
931
  app.layout = html.Div(
932
- #style={'height': '100vh', 'display': 'flex', 'flexDirection': 'column', 'backgroundColor': '#1E1E1E'},
933
- style={},
 
 
 
 
 
 
934
  children=[
935
-
936
-
937
- # --- CONTENEDOR PRINCIPAL CON 3 COLUMNAS ---
938
  html.Div(
939
- id='main-content-container',
940
- #style={'display': 'flex', 'flex': 1, 'minHeight': 0, 'padding': '0 10px 10px 10px'},
941
- style={'display': 'flex', 'padding': '0px 10px 10px 10px'},
 
 
 
 
 
 
942
  children=[
943
 
944
- # --- COLUMNA IZQUIERDA (FILTROS Y TABLA) ---
945
  html.Div(
946
- id='left-column',
947
- style={'width': '25%', 'padding': '1%', 'display': 'flex', 'flexDirection': 'column'},
 
 
948
  children=[
949
- # Contenedor de Filtros
950
- html.Div(
951
- style={'display': 'flex', 'width': '60%', 'justifyContent': 'space-between', 'margin': '0 auto 10px auto'},
952
- children=[
953
- # --- MODIFICACIÓN: Centramos el texto del contenedor del filtro de Región ---
954
- html.Div([
955
- html.Label("Region:", style={'color': 'white', 'fontSize': 2}),
956
- dcc.Dropdown(
957
- id='region-filter',
958
- options=[{'label': 'All', 'value': 'ALL'}] +
959
- [{'label': region, 'value': region} for region in sorted(iracing_ragions.keys())],
960
- value='ALL',
961
- className='iracing-dropdown',
962
- # --- AÑADIMOS ESTILO INICIAL ---
963
-
964
- )
965
- ], style={'flex': 1, 'marginRight': '10px', 'textAlign': 'center'}),
966
-
967
- # --- MODIFICACIÓN: Centramos el texto del contenedor del filtro de País ---
968
- html.Div([
969
- html.Label("Country:", style={'color': 'white', 'fontSize': 10}),
970
- dcc.Dropdown(
971
- id='country-filter',
972
- options=[{'label': 'All', 'value': 'ALL'}],
973
- value='ALL',
974
- className='iracing-dropdown',
975
- # --- AÑADIMOS ESTILO INICIAL ---
976
-
977
- )
978
- ], style={'flex': 1, 'marginRight': '10px', 'textAlign': 'center'}),
979
 
980
-
981
- ]
 
 
 
 
 
 
 
982
  ),
983
- # Contenedor de la Tabla
984
- html.Div(
985
- [
986
- html.Label("Search Driver:", style={'color': 'white', 'fontSize': 10}),
987
- dcc.Dropdown(
988
- id='pilot-search-dropdown',
989
- options=[],
990
- placeholder='Search Driver...',
991
- className='iracing-dropdown',
992
- searchable=True,
993
- clearable=True,
994
- search_value='',
995
- # Se elimina el estilo de aquí para aplicarlo al contenedor.
996
- )
997
- ],
998
- # --- MODIFICACIÓN: Centramos el texto del contenedor de búsqueda ---
999
- style={'width': '60%', 'marginBottom': '10px', 'margin': '0 auto 10px auto', 'color':'white', 'textAlign': 'center'}
1000
  ),
 
1001
 
1002
  html.Div(
1003
- kpi_pilot,
1004
  style={
1005
- 'marginTop': '1%',
1006
- 'marginBottom': '1%' # Pone los KPIs por delante del mapa
1007
- }
1008
- ),
1009
- html.Div(interactive_table, style={'flex': 1})
 
 
 
 
 
 
 
 
1010
  ]
1011
  ),
1012
 
1013
- # --- COLUMNA CENTRAL ---
1014
  html.Div(
1015
- id='middle-column',
1016
- # --- MODIFICACIÓN: Añadimos position: 'relative' ---
1017
- # Esto convierte a la columna en el contenedor de referencia para el posicionamiento absoluto.
1018
- style={'width': '45%', 'padding': '1%', 'display': 'flex', 'flexDirection': 'column', 'position': 'relative'},
1019
- children=[
 
 
 
 
 
 
1020
  html.Div(
1021
- style={'textAlign': 'center'},
 
 
 
 
 
 
 
 
 
 
 
 
1022
  children=[
1023
- html.H1("Top iRating", style={'fontSize': 48, 'color': 'white', 'margin': '-10px 0 10px 0'}),
1024
- html.Div([
1025
- # <-- AÑADIDO
1026
- html.Button('Sports Car', id='btn-road', n_clicks=0, className='dashboard-type-button'),
1027
- html.Button('Formula', id='btn-formula', n_clicks=0, className='dashboard-type-button'),
1028
- html.Button('Oval', id='btn-oval', n_clicks=0, className='dashboard-type-button'),
1029
- html.Button('Dirt Road', id='btn-dirt-road', n_clicks=0, className='dashboard-type-button'),
1030
- html.Button('Dirt Oval', id='btn-dirt-oval', n_clicks=0, className='dashboard-type-button'),
1031
- ], style={'display': 'flex', 'justifyContent': 'center', 'gap': '10px'})
1032
  ]
1033
  ),
1034
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1035
  html.Div(
1036
- kpi_global,
1037
  style={
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1038
 
1039
- 'width': '70%',
1040
- 'margin': '0 auto',
1041
- 'position': 'relative', # Necesario para que z-index funcione
1042
- 'z-index': '10' # Pone los KPIs por delante del mapa
1043
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1044
  ),
 
 
1045
  html.Div(
1046
- continent_map,
1047
  style={
1048
- 'flex': 1,
1049
- 'minHeight': 0,
1050
- 'marginTop': '-5%'
1051
- }
1052
- ),
1053
- # --- MODIFICACIÓN: Se elimina el posicionamiento absoluto ---
1054
- # El histograma ahora está en el flujo normal de la página.
1055
- html.Div(
1056
- histogram_irating,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  style={
1058
- 'width':'100%',
1059
- 'height': '26vh', # Mantenemos una altura definida
1060
- 'marginTop': '1%' # Añadimos un margen superior para separarlo del mapa
 
1061
  }
1062
  ),
1063
-
 
 
 
 
1064
  ]
1065
  ),
1066
 
1067
- # --- COLUMNA DERECHA ---
1068
  html.Div(
1069
- id='right-column',
1070
- style={'width': '25%', 'padding': '1%', 'display': 'flex', 'flexDirection': 'column'},
 
 
 
1071
  children=[
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
 
1073
- # --- MODIFICACIÓN: Contenedor vacío para las tablas de competitividad ---
1074
- html.Div(id='competitiveness-tables-container'),
1075
-
1076
- # --- MODIFICACIÓN: Gráfico de burbujas vacío ---
1077
- dcc.Graph(
1078
- id='region-bubble-chart',
1079
- style={'height': '32vh', 'marginTop': '3.5%','borderRadius': '10px','border': '1px solid #4A4A4A', 'overflow': 'hidden'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1080
  ),
1081
 
1082
- # --- MODIFICACIÓN: Gráfico de líneas vacío ---
1083
- dcc.Graph(
1084
- id='irating-starts-scatter',
1085
- style={'height': '32vh', 'marginTop': '7.4%', 'borderRadius': '10px', 'border': '1px solid #4A4A4A', 'overflow': 'hidden'},
1086
- config={'displayModeBar': False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1087
  )
 
1088
  ]
1089
  )
1090
  ]
1091
  ),
1092
 
1093
- # Componentes ocultos
1094
- # --- ELIMINA EL dcc.Store ---
1095
  dcc.Store(id='active-discipline-store', data='ROAD.csv'),
1096
  dcc.Store(id='shared-data-store', data={}),
1097
  dcc.Store(id='shared-data-store_1', data={}),
1098
- html.Div(id='pilot-info-display', style={'display': 'none'})
 
 
 
 
 
 
 
1099
  ]
1100
  )
1101
 
1102
  # --- 4. Callbacks ---
1103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1104
 
1105
  # --- NUEVO CALLBACK PARA ACTUALIZAR GRÁFICOS DE LA COLUMNA DERECHA ---
1106
  @app.callback(
1107
  Output('competitiveness-tables-container', 'children'),
1108
  Output('region-bubble-chart', 'figure'),
1109
  Output('irating-starts-scatter', 'figure'),
1110
- Input('active-discipline-store', 'data')
1111
- )
 
 
 
1112
  def update_right_column_graphs(filename):
1113
  # 1. Cargar y procesar los datos de la disciplina seleccionada
1114
- df_discipline = pd.read_csv(filename)
1115
  df_discipline = df_discipline[df_discipline['IRATING'] > 1]
1116
  df_discipline = df_discipline[df_discipline['STARTS'] > 1]
1117
  df_discipline = df_discipline[df_discipline['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
@@ -1124,69 +1796,173 @@ def update_right_column_graphs(filename):
1124
  top_regions.insert(0, '#', range(1, 1 + len(top_regions)))
1125
  top_countries.insert(0, '#', range(1, 1 + len(top_countries)))
1126
 
1127
- # --- MODIFICACIÓN: Traducir códigos de país a nombres completos ---
1128
  def get_country_name(code):
1129
  try:
1130
  return pycountry.countries.get(alpha_2=code).name
1131
  except (LookupError, AttributeError):
1132
- return code # Devuelve el código si no se encuentra
1133
 
1134
  top_countries['LOCATION'] = top_countries['LOCATION'].apply(get_country_name)
1135
- # --- FIN DE LA MODIFICACIÓN ---
1136
 
 
1137
  table_style_base = {
1138
- 'style_table': {'borderRadius': '10px', 'overflow': 'hidden', 'border': '1px solid #4A4A4A','backgroundColor': 'rgba(11,11,19,1)'},
1139
- 'style_cell': {'textAlign': 'center', 'padding': '0px', 'backgroundColor': 'rgba(11,11,19,1)', 'color': 'rgb(255, 255, 255,.8)', 'border': 'none', 'font_size': '10px','textOverflow': 'ellipsis',
1140
- 'whiteSpace': 'normal'},
1141
- 'style_header': {'backgroundColor': 'rgba(30,30,38,1)', 'fontWeight': 'bold', 'color': 'white', 'border': 'none', 'textAlign': 'center'},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1142
  'style_cell_conditional': [
1143
- {'if': {'column_id': '#'}, 'width': '10%', 'textAlign': 'center'},
1144
- {'if': {'column_id': 'REGION'}, 'width': '50%', 'textAlign': 'center'},
1145
- {'if': {'column_id': 'LOCATION'}, 'width': '50%', 'textAlign': 'center'},
1146
- {'if': {'column_id': 'avg_irating'}, 'width': '40%', 'textAlign': 'center'},
1147
  ]
1148
  }
1149
 
 
1150
  competitiveness_tables = html.Div(
1151
- style={'display': 'flex', 'gap': '3%', 'marginTop': '1%'},
 
 
 
 
 
 
 
 
1152
  children=[
1153
- html.Div(dash_table.DataTable(
1154
- columns=[{'name': '#', 'id': '#'}, {'name': 'Top Regions', 'id': 'REGION'}, {'name': 'AVG iRating', 'id': 'avg_irating'}],
1155
- data=top_regions.to_dict('records'),
1156
- # --- MODIFICACIÓN: Añadimos paginación nativa ---
1157
- page_action='native', # Activa la paginación
1158
- page_size=5, # Muestra 5 filas por página
1159
- # --- FIN DE LA MODIFICACIÓN ---
1160
- style_table={**table_style_base['style_table'], 'height': '20vh'},
1161
- style_cell=table_style_base['style_cell'],
1162
- style_header=table_style_base['style_header'],
1163
- style_cell_conditional=table_style_base['style_cell_conditional']
1164
- ), style={'width': '49%'}),
1165
- html.Div(dash_table.DataTable(
1166
- columns=[{'name': '#', 'id': '#'}, {'name': 'Top Countries', 'id': 'LOCATION'}, {'name': 'AVG iRating', 'id': 'avg_irating'}],
1167
- data=top_countries.to_dict('records'),
1168
- # --- MODIFICACIÓN: Añadimos paginación nativa ---
1169
- page_action='native', # Activa la paginación
1170
- page_size=5, # Muestra 5 filas por página
1171
- # --- FIN DE LA MODIFICACIÓN ---
1172
- style_table={**table_style_base['style_table'], 'height': '20vh'},
1173
- style_cell=table_style_base['style_cell'],
1174
- style_header=table_style_base['style_header'],
1175
- style_cell_conditional=table_style_base['style_cell_conditional']
1176
- ), style={'width': '49%'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1177
  ]
1178
  )
1179
 
1180
  # 3. Crear los otros gráficos
1181
  bubble_chart_fig = create_region_bubble_chart(df_discipline)
1182
  line_chart_fig = create_irating_trend_line_chart(df_discipline)
1183
-
1184
- # 4. Devolver todos los componentes actualizados
1185
- return competitiveness_tables, bubble_chart_fig, line_chart_fig
1186
-
1187
-
1188
 
1189
 
 
 
 
1190
 
1191
  # --- ELIMINA EL CALLBACK update_data_source ---
1192
 
@@ -1269,67 +2045,86 @@ def update_country_filter_on_map_click(clickData):
1269
  # Devolvemos el código del país, que actualizará el valor del dropdown 'country-filter'.
1270
  return country_code
1271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1272
  @app.callback(
1273
  Output('pilot-search-dropdown', 'options'),
1274
- Input('pilot-search-dropdown', 'search_value'),
 
 
 
1275
  State('pilot-search-dropdown', 'value'),
1276
  State('region-filter', 'value'),
1277
  State('country-filter', 'value'),
1278
- # --- MODIFICACIÓN: Añadimos el State para saber la disciplina activa ---
1279
  State('active-discipline-store', 'data'),
1280
- prevent_initial_call=True,
 
1281
  )
1282
- def update_pilot_search_options(search_value, current_selected_pilot, region_filter, country_filter, active_discipline_filename):
1283
- # --- MODIFICACIÓN: Cargamos el DataFrame correcto al inicio de la función ---
1284
- # 1. Cargar los datos de la disciplina actual
1285
- df_current_discipline = pd.read_csv(active_discipline_filename)
1286
- # Aplicamos los mismos filtros iniciales que en el callback principal
1287
- df_current_discipline = df_current_discipline[df_current_discipline['IRATING'] > 1]
1288
- df_current_discipline = df_current_discipline[df_current_discipline['STARTS'] > 1]
1289
- df_current_discipline = df_current_discipline[df_current_discipline['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
1290
-
1291
- # Asignamos la región para poder filtrar por ella
1292
- country_to_region_map = {country: region for region, countries in iracing_ragions.items() for country in countries}
1293
- df_current_discipline['REGION'] = df_current_discipline['LOCATION'].map(country_to_region_map).fillna('International')
1294
- # --- FIN DE LA MODIFICACIÓN ---
1295
-
1296
- # Si no hay texto de búsqueda, pero ya hay un piloto seleccionado,
1297
- # nos aseguramos de que su opción esté disponible para que no desaparezca.
1298
- if not search_value:
1299
- if current_selected_pilot:
1300
- return [{'label': current_selected_pilot, 'value': current_selected_pilot}]
1301
- return []
1302
-
1303
- # Mantenemos la optimización de no buscar con texto muy corto
1304
- if len(search_value) < 2:
1305
- return []
1306
 
1307
- # 1. La lógica de filtrado ahora usa el DataFrame correcto
1308
- if not region_filter: region_filter = 'ALL'
1309
- if not country_filter: country_filter = 'ALL'
 
1310
 
 
1311
  filtered_df = df_current_discipline
1312
- if region_filter != 'ALL':
1313
  filtered_df = filtered_df[filtered_df['REGION'] == region_filter]
1314
- if country_filter != 'ALL':
1315
  filtered_df = filtered_df[filtered_df['LOCATION'] == country_filter]
1316
 
1317
- # 2. La búsqueda de coincidencias no cambia
1318
- matches = filtered_df[filtered_df['DRIVER'].str.contains(search_value, case=False)]
1319
- top_matches = matches.nlargest(20, 'IRATING')
 
 
 
1320
 
1321
- # 3. Creamos las opciones a partir de las coincidencias
1322
  options = [{'label': row['DRIVER'], 'value': row['DRIVER']}
1323
  for _, row in top_matches.iterrows()]
1324
 
1325
- # 4. LA CLAVE: Si el piloto ya seleccionado no está en la nueva lista de opciones
1326
- # (porque borramos el texto, por ejemplo), lo añadimos para que no se borre de la vista.
1327
  if current_selected_pilot and not any(opt['value'] == current_selected_pilot for opt in options):
1328
  options.insert(0, {'label': current_selected_pilot, 'value': current_selected_pilot})
1329
 
1330
- print(f"DEBUG: Búsqueda de '{search_value}' encontró {len(options)} coincidencias.")
1331
-
1332
- return options
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1333
 
1334
  # --- CALLBACK para limpiar la búsqueda si cambian los filtros ---
1335
  @app.callback(
@@ -1443,8 +2238,8 @@ def update_button_styles(formula_clicks, road_clicks, oval_clicks, dirt_road_cli
1443
  def update_table_and_search(
1444
  region_filter, country_filter, selected_pilot,
1445
  page_current, page_size, sort_by, state_active_cell,
1446
- active_discipline_filename, # <-- Nuevo argumento desde el State
1447
- discipline_change_trigger # <-- Nuevo argumento desde el Input
1448
  ):
1449
 
1450
  ctx = dash.callback_context
@@ -1458,22 +2253,8 @@ def update_table_and_search(
1458
  # Leemos y procesamos el archivo seleccionado
1459
  #df = pd.read_csv(filename)
1460
  df = DISCIPLINE_DATAFRAMES[active_discipline_filename]
1461
- '''df = df[df['IRATING'] > 1]
1462
- df = df[df['STARTS'] > 1]
1463
- df = df[df['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
1464
 
1465
- country_to_region_map = {country: region for region, countries in iracing_ragions.items() for country in countries}
1466
- df['REGION'] = df['LOCATION'].map(country_to_region_map).fillna('International')
1467
-
1468
- df['Rank World'] = df['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
1469
- df['Rank Region'] = df.groupby('REGION')['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
1470
- df['Rank Country'] = df.groupby('LOCATION')['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
1471
-
1472
- df['CLASS'] = df['CLASS'].str[0]'''
1473
  df_for_graphs = df.copy() # Copia para gráficos que no deben ser filtrados
1474
-
1475
- # --- 3. LÓGICA DE FILTRADO Y VISUALIZACIÓN (sin cambios) ---
1476
- # El resto de la función sigue igual, pero ahora opera sobre el 'df' que acabamos de cargar.
1477
 
1478
  # Lógica de columnas dinámicas
1479
  base_cols = ['DRIVER', 'IRATING', 'LOCATION', 'REGION','CLASS', 'STARTS', 'WINS' ]
@@ -1519,17 +2300,15 @@ def update_table_and_search(
1519
  elif triggered_id == 'pilot-search-dropdown' and selected_pilot:
1520
  match_index = filtered_df.index.get_loc(df[df['DRIVER'] == selected_pilot].index[0])
1521
  if match_index is not None:
1522
- target_page = match_index // page_size
1523
  driver_column_index = list(filtered_df.columns).index('DRIVER')
1524
  new_active_cell = {
1525
- 'row': match_index % page_size,
1526
- 'row_id': match_index % page_size,
1527
  'column': driver_column_index,
1528
  'column_id': 'DRIVER'
1529
  }
1530
 
1531
-
1532
-
1533
  # --- 5. GENERACIÓN DE COLUMNAS PARA LA TABLA ---
1534
  columns_definition = []
1535
  for col_name in filtered_df.columns:
@@ -1545,8 +2324,8 @@ def update_table_and_search(
1545
  columns_definition.append({"name": col_name.title(), "id": col_name})
1546
 
1547
  # --- 6. PAGINACIÓN ---
 
1548
  start_idx = target_page * page_size
1549
-
1550
  end_idx = start_idx + page_size
1551
 
1552
  # Aplicamos el formato de bandera a los datos de la página actual
@@ -1555,7 +2334,7 @@ def update_table_and_search(
1555
  page_data = page_df.to_dict('records')
1556
 
1557
  total_pages = len(filtered_df) // page_size + (1 if len(filtered_df) % page_size > 0 else 0)
1558
-
1559
  # --- 7. ACTUALIZACIÓN DE GRÁFICOS ---
1560
  graph_indices = filtered_df.index
1561
  highlight_irating = None
@@ -1700,21 +2479,9 @@ def update_active_cell_from_store(active_cell,ds,ds1,a,b):
1700
  ds1 = ds
1701
  return ds.get('active_cell'),ds1
1702
 
1703
-
1704
-
1705
-
1706
- '''active_cell = a
1707
- selected_pilot = shared_data.get('selected_pilot', '')
1708
- print('..............')
1709
- print(shared_data)
1710
-
1711
- print(f"DEBUG: Recuperando active_cell del store: {active_cell}")
1712
- print(f"DEBUG: Piloto asociado: {selected_pilot}")
1713
- shared_data['shared_data'] = ''
1714
-
1715
-
1716
- return active_cell'''
1717
-
1718
 
1719
  if __name__ == "__main__":
1720
- app.run(debug=True)
 
 
 
 
7
  import pycountry_convert as pc
8
  import pycountry
9
  import gunicorn
10
+ from apscheduler.schedulers.background import BackgroundScheduler
11
+ from apscheduler.triggers.interval import IntervalTrigger
12
+ import atexit
13
+ from datetime import datetime, timezone # <-- AÑADE timezone
14
 
15
  iracing_ragions = {
16
  'US':['US'],
 
35
  'Benelux':['NL','BE','LU']
36
  }
37
 
38
+ DATA_STATUS = {}
39
+ LAST_SUCCESSFUL_STATUS = {}
40
+
41
  def load_and_process_data(filename):
42
  """Función para cargar y pre-procesar un archivo de disciplina."""
43
  print(f"Loading and processing {filename}...")
44
  df = pd.read_csv(filename)
45
+
46
+ # Nuevas columnas en mayúsculas
47
+ new_columns = [
48
+ 'DRIVER', 'CUSTID', 'LOCATION', 'CLUB_NAME', 'STARTS', 'WINS',
49
+ 'AVG_START_POS', 'AVG_FINISH_POS', 'AVG_POINTS', 'TOP25PCNT',
50
+ 'LAPS', 'LAPSLEAD', 'AVG_INC', 'CLASS', 'IRATING', 'TTRATING',
51
+ 'TOT_CLUBPOINTS', 'CHAMPPOINTS'
52
+ ]
53
+
54
+ # Reemplazar las columnas (asumiendo que el orden es correcto)
55
+ if len(df.columns) == len(new_columns):
56
+ df.columns = new_columns
57
  '''filename_parquet = filename.replace('.csv', '.parquet')
58
  df = pd.read_parquet(filename_parquet)'''
59
  df = df[df['IRATING'] > 1]
 
71
  print(f"Finished processing {filename}.")
72
  return df
73
 
74
+ def update_all_data():
75
+ """Actualiza todos los archivos de datos"""
76
+ global DATA_STATUS
77
+ print(f"\n{'='*60}")
78
+ print(f"🔄 SCHEDULED DATA UPDATE STARTED")
79
+ print(f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
80
+ print(f"{'='*60}")
81
+
82
+
83
+ files_to_update = {
84
+ 'ROAD.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/ROAD.csv',
85
+ 'FORMULA.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/FORMULA.csv',
86
+ 'OVAL.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/OVAL.csv',
87
+ 'DROAD.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DROAD.csv',
88
+ 'DOVAL.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DOVAL.csv'
89
+ }
90
+
91
+
92
+ try:
93
+ status_url = 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/branch_actions/update_status.json'
94
+ # Leemos el JSON y lo guardamos en la variable de estado actual
95
+ current_status_data = pd.read_json(status_url, typ='series').to_dict()
96
+ DATA_STATUS = current_status_data
97
+
98
+ # Si el estado actual es exitoso, lo guardamos como el último éxito conocido.
99
+ if DATA_STATUS.get('status') == 'success':
100
+ LAST_SUCCESSFUL_STATUS = DATA_STATUS
101
+
102
+ print(f"✅ Status file loaded successfully: {DATA_STATUS}")
103
+ except Exception as e:
104
+ print(f"❌ Could not load status file: {e}")
105
+ # Si no podemos leer el archivo, el estado actual es desconocido.
106
+ DATA_STATUS = {'status': 'unknown', 'last_update_utc': datetime.now(timezone.utc).isoformat()}
107
+
108
+ print(f"🎉 SCHEDULED DATA UPDATE COMPLETED")
109
+ print(f"{'='*60}\n")
110
+
111
+
112
+
113
+ for filename in files_to_update:
114
+ try:
115
+ print(f"🔄 Updating {filename}...")
116
+ new_data = load_and_process_data(files_to_update[filename])
117
+ DISCIPLINE_DATAFRAMES[filename] = new_data
118
+ print(f"✅ {filename} updated successfully! ({len(new_data)} records)")
119
+ except Exception as e:
120
+ print(f"❌ Error updating {filename}: {str(e)}")
121
+
122
+ print(f"🎉 SCHEDULED DATA UPDATE COMPLETED")
123
+ print(f"{'='*60}\n")
124
+
125
+
126
+
127
+ try:
128
+ status_url = 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/branch_actions/update_status.json'
129
+ DATA_STATUS = pd.read_json(status_url, typ='series').to_dict()
130
+
131
+ # Si la carga inicial es exitosa, la guardamos como el último éxito.
132
+ if DATA_STATUS.get('status') == 'success':
133
+ LAST_SUCCESSFUL_STATUS = DATA_STATUS
134
+
135
+ print(f"✅ Initial status loaded: {DATA_STATUS}")
136
+ except Exception as e:
137
+ print(f"❌ Initial status load failed: {e}")
138
+ DATA_STATUS = {'status': 'unknown', 'last_update_utc': datetime.now(timezone.utc).isoformat()}
139
+
140
+
141
+
142
+ # CARGA INICIAL
143
+ DISCIPLINE_DATAFRAMES = {
144
+ 'ROAD.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/ROAD.csv'),
145
+ 'FORMULA.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/FORMULA.csv'),
146
+ 'OVAL.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/OVAL.csv'),
147
+ 'DROAD.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DROAD.csv'),
148
+ 'DOVAL.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DOVAL.csv')
149
+ }
150
+
151
+
152
+ # CONFIGURAR EL SCHEDULER
153
+ scheduler = BackgroundScheduler()
154
+ scheduler.add_job(
155
+ func=update_all_data,
156
+ trigger=IntervalTrigger(hours=12), # Ejecutar cada 2 horas
157
+ id='data_update_job',
158
+ name='Update iRacing Data',
159
+ replace_existing=True
160
+ )
161
+
162
+ # INICIAR EL SCHEDULER
163
+ scheduler.start()
164
+ print("🚀 Automatic data updater started with APScheduler!")
165
+ print("📅 Updates scheduled every 2 hours")
166
+
167
+ # ASEGURAR QUE EL SCHEDULER SE CIERRE AL CERRAR LA APP
168
+ atexit.register(lambda: scheduler.shutdown())
169
+
170
+
171
+
172
  def create_irating_trend_line_chart(df):
173
  """
174
  Crea un gráfico de líneas que muestra el promedio de carreras corridas
 
218
  ))
219
 
220
  fig.update_layout(
 
 
 
 
 
 
221
  template='plotly_dark',
222
  paper_bgcolor='rgba(11,11,19,1)',
223
  plot_bgcolor='rgba(11,11,19,1)',
 
236
  gridwidth=1,
237
  gridcolor='rgba(255,255,255,0.1)'
238
  ),
239
+ margin=dict(l=10, r=10, t=0, b=10),
240
  )
241
  return fig
242
 
 
337
  )
338
  ))
339
 
340
+
341
+ """title=dict(
342
+ text='Regions (Avg. iRating, Avg. Races, Qty. Drivers)',
343
+ font=dict(color='white', size=14),
344
+ x=0.5,
345
+ xanchor='center'
346
+ ),
347
+ font=GLOBAL_FONT,"""
348
  fig.update_layout(
349
+
 
 
 
 
 
 
350
  #xaxis_title='Avg. iRating',
351
  #yaxis_title='Avg. Races',
352
  template='plotly_dark',
 
370
  gridcolor='rgba(255,255,255,0.1)'
371
  ),
372
  # --- FIN DEL ESTILO DE GRID ---
373
+ margin=dict(l=10, r=10, t=0, b=10),
374
  )
375
  return fig
376
 
 
383
  fig = go.Figure()
384
  kpis = [
385
  {'value': total_pilots, 'title': f"Drivers {filter_context}", 'format': ',.0f'},
386
+ {'value': avg_irating, 'title': "Avg iRating", 'format': ',.0f'},
387
+ {'value': avg_starts, 'title': "Avg Starts", 'format': '.1f'},
388
+ {'value': avg_wins, 'title': "Avg Wins", 'format': '.2f'}
389
  ]
390
  for i, kpi in enumerate(kpis):
391
  fig.add_trace(go.Indicator(
 
400
  grid={'rows': 1, 'columns': 4, 'pattern': "independent"},
401
  template='plotly_dark',
402
  paper_bgcolor='rgba(0,0,0,0)',
403
+ plot_bgcolor="#BD1818",
404
  margin=dict(l=20, r=20, t=50, b=10),
405
  height=60,
406
  font=GLOBAL_FONT
 
419
  plot_bgcolor='rgba(0,0,0,0)',
420
  xaxis_visible=False,
421
  yaxis_visible=False,
422
+ height=240, # Aumentamos la altura considerablemente
423
  annotations=[
424
  dict(
425
  text="<b>Select or search a driver</b>",
 
436
  ]
437
  )
438
  return fig
 
439
 
440
  # Si SÍ hay información del piloto, procedemos como antes.
441
  pilot_name = pilot_info.get('DRIVER', 'Piloto')
 
452
 
453
  kpis_piloto = [
454
  {'rank': rank_world, 'percentil': percentil_world, 'title': "World Rank"},
455
+ {'rank': rank_region, 'percentil': percentil_region, 'title': "Region Rank"},
456
  {'rank': rank_country, 'percentil': percentil_country, 'title': "Country Rank"}
457
  ]
458
 
459
+ # Layout vertical con más espacio
460
  for i, kpi in enumerate(kpis_piloto):
461
  fig.add_trace(go.Indicator(
462
  mode="number",
463
  value=kpi['rank'],
464
+ number={'prefix': "#", 'font': {'size': 18}}, # Número más grande
465
+ title={
466
+ "text": f"<b>{kpi['title']}</b><span style='font-size:11px;color:gray'>(Top {100-kpi['percentil']:.1f}%)</span>",
467
+ 'font': {'size': 12} # Título más grande
468
+ },
469
+ domain={
470
+ 'row': i,
471
+ 'column': 0,
472
+ # AGREGAMOS ESPACIADO ESPECÍFICO PARA CADA KPI
473
+ 'y': [0.75 - i*0.32, 0.95 - i*0.32] # Da más espacio vertical a cada KPI
474
+ }
475
  ))
476
+
477
  fig.update_layout(
478
  title={
479
  'text': title_text,
480
+ 'y': 0.98, 'x': 0.5, 'xanchor': 'center', 'yanchor': 'top',
481
+ 'font': {'size': 28}
482
  },
483
+ # CAMBIO: Eliminamos el grid automático y usamos dominios manuales
484
  template='plotly_dark',
485
  paper_bgcolor='rgba(0,0,0,0)',
486
  plot_bgcolor='rgba(0,0,0,0)',
487
+ margin=dict(l=20, r=20, t=35, b=15), # Más margen arriba y abajo
488
+ height=240, # Altura aumentada considerablemente
489
+ font=GLOBAL_FONT,
490
+ showlegend=False
491
  )
492
  return fig
493
 
 
529
 
530
  max_line_trace = go.Scatter(
531
  x=x_trend, y=y_trend_max, mode='lines',
532
+ name='Tendency Max AVG_INC',
533
  line=dict(color='red', width=1, dash='dash')
534
  )
535
 
536
  min_line_trace = go.Scatter(
537
  x=x_trend, y=y_trend_min, mode='lines',
538
+ name='Tendency Min AVG_INC',
539
  line=dict(color='lime', width=1, dash='dash')
540
  )
541
 
 
544
  x=x_trend,
545
  y=y_trend_mean,
546
  mode='lines',
547
+ name='Tendency Average Incidents',
548
  line=dict(color='black', width=2, dash='solid')
549
  )
550
 
 
553
  fig = go.Figure(data=[heatmap_trace, max_line_trace, min_line_trace, mean_line_trace])
554
 
555
  fig.update_layout(
556
+
557
+ font=GLOBAL_FONT,
558
  xaxis_title='iRating',
559
  yaxis_title='Incidents Per Race',
560
  template='plotly_dark',
 
656
  showframe=False, # <-- Oculta el marco exterior del globo
657
  showcoastlines=False # <-- Oculta las líneas de la costa
658
  ),
659
+ margin={"r":0,"t":0,"l":0,"b":0},
660
  coloraxis_showscale=show_scale,
661
  coloraxis_colorbar=dict(
662
  title='Drivers',
 
673
 
674
  def create_histogram_with_percentiles(df, column='IRATING', bin_width=100, highlight_irating=None, highlight_name=None):
675
  # Crear bins específicos de 100 en 100
 
676
  max_val = df[column].max()
677
+ # Por ejemplo, si max_val es 11250, (ceil(11250 / 100)) = 113, * 100 = 11300.
678
+ upper_limit = (np.ceil(max_val / bin_width)) * bin_width
679
+
680
+ # Creamos los bordes de los bins desde 0 hasta el límite superior, en pasos de 100.
681
+ # Esto generará [0, 100, 200, 300, ... , 11300]
682
+ bin_edges = np.arange(0, upper_limit + bin_width, bin_width)
683
+ # --- FIN DE LA CORRECCIÓN ---
684
 
685
  hist, bin_edges = np.histogram(df[column], bins=bin_edges)
686
  bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
 
695
  below = (df[column] < bin_edges[i+1]).sum()
696
  percentile = below / total * 100
697
  top_percent = 100 - percentile
698
+
699
+ # --- CORRECCIÓN: Mostrar el rango correctamente (ej. 0-99) ---
700
  hover_text.append(
701
+ f"Range: {int(bin_edges[i])}-{int(bin_edges[i+1]-1)}<br>"
702
  f"Drivers: {hist[i]}<br>"
703
  f"Top: {top_percent:.2f}%"
704
  )
 
739
  # --- FIN DEL BLOQUE NUEVO ---
740
 
741
  fig.update_layout(
 
 
 
 
 
 
742
  font=GLOBAL_FONT,
743
  xaxis=dict(
744
  title_text='iRating', # Texto del título
 
756
  ),
757
  template='plotly_dark',
758
  hovermode='x unified',
759
+ paper_bgcolor='rgba(18,18,26,0)',
760
  plot_bgcolor='rgba(255,255,255,0)',
761
 
762
 
763
  # --- MODIFICACIÓN: Reducir márgenes y tamaño de fuentes ---
764
+ margin=dict(l=10, r=10, t=0, b=10) # Reduce los márgenes (izquierda, derecha, arriba, abajo) # Reduce el tamaño del título principal
765
 
766
  )
767
 
 
790
  )
791
  return fig
792
 
793
+ def create_starts_vs_irating_scatter(df):
794
+ """Crea un gráfico de dispersión no interactivo para Carreras vs. iRating."""
795
+
796
+ # Para un rendimiento óptimo, si hay demasiados datos, tomamos una muestra aleatoria.
797
+ # Esto evita sobrecargar el navegador del cliente sin perder la forma general de la distribución.
798
+ if len(df) > 50000:
799
+ df_sample = df.sample(n=50000, random_state=42)
800
+ else:
801
+ df_sample = df
802
+
803
+ # Usamos go.Scattergl que está optimizado para grandes datasets.
804
+ fig = go.Figure(data=go.Scattergl(
805
+ x=df_sample['IRATING'],
806
+ y=df_sample['STARTS'],
807
+ mode='markers',
808
+ marker=dict(
809
+ color='rgba(0, 111, 255, 0.3)', # Color azul semitransparente
810
+ # --- CORRECCIÓN: Puntos 50% más grandes (de 4 a 6) ---
811
+ size=6,
812
+ line=dict(width=0)
813
+ ),
814
+ # Desactivamos el hover para máxima velocidad ya que es estático.
815
+ hoverinfo='none'
816
+ ))
817
+
818
+ fig.update_layout(
819
+
820
+ font=GLOBAL_FONT,
821
+ xaxis_title='iRating',
822
+ yaxis_title='Races (Starts)',
823
+ template='plotly_dark',
824
+ paper_bgcolor='rgba(0,0,0,0)',
825
+ plot_bgcolor='rgba(0,0,0,0)',
826
+ xaxis=dict(range=[0, 12000], showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
827
+ # --- CORRECCIÓN: Altura máxima de 1500 en el eje Y ---
828
+ yaxis=dict(range=[0, 1500], showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
829
+ legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99)
830
+ )
831
+ return fig
832
+
833
+
834
  def flag_img(code):
835
  url = f"https://flagcdn.com/16x12/{code.lower()}.png"
836
  # La función ahora asume que si el código llega aquí, es válido.
 
839
 
840
  GLOBAL_FONT = {'family': "Lato, sans-serif"}
841
 
 
 
 
 
 
 
 
 
842
  country_coords = {
843
  'ES': {'lat': 40.4, 'lon': -3.7}, 'US': {'lat': 39.8, 'lon': -98.5},
844
  'BR': {'lat': -14.2, 'lon': -51.9}, 'DE': {'lat': 51.1, 'lon': 10.4},
 
906
  #df['LOCATION'] = 'a'
907
  density_heatmap = dcc.Graph(
908
  id='density-heatmap',
909
+ # --- CORRECCIÓN: Ajustamos la altura para que sea más visible ---
910
+ style={'height': '600px', 'borderRadius': '15px', 'overflow': 'hidden'},
911
+ figure=create_density_heatmap(df_for_graphs),
912
+ config={'displayModeBar': False} # <-- AÑADIMOS ESTO
913
  )
914
  correlation_heatmap = dcc.Graph(
915
  id='correlation-heatmap',
916
+ # --- CORRECCIÓN: Ajustamos la altura a un valor más estándar ---
917
+ style={'height': '500px'},
918
  # Usamos las columnas numéricas del dataframe original
919
+ figure=create_correlation_heatmap(df[['IRATING', 'STARTS', 'WINS','TOP25PCNT','AVG_INC','AVG_FINISH_POS']]),
920
+ config={'displayModeBar': False} # <-- AÑADIMOS ESTO
921
  )
922
 
923
 
 
947
  sort_mode="single",
948
  page_action="custom",
949
  page_current=0,
950
+ page_size=100, # CAMBIO: De 20 a 100 elementos por página
951
+ page_count=len(df_table) // 100 + (1 if len(df_table) % 100 > 0 else 0), # CAMBIO: Actualizar cálculo
952
  virtualization=False,
953
  style_as_list_view=True,
954
  active_cell={'row': 21,'column':1},
955
 
 
 
 
 
956
  style_table={
 
957
  'overflowX': 'auto',
958
+ 'overflowY': 'auto', # SCROLL VERTICAL habilitado
959
+ 'height': '100%', # CAMBIO: Usar 100% del contenedor padre
960
+ 'maxHeight': '100%', # CAMBIO: Usar 100% del contenedor padre
961
+ 'minHeight': '700px', # CAMBIO: Altura mínima aumentada
962
  'width': '100%',
963
  'borderRadius': '15px',
964
  'overflow': 'hidden',
965
  'backgroundColor': 'rgba(11,11,19,1)',
 
966
  'border': '1px solid #4A4A4A'
 
967
  },
968
 
969
  style_cell={
970
  'textAlign': 'center',
971
+ 'padding': '6px 3px', # CAMBIO: Padding más compacto para aprovechar espacio
972
  'backgroundColor': 'rgba(11,11,19,1)',
973
  'color': 'rgb(255, 255, 255,.8)',
974
+ 'border': '1px solid rgba(255, 255, 255, 0.1)',
975
  'overflow': 'hidden',
976
  'textOverflow': 'ellipsis',
977
+ 'whiteSpace': 'nowrap',
978
+ 'maxWidth': 0,
979
+ 'fontSize': '11px' # CAMBIO: Fuente ligeramente más pequeña para más filas
980
  },
981
+
982
  style_header={
983
  'backgroundColor': 'rgba(30,30,38,1)',
984
  'fontWeight': 'bold',
985
  'color': 'white',
986
+ 'border': '1px solid rgba(255, 255, 255, 0.2)',
987
  'textAlign': 'center',
988
+ 'fontSize': '12px',
989
+ 'position': 'sticky', # Header pegajoso
990
+ 'top': 0, # Se queda arriba al hacer scroll
991
+ 'zIndex': 10, # Prioridad visual
992
+ 'padding': '6px 3px' # CAMBIO: Padding más compacto
993
  },
994
+
995
  # --- AÑADIMOS ESTILO PARA LA FILA SELECCIONADA Y LAS CLASES ---
996
  style_data_conditional=[
997
  {
 
1045
 
1046
  scatter_irating_starts = dcc.Graph(
1047
  id='scatter-irating',
1048
+ style={'height': '30vh','borderRadius': '15px','overflow': 'hidden'},
1049
  # Usamos go.Scattergl en lugar de px.scatter para un rendimiento masivo
1050
  figure=go.Figure(data=go.Scattergl(
1051
  x=df['IRATING'],
 
1090
  app = dash.Dash(__name__)
1091
  server = app.server # <-- AÑADE ESTA LÍNEA
1092
 
1093
+ # Layout principal MODIFICADO
1094
  app.layout = html.Div(
1095
+ style={
1096
+ 'margin': '0',
1097
+ 'padding': '0',
1098
+ 'backgroundColor': 'rgba(5,5,15,255)',
1099
+ 'color': '#ffffff',
1100
+ 'fontFamily': 'Lato, sans-serif',
1101
+ 'minHeight': '100vh'
1102
+ },
1103
  children=[
1104
+ # CONTENEDOR PRINCIPAL CENTRADO
 
 
1105
  html.Div(
1106
+ style={
1107
+ 'maxWidth': '1400px',
1108
+ 'margin': '0 auto',
1109
+ 'padding': '20px 5vw',
1110
+ 'minHeight': '100vh',
1111
+ 'display': 'flex',
1112
+ 'flexDirection': 'column',
1113
+ 'gap': '30px'
1114
+ },
1115
  children=[
1116
 
1117
+ # 1. SECCIÓN HEADER - Título y Botones
1118
  html.Div(
1119
+ style={
1120
+ 'textAlign': 'center',
1121
+ 'marginBottom': '20px'
1122
+ },
1123
  children=[
1124
+ html.H1(
1125
+ "🏁 Top iRating",
1126
+ style={
1127
+ 'fontSize': 'clamp(36px, 5vw, 48px)',
1128
+ 'color': 'white',
1129
+ 'margin': '0 0 20px 0',
1130
+ 'fontWeight': '900'
1131
+ }
1132
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1133
 
1134
+ # --- AÑADE ESTE COMPONENTE AQUÍ ---
1135
+ html.P(
1136
+ id='last-update-display',
1137
+ style={
1138
+ 'color': '#A0A0A0',
1139
+ 'fontSize': '12px',
1140
+ 'margin': '-15px 0 20px 0', # Margen para acercarlo al título
1141
+ 'fontStyle': 'italic'
1142
+ }
1143
  ),
1144
+ html.P(
1145
+ "Only drivers with 1 < race start and 1 < irating are consider",
1146
+ style={
1147
+ 'color': '#A0A0A0',
1148
+ 'fontSize': '12px',
1149
+ 'margin': '-15px 0 20px 0', # Margen para acercarlo al título
1150
+ 'fontStyle': 'italic'
1151
+ }
 
 
 
 
 
 
 
 
 
1152
  ),
1153
+ # --- FIN DEL BLOQUE A AÑADIR ---
1154
 
1155
  html.Div(
 
1156
  style={
1157
+ 'display': 'flex',
1158
+ 'justifyContent': 'center',
1159
+ 'gap': '10px',
1160
+ 'flexWrap': 'wrap'
1161
+ },
1162
+ children=[
1163
+ html.Button('Sports Car', id='btn-road', n_clicks=0, className='dashboard-type-button'),
1164
+ html.Button('Formula', id='btn-formula', n_clicks=0, className='dashboard-type-button'),
1165
+ html.Button('Oval', id='btn-oval', n_clicks=0, className='dashboard-type-button'),
1166
+ html.Button('Dirt Road', id='btn-dirt-road', n_clicks=0, className='dashboard-type-button'),
1167
+ html.Button('Dirt Oval', id='btn-dirt-oval', n_clicks=0, className='dashboard-type-button'),
1168
+ ]
1169
+ )
1170
  ]
1171
  ),
1172
 
1173
+ # 2. SECCIÓN MAPA Y KPIs GLOBALES - Solo KPIs globales superpuestos
1174
  html.Div(
1175
+ style={
1176
+ 'position': 'relative',
1177
+ 'top': '0px',
1178
+ 'backgroundColor': 'transparent', # Fondo transparente
1179
+ 'borderRadius': '0px', # Sin bordes redondeados
1180
+ 'border': '0px solid #4A4A4A',
1181
+ 'padding': '20px',
1182
+ 'overflow': 'hidden'
1183
+ },
1184
+ children=[
1185
+ # Solo KPIs globales superpuestos
1186
  html.Div(
1187
+ style={
1188
+ 'position': 'absolute',
1189
+ 'top': '0px',
1190
+ 'left': '50%',
1191
+ 'transform': 'translateX(-50%)',
1192
+ 'width': '80%',
1193
+ 'maxWidth': '800px',
1194
+ 'zIndex': '10',
1195
+ 'backgroundColor': 'rgba(11,11,19,0)',
1196
+ 'borderRadius': '10px',
1197
+ 'border': '0px solid #4A4A4A',
1198
+ 'padding': '10px'
1199
+ },
1200
  children=[
1201
+ dcc.Graph(id='kpi-global', style={'height': '50px', 'margin': '0'})
 
 
 
 
 
 
 
 
1202
  ]
1203
  ),
1204
+ # Mapa de fondo
1205
+ dcc.Graph(
1206
+ id='continent-map',
1207
+
1208
+ style={'height': '650px',
1209
+ 'backgroundColor': 'transparent',
1210
+ 'margin': '0'},
1211
+ config={'displayModeBar': False}
1212
+ )
1213
+ ]
1214
+ ),
1215
+
1216
+ # 3. SECCIÓN FILTROS Y TABLA - Lado a lado CON KPIs DEL PILOTO
1217
+ html.Div(
1218
+ style={
1219
+ 'display': 'flex',
1220
+ 'gap': '20px',
1221
+ 'flexWrap': 'wrap'
1222
+
1223
+ },
1224
+ children=[
1225
+ # Contenedor de Filtros CON KPIs DEL PILOTO
1226
  html.Div(
 
1227
  style={
1228
+ 'flex': '1',
1229
+ 'minWidth': '300px',
1230
+ 'maxWidth': '600px',
1231
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1232
+ 'borderRadius': '15px',
1233
+ 'border': '1px solid #4A4A4A',
1234
+ 'padding': '20px'
1235
+ },
1236
+ children=[
1237
+ html.H3(
1238
+ "Filters",
1239
+ style={
1240
+ 'color': 'white',
1241
+ 'textAlign': 'center',
1242
+ 'marginBottom': '20px',
1243
+ 'fontWeight': '700'
1244
+ }
1245
+ ),
1246
 
1247
+ # Filtros de región y país
1248
+ html.Div(
1249
+ style={
1250
+ 'display': 'flex',
1251
+ 'gap': '15px',
1252
+ 'marginBottom': '20px',
1253
+ 'flexDirection': 'column'
1254
+ },
1255
+ children=[
1256
+ html.Div([
1257
+
1258
+ html.Label(
1259
+ "Region:",
1260
+ style={
1261
+ 'color': 'white',
1262
+ 'fontSize': '14px',
1263
+ 'marginBottom': '5px',
1264
+ 'display': 'block',
1265
+ 'textAlign': 'center'
1266
+ }
1267
+ ),
1268
+ dcc.Dropdown(
1269
+ id='region-filter',
1270
+ options=[{'label': 'All', 'value': 'ALL'}] +
1271
+ [{'label': region, 'value': region} for region in sorted(iracing_ragions.keys())],
1272
+ value='ALL',
1273
+ className='iracing-dropdown'
1274
+ )
1275
+ ]),
1276
+
1277
+ html.Div([
1278
+ html.Label(
1279
+ "Country:",
1280
+ style={
1281
+ 'color': 'white',
1282
+ 'fontSize': '14px',
1283
+ 'marginBottom': '5px',
1284
+ 'display': 'block',
1285
+ 'textAlign': 'center'
1286
+ }
1287
+ ),
1288
+ dcc.Dropdown(
1289
+ id='country-filter',
1290
+ options=[{'label': 'All', 'value': 'ALL'}],
1291
+ value='ALL',
1292
+ className='iracing-dropdown'
1293
+ )
1294
+ ])
1295
+ ]
1296
+ ),
1297
+
1298
+ # Búsqueda de piloto
1299
+ html.Div([
1300
+ html.Label(
1301
+ "Search Driver:",
1302
+ style={
1303
+ 'color': 'white',
1304
+ 'fontSize': '14px',
1305
+ 'marginBottom': '5px',
1306
+ 'display': 'block',
1307
+ 'textAlign': 'center'
1308
+ }
1309
+ ),
1310
+ dcc.Dropdown(
1311
+ id='pilot-search-dropdown',
1312
+ options=[],
1313
+ placeholder='Search Driver...',
1314
+ className='iracing-dropdown',
1315
+ searchable=True,
1316
+ clearable=True,
1317
+ search_value=''
1318
+ ),
1319
+ # --- NUEVOS COMPONENTES PARA DEBOUNCING ---
1320
+ dcc.Interval(
1321
+ id='search-debounce-interval',
1322
+ interval=400, # 400ms de delay
1323
+ n_intervals=0,
1324
+ disabled=True # Inicialmente deshabilitado
1325
+ ),
1326
+ dcc.Store(id='last-search-store', data='')
1327
+ ]),
1328
+
1329
+ # NUEVA SECCIÓN: KPIs del piloto debajo de los filtros
1330
+ html.Div(
1331
+ style={
1332
+ 'marginTop': '30px',
1333
+ 'padding': '15px',
1334
+ 'backgroundColor': 'rgba(11,11,19,0.8)',
1335
+ 'borderRadius': '10px',
1336
+ 'border': '1px solid #4A4A4A'
1337
+ },
1338
+ children=[
1339
+ html.H4(
1340
+ "Selected Driver",
1341
+ style={
1342
+ 'color': 'white',
1343
+ 'textAlign': 'center',
1344
+ 'marginBottom': '15px',
1345
+ 'fontSize': '16px',
1346
+ 'fontWeight': '700'
1347
+ }
1348
+ ),
1349
+ dcc.Graph(
1350
+ id='kpi-pilot',
1351
+ style={
1352
+ 'height': '260px', # Aumentamos altura del contenedor
1353
+ 'margin': '0'
1354
+ }
1355
+ )
1356
+ ]
1357
+ )
1358
+ ]
1359
  ),
1360
+
1361
+ # Contenedor de Tabla MODIFICADO
1362
  html.Div(
 
1363
  style={
1364
+ 'flex': '2',
1365
+ 'minWidth': '300px',
1366
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1367
+ 'borderRadius': '15px',
1368
+ 'border': '1px solid #4A4A4A',
1369
+ 'padding': '20px',
1370
+ 'display': 'flex', # CAMBIO: Usar flexbox
1371
+ 'flexDirection': 'column', # CAMBIO: Dirección vertical
1372
+ 'height': '880px' # CAMBIO: Altura fija del contenedor
1373
+ },
1374
+ children=[
1375
+ html.H3(
1376
+ "Rankings",
1377
+ style={
1378
+ 'color': 'white',
1379
+ 'textAlign': 'center',
1380
+ 'marginBottom': '20px',
1381
+ 'fontWeight': '700',
1382
+ 'flexShrink': 0 # CAMBIO: No se encoge
1383
+ }
1384
+ ),
1385
+ # Contenedor específico para la tabla
1386
+ html.Div(
1387
+ style={
1388
+ 'flex': '1', # CAMBIO: Ocupa todo el espacio restante
1389
+ 'display': 'flex',
1390
+ 'flexDirection': 'column',
1391
+ 'minHeight': 0 # CAMBIO: Permite que se encoja si es necesario
1392
+ },
1393
+ children=[
1394
+ dash_table.DataTable(
1395
+ id='datatable-interactiva',
1396
+ data=[],
1397
+ sort_action="custom",
1398
+ sort_mode="single",
1399
+ page_action="custom",
1400
+ page_current=0,
1401
+ page_size=100, # CAMBIO: 100 elementos por página
1402
+ page_count=len(df_table) // 100 + (1 if len(df_table) % 100 > 0 else 0),
1403
+ virtualization=False,
1404
+ style_as_list_view=True,
1405
+ active_cell={'row': 101,'column':1},
1406
+
1407
+ style_table={
1408
+ 'overflowX': 'auto',
1409
+ 'overflowY': 'auto',
1410
+ 'height': '100%', # CAMBIO: Usa todo el espacio del contenedor padre
1411
+ 'maxHeight': '100%', # CAMBIO: No limitar altura
1412
+ 'minHeight': '820px',
1413
+ 'width': '100%',
1414
+ 'borderRadius': '15px',
1415
+ 'overflow': 'hidden',
1416
+ 'backgroundColor': 'rgba(11,11,19,1)',
1417
+ 'border': '1px solid #4A4A4A'
1418
+ },
1419
+
1420
+ style_cell={
1421
+ 'textAlign': 'center',
1422
+ 'padding': '3px 1px', # Padding más compacto
1423
+ 'backgroundColor': 'rgba(11,11,19,1)',
1424
+ 'height': '15px',
1425
+ 'color': 'rgb(255, 255, 255,.8)',
1426
+ 'border': '1px solid rgba(255, 255, 255, 0.1)',
1427
+ 'overflow': 'hidden',
1428
+ 'textOverflow': 'ellipsis',
1429
+ 'whiteSpace': 'nowrap',
1430
+ 'maxWidth': 0,
1431
+ 'fontSize': '11px' # Fuente más compacta
1432
+ },
1433
+
1434
+ style_header={
1435
+ 'backgroundColor': 'rgba(30,30,38,1)',
1436
+ 'fontWeight': 'bold',
1437
+ 'color': 'white',
1438
+ 'border': '1px solid rgba(255, 255, 255, 0.2)',
1439
+ 'textAlign': 'center',
1440
+ 'fontSize': '12px',
1441
+ 'position': 'sticky',
1442
+ 'top': 0,
1443
+ 'zIndex': 10,
1444
+ 'padding': '6px 3px'
1445
+ },
1446
+
1447
+ # Mantén todos tus estilos existentes
1448
+ style_data_conditional=[
1449
+ {
1450
+ 'if': {'state': 'active'},
1451
+ 'backgroundColor': 'rgba(0, 111, 255, 0.3)',
1452
+ 'border': '1px solid rgba(0, 111, 255)'
1453
+ },
1454
+ {
1455
+ 'if': {'state': 'selected'},
1456
+ 'backgroundColor': 'rgba(0, 111, 255, 0)',
1457
+ 'border': '1px solid rgba(0, 111, 255,0)'
1458
+ },
1459
+ # --- REGLAS MEJORADAS CON BORDES REDONDEADOS ---
1460
+ {'if': {'filter_query': '{CLASS} contains "P"','column_id': 'CLASS'},
1461
+ 'backgroundColor': 'rgba(54,54,62,255)', 'color': 'rgba(166,167,171,255)', 'fontWeight': 'bold','border': '1px solid rgba(134,134,142,255)'},
1462
+
1463
+ {'if': {'filter_query': '{CLASS} contains "A"','column_id': 'CLASS'},
1464
+ 'backgroundColor': 'rgba(0,42,102,255)', 'color': 'rgba(107,163,238,255)', 'fontWeight': 'bold','border': '1px solid rgba(35,104,195,255)'},
1465
+
1466
+ {'if': {'filter_query': '{CLASS} contains "B"','column_id': 'CLASS'},
1467
+ 'backgroundColor': 'rgba(24,84,14,255)', 'color': 'rgba(139,224,105,255)', 'fontWeight': 'bold','border': '1px solid rgba(126,228,103,255)'},
1468
+
1469
+ {'if': {'filter_query': '{CLASS} contains "C"','column_id': 'CLASS'},
1470
+ 'backgroundColor': 'rgba(81,67,6,255)', 'color': 'rgba(224,204,109,255)', 'fontWeight': 'bold','border': '1px solid rgba(220,193,76,255)'},
1471
+
1472
+ {'if': {'filter_query': '{CLASS} contains "D"','column_id': 'CLASS'},
1473
+ 'backgroundColor': 'rgba(102,40,3,255)', 'color': 'rgba(255,165,105,255)', 'fontWeight': 'bold','border': '1px solid rgba(208,113,55,255)'},
1474
+
1475
+ {'if': {'filter_query': '{CLASS} contains "R"','column_id': 'CLASS'},
1476
+ 'backgroundColor': 'rgba(91,19,20,255)', 'color': 'rgba(225,125,123,255)', 'fontWeight': 'bold','border': '1px solid rgba(172,62,61,255)'},
1477
+ ],
1478
+
1479
+ style_cell_conditional=[
1480
+ {'if': {'column_id': 'CLASS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
1481
+ {'if': {'column_id': 'Rank World'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
1482
+ {'if': {'column_id': 'Rank Region'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
1483
+ {'if': {'column_id': 'Rank Country'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
1484
+ {'if': {'column_id': 'DRIVER'}, 'width': '30%', 'minWidth': '30%', 'maxWidth': '30%', 'textAlign': 'center'},
1485
+ {'if': {'column_id': 'IRATING'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
1486
+ {'if': {'column_id': 'LOCATION'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%', 'justify-content': 'center', 'align-items': 'center'},
1487
+ {'if': {'column_id': 'WINS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
1488
+ {'if': {'column_id': 'STARTS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
1489
+ {'if': {'column_id': 'REGION'}, 'width': '20%', 'minWidth': '20%', 'maxWidth': '20%'},
1490
+ ]
1491
+ )
1492
+ ]
1493
+ )
1494
+ ]
1495
+ )
1496
+ ]
1497
+ ),
1498
+
1499
+ # 4. SECCIÓN HISTOGRAMA - Ancho completo (sin cambios)
1500
+ html.Div(
1501
+ style={
1502
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1503
+ 'borderRadius': '15px',
1504
+ 'border': '1px solid #4A4A4A',
1505
+ 'padding': '20px'
1506
+ },
1507
+ children=[
1508
+ html.H3(
1509
+ "iRating Distribution",
1510
  style={
1511
+ 'color': 'white',
1512
+ 'textAlign': 'center',
1513
+ 'marginBottom': '20px',
1514
+ 'fontWeight': '700'
1515
  }
1516
  ),
1517
+ dcc.Graph(
1518
+ id='histogram-plot',
1519
+ style={'height': '350px'},
1520
+ config={'displayModeBar': False}
1521
+ )
1522
  ]
1523
  ),
1524
 
1525
+ # 5. SECCIÓN GRÁFICOS ADICIONALES (sin cambios)
1526
  html.Div(
1527
+ style={
1528
+ 'display': 'flex',
1529
+ 'flexDirection': 'column',
1530
+ 'gap': '20px'
1531
+ },
1532
  children=[
1533
+ # Primera fila: Tabla de competitividad (sin cambios)
1534
+ html.Div(
1535
+ style={
1536
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1537
+ 'borderRadius': '15px',
1538
+ 'border': '1px solid #4A4A4A',
1539
+ 'padding': '20px'
1540
+ },
1541
+ children=[
1542
+ html.H3(
1543
+ "Top Competitive Regions & Countries",
1544
+ style={
1545
+ 'color': 'white',
1546
+ 'textAlign': 'center',
1547
+ 'marginBottom': '10px',
1548
+ 'fontWeight': '700'
1549
+ }
1550
+ ),
1551
+ html.P(
1552
+ "Based on average iRating of top 100 drivers per region/country (minimum 100 drivers required)",
1553
+ style={
1554
+ 'color': '#CCCCCC',
1555
+ 'textAlign': 'center',
1556
+ 'marginBottom': '20px',
1557
+ 'fontSize': '12px',
1558
+ 'fontStyle': 'italic'
1559
+ }
1560
+ ),
1561
+ html.Div(id='competitiveness-tables-container')
1562
+ ]
1563
+ ),
1564
 
1565
+ # CAMBIO: Ahora los gráficos están en columna vertical
1566
+ # Primer gráfico: Regional Analysis
1567
+ html.Div(
1568
+ style={
1569
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1570
+ 'borderRadius': '15px',
1571
+ 'border': '1px solid #4A4A4A',
1572
+ 'padding': '20px'
1573
+ },
1574
+ children=[
1575
+ html.H3(
1576
+ "Regional Analysis",
1577
+ style={
1578
+ 'color': 'white',
1579
+ 'textAlign': 'center',
1580
+ 'marginBottom': '5px',
1581
+ 'fontWeight': '700'
1582
+ }
1583
+ ),
1584
+ html.P(
1585
+ "Average races with average iRating relation, bubble size represents quantity drivers in region.",
1586
+ style={
1587
+ 'color': '#CCCCCC',
1588
+ 'textAlign': 'center',
1589
+ 'marginBottom': '20px',
1590
+ 'fontSize': '12px',
1591
+ 'fontStyle': 'italic'
1592
+ }
1593
+ ),
1594
+ dcc.Graph(
1595
+ id='region-bubble-chart',
1596
+ style={'height': '400px'}, # Aumentamos altura ya que ahora ocupa todo el ancho
1597
+ config={'displayModeBar': False}
1598
+ )
1599
+ ]
1600
  ),
1601
 
1602
+ # Segundo gráfico: Experience vs Performance
1603
+ html.Div(
1604
+ style={
1605
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1606
+ 'borderRadius': '15px',
1607
+ 'border': '1px solid #4A4A4A',
1608
+ 'padding': '20px'
1609
+ },
1610
+ children=[
1611
+ html.H3(
1612
+ "Average Races vs iRating",
1613
+ style={
1614
+ 'color': 'white',
1615
+ 'textAlign': 'center',
1616
+ 'marginBottom': '5px',
1617
+ 'fontWeight': '700'
1618
+ }
1619
+ ),
1620
+ html.P(
1621
+ "Average races for iRating ranges",
1622
+ style={
1623
+ 'color': '#CCCCCC',
1624
+ 'textAlign': 'center',
1625
+ 'marginBottom': '20px',
1626
+ 'fontSize': '12px',
1627
+ 'fontStyle': 'italic'
1628
+ }
1629
+ ),
1630
+ dcc.Graph(
1631
+ id='irating-starts-scatter',
1632
+ style={'height': '400px'}, # Aumentamos altura ya que ahora ocupa todo el ancho
1633
+ config={'displayModeBar': False}
1634
+ )
1635
+ ]
1636
+ ),
1637
+
1638
+ # --- INICIO DE GRÁFICOS AÑADIDOS ---
1639
+
1640
+ # Tercer gráfico: Density Heatmap (iRating vs Incidents)
1641
+ html.Div(
1642
+ style={
1643
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1644
+ 'borderRadius': '15px',
1645
+ 'border': '1px solid #4A4A4A',
1646
+ 'padding': '10px'
1647
+ },
1648
+ children=[
1649
+ html.H3(
1650
+ "Incidents vs iRating",
1651
+ style={
1652
+ 'color': 'white',
1653
+ 'textAlign': 'center',
1654
+ 'marginBottom': '5px',
1655
+ 'fontWeight': '700'
1656
+ }
1657
+ ),
1658
+ html.P(
1659
+ "Correlation between incidents and iRating",
1660
+ style={
1661
+ 'color': '#CCCCCC',
1662
+ 'textAlign': 'center',
1663
+ 'marginBottom': '0px',
1664
+ 'fontSize': '12px',
1665
+ 'fontStyle': 'italic'
1666
+ }
1667
+ ),
1668
+ # Usamos la variable que ya definimos
1669
+ density_heatmap
1670
+ ]
1671
+ ),
1672
+ html.Div(
1673
+ style={
1674
+ 'backgroundColor': 'rgba(18,18,26,.5)',
1675
+ 'borderRadius': '15px',
1676
+ 'border': '1px solid #4A4A4A',
1677
+ 'padding': '10px'
1678
+ },
1679
+ children=[
1680
+ html.H3(
1681
+ "Races vs iRating", # <-- Título actualizado
1682
+ style={
1683
+ 'color': 'white',
1684
+ 'textAlign': 'center',
1685
+ 'marginBottom': '5px',
1686
+ 'fontWeight': '700'
1687
+ }
1688
+ ),
1689
+ html.P(
1690
+ "iRating - races scatter",
1691
+ style={
1692
+ 'color': '#CCCCCC',
1693
+ 'textAlign': 'center',
1694
+ 'marginBottom': '0px',
1695
+ 'fontSize': '12px',
1696
+ 'fontStyle': 'italic'
1697
+ }
1698
+ ),
1699
+ # Añadimos el nuevo gráfico aquí
1700
+ dcc.Graph(
1701
+ id='starts-vs-irating-heatmap',
1702
+ style={'height': '600px'},
1703
+ config={'staticPlot': True} # <-- CLAVE: Hace el gráfico no interactivo
1704
+ )
1705
+ ]
1706
  )
1707
+ # --- FIN DE GRÁFICOS AÑADIDOS ---
1708
  ]
1709
  )
1710
  ]
1711
  ),
1712
 
1713
+ # Componentes ocultos (sin cambios)
 
1714
  dcc.Store(id='active-discipline-store', data='ROAD.csv'),
1715
  dcc.Store(id='shared-data-store', data={}),
1716
  dcc.Store(id='shared-data-store_1', data={}),
1717
+ html.Div(id='pilot-info-display', style={'display': 'none'}),
1718
+ # --- AÑADE ESTE COMPONENTE AQUÍ ---
1719
+ dcc.Interval(
1720
+ id='update-timestamp-interval',
1721
+ interval=60 * 1000, # Cada minuto (en milisegundos)
1722
+ n_intervals=0
1723
+ )
1724
+ # --- FIN DEL BLOQUE A AÑADIR ---
1725
  ]
1726
  )
1727
 
1728
  # --- 4. Callbacks ---
1729
 
1730
+ # --- CALLBACK MODIFICADO PARA MOSTRAR LA FECHA CON ESTADO Y COLORES ---
1731
+ @app.callback(
1732
+ Output('last-update-display', 'children'),
1733
+ Input('update-timestamp-interval', 'n_intervals')
1734
+ )
1735
+ def update_timestamp_display(n):
1736
+ if not DATA_STATUS:
1737
+ return "Checking data status..."
1738
+
1739
+ status = DATA_STATUS.get('status', 'unknown')
1740
+
1741
+ try:
1742
+ # Convertimos la fecha UTC del archivo a un objeto datetime
1743
+ last_update_utc = datetime.fromisoformat(DATA_STATUS['last_update_utc'].replace('Z', '+00:00'))
1744
+ # Calculamos cuánto tiempo ha pasado
1745
+ age = datetime.now(timezone.utc) - last_update_utc
1746
+
1747
+ # Convertimos a la hora local para mostrarla
1748
+ last_update_local = last_update_utc.astimezone(tz=None)
1749
+ formatted_time = last_update_local.strftime('%B %d, %Y')
1750
+
1751
+ # Lógica de colores y mensajes
1752
+ if status.lower() != 'success':
1753
+ color = '#FF5A5A' # Rojo para fallos
1754
+ message = f"⚠️ Last update attempt failed on: {formatted_time}"
1755
+ elif age.total_seconds() > (13 * 3600): # Si tiene más de 13 horas (damos 1h de margen)
1756
+ color = '#FFA500' # Naranja para datos viejos
1757
+ message = f"Warning: Data is over 12 hours old. Last updated: {formatted_time}"
1758
+ else:
1759
+ color = '#50C878' # Verde para éxito
1760
+ message = f"Data last updated on: {formatted_time}"
1761
+
1762
+ except Exception as e:
1763
+ color = '#FF5A5A'
1764
+ message = f"Error reading update status: {e}"
1765
+
1766
+ # Devolvemos un componente html.P para poder aplicarle el estilo de color
1767
+ return html.P(message, style={'color': color, 'margin': 0, 'padding': 0})
1768
+
1769
+ # --- FIN DEL CALLBACK MODIFICADO ---
1770
+
1771
+
1772
+
1773
 
1774
  # --- NUEVO CALLBACK PARA ACTUALIZAR GRÁFICOS DE LA COLUMNA DERECHA ---
1775
  @app.callback(
1776
  Output('competitiveness-tables-container', 'children'),
1777
  Output('region-bubble-chart', 'figure'),
1778
  Output('irating-starts-scatter', 'figure'),
1779
+ Output('density-heatmap', 'figure'),
1780
+ # --- CAMBIO: Reemplazamos la salida del gráfico de correlación por el nuevo ---
1781
+ Output('starts-vs-irating-heatmap', 'figure'),
1782
+ Input('active-discipline-store', 'data'))
1783
+
1784
  def update_right_column_graphs(filename):
1785
  # 1. Cargar y procesar los datos de la disciplina seleccionada
1786
+ df_discipline = DISCIPLINE_DATAFRAMES[filename]
1787
  df_discipline = df_discipline[df_discipline['IRATING'] > 1]
1788
  df_discipline = df_discipline[df_discipline['STARTS'] > 1]
1789
  df_discipline = df_discipline[df_discipline['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
 
1796
  top_regions.insert(0, '#', range(1, 1 + len(top_regions)))
1797
  top_countries.insert(0, '#', range(1, 1 + len(top_countries)))
1798
 
1799
+ # Traducir códigos de país a nombres completos
1800
  def get_country_name(code):
1801
  try:
1802
  return pycountry.countries.get(alpha_2=code).name
1803
  except (LookupError, AttributeError):
1804
+ return code
1805
 
1806
  top_countries['LOCATION'] = top_countries['LOCATION'].apply(get_country_name)
 
1807
 
1808
+ # ESTILOS CORREGIDOS - Control estricto de ancho
1809
  table_style_base = {
1810
+ 'style_table': {
1811
+ 'borderRadius': '10px',
1812
+ 'overflow': 'hidden',
1813
+ 'border': '1px solid #4A4A4A',
1814
+ 'backgroundColor': 'rgba(11,11,19,1)',
1815
+ 'height': '350px',
1816
+ 'overflowY': 'auto',
1817
+ 'overflowX': 'hidden', # CLAVE: Prevenir scroll horizontal
1818
+ 'width': '100%',
1819
+ 'maxWidth': '100%' # CLAVE: Forzar límite de ancho
1820
+ },
1821
+ 'style_cell': {
1822
+ 'textAlign': 'center',
1823
+ 'padding': '6px 4px', # Padding más compacto
1824
+ 'backgroundColor': 'rgba(11,11,19,1)',
1825
+ 'color': 'rgb(255, 255, 255,.8)',
1826
+ 'border': 'none',
1827
+ 'fontSize': '11px', # Fuente más pequeña
1828
+ 'textOverflow': 'ellipsis',
1829
+ 'whiteSpace': 'nowrap',
1830
+ 'overflow': 'hidden',
1831
+ 'maxWidth': '0' # CLAVE: Forzar truncado de texto
1832
+ },
1833
+ 'style_header': {
1834
+ 'backgroundColor': 'rgba(30,30,38,1)',
1835
+ 'fontWeight': 'bold',
1836
+ 'color': 'white',
1837
+ 'border': 'none',
1838
+ 'textAlign': 'center',
1839
+ 'fontSize': '12px',
1840
+ 'padding': '6px 4px',
1841
+ 'overflow': 'hidden',
1842
+ 'textOverflow': 'ellipsis'
1843
+ },
1844
  'style_cell_conditional': [
1845
+ {'if': {'column_id': '#'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
1846
+ {'if': {'column_id': 'REGION'}, 'width': '60%', 'minWidth': '60%', 'maxWidth': '60%', 'textAlign': 'left'},
1847
+ {'if': {'column_id': 'LOCATION'}, 'width': '60%', 'minWidth': '60%', 'maxWidth': '60%', 'textAlign': 'left'},
1848
+ {'if': {'column_id': 'avg_irating'}, 'width': '30%', 'minWidth': '30%', 'maxWidth': '30%'},
1849
  ]
1850
  }
1851
 
1852
+ # CONTENEDOR CON CONTROL ESTRICTO DE ANCHO
1853
  competitiveness_tables = html.Div(
1854
+ style={
1855
+ 'display': 'grid',
1856
+ 'gridTemplateColumns': '1fr 1fr',
1857
+ 'gap': '15px', # Gap más pequeño
1858
+ 'width': '100%',
1859
+ 'maxWidth': '100%',
1860
+ 'overflow': 'hidden',
1861
+ 'boxSizing': 'border-box' # CLAVE: Incluir padding en el ancho
1862
+ },
1863
  children=[
1864
+ # Tabla de Regiones
1865
+ html.Div(
1866
+ style={
1867
+ 'width': '100%',
1868
+ 'maxWidth': '100%',
1869
+ 'overflow': 'hidden',
1870
+ 'boxSizing': 'border-box' # CLAVE: Control de ancho
1871
+ },
1872
+ children=[
1873
+ html.H4(
1874
+ "Top Regions",
1875
+ style={
1876
+ 'color': 'white',
1877
+ 'textAlign': 'center',
1878
+ 'marginBottom': '10px',
1879
+ 'fontSize': '14px',
1880
+ 'margin': '0 0 10px 0'
1881
+ }
1882
+ ),
1883
+ html.Div(
1884
+ style={
1885
+ 'width': '100%',
1886
+ 'maxWidth': '100%',
1887
+ 'overflow': 'hidden'
1888
+ },
1889
+ children=[
1890
+ dash_table.DataTable(
1891
+ columns=[
1892
+ {'name': '#', 'id': '#'},
1893
+ {'name': 'Region', 'id': 'REGION'},
1894
+ {'name': 'Avg iRating', 'id': 'avg_irating', 'type': 'numeric', 'format': {'specifier': '.0f'}}
1895
+ ],
1896
+ data=top_regions.to_dict('records'),
1897
+ page_action='none',
1898
+ style_table=table_style_base['style_table'],
1899
+ style_cell=table_style_base['style_cell'],
1900
+ style_header=table_style_base['style_header'],
1901
+ style_cell_conditional=table_style_base['style_cell_conditional']
1902
+ )
1903
+ ]
1904
+ )
1905
+ ]
1906
+ ),
1907
+
1908
+ # Tabla de Países
1909
+ html.Div(
1910
+ style={
1911
+ 'width': '100%',
1912
+ 'maxWidth': '100%',
1913
+ 'overflow': 'hidden',
1914
+ 'boxSizing': 'border-box' # CLAVE: Control de ancho
1915
+ },
1916
+ children=[
1917
+ html.H4(
1918
+ "Top Countries",
1919
+ style={
1920
+ 'color': 'white',
1921
+ 'textAlign': 'center',
1922
+ 'marginBottom': '10px',
1923
+ 'fontSize': '14px',
1924
+ 'margin': '0 0 10px 0'
1925
+ }
1926
+ ),
1927
+ html.Div(
1928
+ style={
1929
+ 'width': '100%',
1930
+ 'maxWidth': '100%',
1931
+ 'overflow': 'hidden'
1932
+ },
1933
+ children=[
1934
+ dash_table.DataTable(
1935
+ columns=[
1936
+ {'name': '#', 'id': '#'},
1937
+ {'name': 'Country', 'id': 'LOCATION'},
1938
+ {'name': 'Avg iRating', 'id': 'avg_irating', 'type': 'numeric', 'format': {'specifier': '.0f'}}
1939
+ ],
1940
+ data=top_countries.to_dict('records'),
1941
+ page_action='none',
1942
+ style_table=table_style_base['style_table'],
1943
+ style_cell=table_style_base['style_cell'],
1944
+ style_header=table_style_base['style_header'],
1945
+ style_cell_conditional=table_style_base['style_cell_conditional']
1946
+ )
1947
+ ]
1948
+ )
1949
+ ]
1950
+ )
1951
  ]
1952
  )
1953
 
1954
  # 3. Crear los otros gráficos
1955
  bubble_chart_fig = create_region_bubble_chart(df_discipline)
1956
  line_chart_fig = create_irating_trend_line_chart(df_discipline)
1957
+ # --- AÑADIMOS LA CREACIÓN DE LOS NUEVOS GRÁFICOS ---
1958
+ density_fig = create_density_heatmap(df_discipline)
1959
+ # --- CAMBIO: Creamos la figura para el nuevo gráfico ---
1960
+ starts_scatter_fig = create_starts_vs_irating_scatter(df_discipline)
 
1961
 
1962
 
1963
+ # 4. Devolver todos los componentes actualizados
1964
+ # --- CAMBIO: Devolvemos la nueva figura en lugar de la antigua ---
1965
+ return competitiveness_tables, bubble_chart_fig, line_chart_fig, density_fig, starts_scatter_fig
1966
 
1967
  # --- ELIMINA EL CALLBACK update_data_source ---
1968
 
 
2045
  # Devolvemos el código del país, que actualizará el valor del dropdown 'country-filter'.
2046
  return country_code
2047
 
2048
+ # CALLBACK 1: Inicia el temporizador de debouncing cuando el usuario escribe.
2049
+ @app.callback(
2050
+ Output('search-debounce-interval', 'disabled'),
2051
+ Input('pilot-search-dropdown', 'search_value')
2052
+ )
2053
+ def start_search_debounce(search_value):
2054
+ # Si el texto de búsqueda es muy corto, deshabilita el temporizador.
2055
+ if not search_value or len(search_value) < 3:
2056
+ return True # Deshabilitado
2057
+ # Si hay texto válido, habilita el temporizador (se disparará en 400ms).
2058
+ return False # Habilitado
2059
+
2060
+ # CALLBACK 2: Ejecuta la búsqueda real cuando el temporizador se dispara.
2061
  @app.callback(
2062
  Output('pilot-search-dropdown', 'options'),
2063
+ Output('last-search-store', 'data'),
2064
+ Output('search-debounce-interval', 'disabled', allow_duplicate=True), # Deshabilita el timer después de usarlo
2065
+ Input('search-debounce-interval', 'n_intervals'), # Se activa por el temporizador
2066
+ State('pilot-search-dropdown', 'search_value'),
2067
  State('pilot-search-dropdown', 'value'),
2068
  State('region-filter', 'value'),
2069
  State('country-filter', 'value'),
 
2070
  State('active-discipline-store', 'data'),
2071
+ State('last-search-store', 'data'),
2072
+ prevent_initial_call=True
2073
  )
2074
+ def update_pilot_search_options_debounced(n, search_value, current_selected_pilot,
2075
+ region_filter, country_filter, active_discipline_filename,
2076
+ last_search):
2077
+
2078
+ # --- OPTIMIZACIONES CLAVE ---
2079
+ # 1. Si la búsqueda es inválida o es la misma que la anterior, no hacer nada.
2080
+ if not search_value or len(search_value) < 3 or search_value == last_search:
2081
+ # Devolvemos dash.no_update para las opciones y el store, y deshabilitamos el timer.
2082
+ return dash.no_update, dash.no_update, True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2083
 
2084
+ print(f"🚀 EXECUTING OPTIMIZED SEARCH for: '{search_value}'")
2085
+
2086
+ # --- LÓGICA DE BÚSQUEDA (sin cambios, pero ahora se ejecuta mucho menos) ---
2087
+ df_current_discipline = DISCIPLINE_DATAFRAMES[active_discipline_filename]
2088
 
2089
+ # Filtramos el DataFrame una sola vez
2090
  filtered_df = df_current_discipline
2091
+ if region_filter and region_filter != 'ALL':
2092
  filtered_df = filtered_df[filtered_df['REGION'] == region_filter]
2093
+ if country_filter and country_filter != 'ALL':
2094
  filtered_df = filtered_df[filtered_df['LOCATION'] == country_filter]
2095
 
2096
+ # Búsqueda de coincidencias (más eficiente en un DF ya filtrado)
2097
+ # Usamos `na=False` para evitar errores con valores nulos
2098
+ matches = filtered_df[filtered_df['DRIVER'].str.contains(search_value, case=False, na=False)]
2099
+
2100
+ # OPTIMIZACIÓN: Devolvemos menos resultados para que la respuesta sea más ligera.
2101
+ top_matches = matches.nlargest(15, 'IRATING')
2102
 
 
2103
  options = [{'label': row['DRIVER'], 'value': row['DRIVER']}
2104
  for _, row in top_matches.iterrows()]
2105
 
2106
+ # Asegurarse de que el piloto seleccionado no desaparezca de las opciones
 
2107
  if current_selected_pilot and not any(opt['value'] == current_selected_pilot for opt in options):
2108
  options.insert(0, {'label': current_selected_pilot, 'value': current_selected_pilot})
2109
 
2110
+ # Devolvemos las nuevas opciones, actualizamos el 'last_search' y deshabilitamos el timer.
2111
+ return options, search_value, True
2112
+
2113
+ # CALLBACK 3: Limpia las opciones si el usuario borra el texto.
2114
+ @app.callback(
2115
+ Output('pilot-search-dropdown', 'options', allow_duplicate=True),
2116
+ Input('pilot-search-dropdown', 'search_value'),
2117
+ State('pilot-search-dropdown', 'value'),
2118
+ prevent_initial_call=True
2119
+ )
2120
+ def clear_options_on_empty_search(search_value, current_selected_pilot):
2121
+ if not search_value:
2122
+ # Si no hay texto, solo muestra la opción del piloto seleccionado (si existe).
2123
+ if current_selected_pilot:
2124
+ return [{'label': current_selected_pilot, 'value': current_selected_pilot}]
2125
+ return []
2126
+ return dash.no_update
2127
+
2128
 
2129
  # --- CALLBACK para limpiar la búsqueda si cambian los filtros ---
2130
  @app.callback(
 
2238
  def update_table_and_search(
2239
  region_filter, country_filter, selected_pilot,
2240
  page_current, page_size, sort_by, state_active_cell,
2241
+ active_discipline_filename,
2242
+ discipline_change_trigger
2243
  ):
2244
 
2245
  ctx = dash.callback_context
 
2253
  # Leemos y procesamos el archivo seleccionado
2254
  #df = pd.read_csv(filename)
2255
  df = DISCIPLINE_DATAFRAMES[active_discipline_filename]
 
 
 
2256
 
 
 
 
 
 
 
 
 
2257
  df_for_graphs = df.copy() # Copia para gráficos que no deben ser filtrados
 
 
 
2258
 
2259
  # Lógica de columnas dinámicas
2260
  base_cols = ['DRIVER', 'IRATING', 'LOCATION', 'REGION','CLASS', 'STARTS', 'WINS' ]
 
2300
  elif triggered_id == 'pilot-search-dropdown' and selected_pilot:
2301
  match_index = filtered_df.index.get_loc(df[df['DRIVER'] == selected_pilot].index[0])
2302
  if match_index is not None:
2303
+ target_page = match_index // 100 # CAMBIO: Usar 100 en lugar de page_size
2304
  driver_column_index = list(filtered_df.columns).index('DRIVER')
2305
  new_active_cell = {
2306
+ 'row': match_index % 100, # CAMBIO: Usar 100 en lugar de page_size
2307
+ 'row_id': match_index % 100, # CAMBIO: Usar 100
2308
  'column': driver_column_index,
2309
  'column_id': 'DRIVER'
2310
  }
2311
 
 
 
2312
  # --- 5. GENERACIÓN DE COLUMNAS PARA LA TABLA ---
2313
  columns_definition = []
2314
  for col_name in filtered_df.columns:
 
2324
  columns_definition.append({"name": col_name.title(), "id": col_name})
2325
 
2326
  # --- 6. PAGINACIÓN ---
2327
+ page_size = 100 # FORZAR: Siempre usar 100 elementos por página
2328
  start_idx = target_page * page_size
 
2329
  end_idx = start_idx + page_size
2330
 
2331
  # Aplicamos el formato de bandera a los datos de la página actual
 
2334
  page_data = page_df.to_dict('records')
2335
 
2336
  total_pages = len(filtered_df) // page_size + (1 if len(filtered_df) % page_size > 0 else 0)
2337
+
2338
  # --- 7. ACTUALIZACIÓN DE GRÁFICOS ---
2339
  graph_indices = filtered_df.index
2340
  highlight_irating = None
 
2479
  ds1 = ds
2480
  return ds.get('active_cell'),ds1
2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2482
 
2483
  if __name__ == "__main__":
2484
+ app.run(debug=True)
2485
+
2486
+
2487
+
assets/custom.css ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* assets/custom.css */
2
+ .dash-table-container .dash-spreadsheet-container .dash-cell {
3
+ height: calc(70vh / 21.5); /* 70vh dividido por el número de filas por página */
4
+ min-height: 1px;
5
+ max-height: 5px;
6
+ }
7
+
8
+
9
+ @font-face {
10
+ font-family: 'DashboardFont'; /* Dale un nombre para usarla en tu app */
11
+ src: url('ADDCN___.ttf') format('truetype'); /* Apunta al archivo de tu fuente */
12
+ }
13
+ /* 2. Aplica la fuente a toda la aplicación */
14
+ /* body {
15
+ font-family: 'DashboardFont', sans-serif; Usa tu fuente. 'sans-serif' es un respaldo
16
+ } */
17
+
18
+
19
+ /* Opcional: Asegúrate de que los títulos también la usen */
20
+ h1, h2, h3, h4, h5, h6 {
21
+ font-family: 'DashboardFont', 'Lato', sans-serif;
22
+ }
23
+
24
+ .iracing-dropdown .Select-control {
25
+ background-color: rgba(11, 11, 19, 1) !important; /* Fondo gris oscuro */
26
+ border: 1px solid #4A4A4A !important; /* Borde sutil un poco más claro */
27
+ border-radius: 4px !important;
28
+ box-shadow: none !important; /* Sin sombras */
29
+ }
30
+
31
+ /* Texto de la opción seleccionada */
32
+ .iracing-dropdown .Select-value-label {
33
+ color: #E0E0E0 !important; /* Color de texto gris claro */
34
+ }
35
+
36
+ /* Flecha del dropdown */
37
+ .iracing-dropdown .Select-arrow {
38
+ border-color: #E0E0E0 transparent transparent !important; /* Hace la flecha del color del texto */
39
+ }
40
+
41
+ /* Menú que se despliega */
42
+ .iracing-dropdown .Select-menu-outer {
43
+ background-color: #323232 !important; /* Mismo fondo que el control */
44
+ border: 1px solid #4A4A4A !important; /* Mismo borde */
45
+ border-radius: 4px !important;
46
+ }
47
+
48
+ /* Estilo de cada opción en el menú */
49
+ .iracing-dropdown .Select-option {
50
+ background-color: #323232 !important; /* Fondo de la opción */
51
+ color: #E0E0E0 !important; /* Color del texto de la opción */
52
+ }
53
+
54
+ /* Estilo de la opción cuando el ratón está encima (hover) */
55
+ .iracing-dropdown .Select-option:hover {
56
+ background-color: #4A4A4A !important; /* Un gris un poco más claro para el hover */
57
+ }
58
+
59
+ /* Estilo de la opción que está seleccionada/enfocada */
60
+ .iracing-dropdown .Select-option.is-focused {
61
+ background-color: #4A4A4A !important;
62
+ }
63
+
assets/style.css ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap');
2
+
3
+
4
+ body {
5
+ margin: 0;
6
+ padding: 0;
7
+ background-color: rgba(5,5,15,255);
8
+ color: #ffffff;
9
+ font-family: 'Lato', sans-serif;
10
+ font-weight: 500;
11
+ animation: fadeIn 0.8s ease-in-out;
12
+ }
13
+
14
+ @keyframes fadeIn {
15
+ from { opacity: 0; }
16
+ to { opacity: 1; }
17
+ }
18
+
19
+ /* RESPONSIVE DESIGN PARA EL NUEVO LAYOUT */
20
+
21
+ /* Diseño responsive para la sección de filtros y tabla */
22
+ @media (max-width: 1024px) {
23
+ /* Filtros y tabla se vuelven verticales en tablets */
24
+ .filters-table-container {
25
+ flex-direction: column !important;
26
+ }
27
+
28
+ .filters-table-container > div {
29
+ min-width: 100% !important;
30
+ max-width: 100% !important;
31
+ }
32
+ }
33
+
34
+ @media (max-width: 768px) {
35
+ /* Contenedor principal con menos padding en móvil */
36
+ .main-container {
37
+ padding: 10px 3vw !important;
38
+ }
39
+
40
+ /* Grid de gráficos adicionales en una sola columna */
41
+ .additional-charts-grid {
42
+ grid-template-columns: 1fr !important;
43
+ }
44
+
45
+ /* Ajustar títulos para móvil */
46
+ h1 {
47
+ font-size: clamp(28px, 8vw, 36px) !important;
48
+ }
49
+
50
+ h3 {
51
+ font-size: clamp(16px, 4vw, 20px) !important;
52
+ }
53
+
54
+ /* Botones más pequeños en móvil */
55
+ .dashboard-type-button {
56
+ width: 70px !important;
57
+ font-size: 10px !important;
58
+ padding: 6px 4px !important;
59
+ }
60
+
61
+ /* KPIs overlay más pequeño en móvil */
62
+ .kpi-overlay {
63
+ width: 95% !important;
64
+ top: 10px !important;
65
+ }
66
+ }
67
+
68
+ @media (max-width: 480px) {
69
+ /* Contenedor muy compacto para móviles pequeños */
70
+ .main-container {
71
+ padding: 5px 2vw !important;
72
+ gap: 15px !important;
73
+ }
74
+
75
+ /* Secciones con menos padding */
76
+ .section-container {
77
+ padding: 10px !important;
78
+ }
79
+
80
+ /* Filtros en columna única */
81
+ .filters-row {
82
+ flex-direction: column !important;
83
+ gap: 10px !important;
84
+ }
85
+ }
86
+
87
+ /* ESTILOS PARA LOS BOTONES DEL TIPO DE TABLERO */
88
+ .dashboard-type-button {
89
+ background-color: rgba(18,18,26,.5);
90
+ color: white;
91
+ border: 1px solid #4A4A4A;
92
+ padding: 8px 12px;
93
+ border-radius: 5px;
94
+ cursor: pointer;
95
+ font-weight: bold;
96
+ width: 100px;
97
+ text-align: center;
98
+ transition: background-color 0.2s, border-color 0.2s;
99
+ }
100
+
101
+ .dashboard-type-button:hover {
102
+ background-color: rgba(0, 111, 255, 0.3);
103
+ border-color: rgb(0, 111, 255);
104
+ }
105
+
106
+ /* ESTILOS PARA DROPDOWNS ACTIVOS */
107
+ .iracing-dropdown.active-filter .Select-control {
108
+ background-color: rgba(0, 111, 255, 0.25) !important;
109
+ border: 1px solid rgba(0, 111, 255, 0.7) !important;
110
+ box-shadow: 0 0 10px rgba(0, 111, 255, 0.3) !important;
111
+ }
112
+
113
+ .iracing-dropdown.active-filter .Select-placeholder,
114
+ .iracing-dropdown.active-filter .Select-value-label {
115
+ color: #FFFFFF !important;
116
+ }
117
+
118
+ .iracing-dropdown.active-filter .Select-arrow {
119
+ border-top-color: #FFFFFF !important;
120
+ }
121
+
122
+ /* ESTILOS PARA LA TABLA INTERACTIVA */
123
+ #datatable-interactiva .dash-cell {
124
+ font-size: clamp(8px, 0.6vw, 12px) !important;
125
+ padding: 1px 1px !important;
126
+ }
127
+
128
+ #datatable-interactiva .dash-header {
129
+ font-size: clamp(9px, 0.7vw, 10px) !important;
130
+ padding: 2px 1px !important;
131
+ }
132
+
133
+ /* ESTILOS ESPECÍFICOS POR COLUMNA */
134
+ #datatable-interactiva .dash-cell[data-dash-column="REGION"] {
135
+ font-size: 10px !important;
136
+ }
137
+
138
+ #datatable-interactiva .dash-cell[data-dash-column="DRIVER"] {
139
+ font-size: 10px !important;
140
+ }
141
+
142
+ #datatable-interactiva .dash-cell[data-dash-column="CLASS"] {
143
+ font-size: 12px !important;
144
+ }
145
+
146
+ #datatable-interactiva .dash-cell[data-dash-column="IRATING"] {
147
+ font-size: 10px !important;
148
+ }
149
+
150
+ #datatable-interactiva .dash-cell[data-dash-column="STARTS"] {
151
+ font-size: 10px !important;
152
+ }
153
+
154
+ #datatable-interactiva .dash-cell[data-dash-column="WINS"] {
155
+ font-size: 10px !important;
156
+ }
157
+
158
+ /* ESTILOS PARA DROPDOWNS Y LABELS */
159
+ .iracing-dropdown,
160
+ .iracing-dropdown .Select-value-label,
161
+ .iracing-dropdown .Select-placeholder {
162
+ font-size: clamp(10px, 0.8vw, 14px) !important;
163
+ }
164
+
165
+ label {
166
+ font-size: clamp(11px, 0.8vw, 14px) !important;
167
+ }
168
+
169
+ /* ESTILOS PARA LAS TABLAS DE COMPETITIVIDAD */
170
+ #competitiveness-tables-container .pagination {
171
+ justify-content: center;
172
+ margin-top: 5px;
173
+ margin-bottom: 0;
174
+ }
175
+
176
+ #competitiveness-tables-container .page-item .page-link {
177
+ background-color: rgba(11,11,19,1);
178
+ color: rgb(255, 255, 255, .8);
179
+ border: 1px solid #4A4A4A;
180
+ font-size: 12px;
181
+ padding: 4px 10px;
182
+ margin: 0 2px;
183
+ border-radius: 4px;
184
+ }
185
+
186
+ #competitiveness-tables-container .page-item:not(.active) .page-link:hover {
187
+ background-color: #323232;
188
+ color: white;
189
+ }
190
+
191
+ #competitiveness-tables-container .page-item.active .page-link {
192
+ background-color: rgba(0, 111, 255, 0.5);
193
+ border-color: rgb(0, 111, 255);
194
+ color: white;
195
+ font-weight: bold;
196
+ }
197
+
198
+ #competitiveness-tables-container .page-item.disabled .page-link {
199
+ background-color: rgba(11,11,19,1);
200
+ color: #4A4A4A;
201
+ border-color: #323232;
202
+ }
203
+
204
+ /* ESTILOS ESPECÍFICOS PARA LAS TABLAS DE COMPETITIVIDAD */
205
+ #competitiveness-tables-container .dash-table-container {
206
+ border-radius: 10px !important;
207
+ overflow: hidden !important;
208
+ max-width: 100% !important;
209
+ }
210
+
211
+ #competitiveness-tables-container .dash-spreadsheet-container {
212
+ max-height: 350px !important;
213
+ overflow-y: auto !important;
214
+ overflow-x: hidden !important;
215
+ }
216
+
217
+ #competitiveness-tables-container .dash-cell {
218
+ font-size: 12px !important;
219
+ padding: 6px 8px !important;
220
+ white-space: nowrap !important;
221
+ text-overflow: ellipsis !important;
222
+ overflow: hidden !important;
223
+ }
224
+
225
+ #competitiveness-tables-container .dash-header {
226
+ font-size: 13px !important;
227
+ font-weight: bold !important;
228
+ padding: 8px 6px !important;
229
+ position: sticky !important;
230
+ top: 0 !important;
231
+ z-index: 10 !important;
232
+ }
233
+
234
+ /* SCROLL PERSONALIZADO PARA LAS TABLAS DE COMPETITIVIDAD */
235
+ #competitiveness-tables-container .dash-spreadsheet-container::-webkit-scrollbar {
236
+ width: 6px;
237
+ }
238
+
239
+ #competitiveness-tables-container .dash-spreadsheet-container::-webkit-scrollbar-track {
240
+ background: rgba(30,30,38,1);
241
+ border-radius: 3px;
242
+ }
243
+
244
+ #competitiveness-tables-container .dash-spreadsheet-container::-webkit-scrollbar-thumb {
245
+ background: #4A4A4A;
246
+ border-radius: 3px;
247
+ }
248
+
249
+ #competitiveness-tables-container .dash-spreadsheet-container::-webkit-scrollbar-thumb:hover {
250
+ background: #6c6c6c;
251
+ }
252
+
253
+ /* RESPONSIVE: En móvil las tablas se vuelven verticales */
254
+ @media (max-width: 768px) {
255
+ #competitiveness-tables-container > div {
256
+ grid-template-columns: 1fr !important;
257
+ gap: 15px !important;
258
+ }
259
+
260
+ #competitiveness-tables-container .dash-spreadsheet-container {
261
+ max-height: 300px !important;
262
+ }
263
+ }
264
+
265
+ /* ASEGURAR QUE LOS CONTENEDORES NO SE SALGAN */
266
+ .chart-card {
267
+ box-sizing: border-box !important;
268
+ overflow: hidden !important;
269
+ }
270
+
271
+ .chart-card > div {
272
+ max-width: 100% !important;
273
+ overflow: hidden !important;
274
+ }
275
+
276
+ /* UTILIDADES RESPONSIVE */
277
+ .container-fluid {
278
+ max-width: 100%;
279
+ padding: 0;
280
+ }
281
+
282
+ .no-gutters {
283
+ margin: 0;
284
+ padding: 0;
285
+ }
286
+
287
+ /* SCROLLBAR PERSONALIZADO */
288
+ ::-webkit-scrollbar {
289
+ width: 8px;
290
+ }
291
+
292
+ ::-webkit-scrollbar-track {
293
+ background: rgba(18,18,26,.5);
294
+ border-radius: 4px;
295
+ }
296
+
297
+ ::-webkit-scrollbar-thumb {
298
+ background: #4A4A4A;
299
+ border-radius: 4px;
300
+ }
301
+
302
+ ::-webkit-scrollbar-thumb:hover {
303
+ background: #6c6c6c;
304
+ }
305
+
306
+ /* ESTILOS MEJORADOS PARA LA TABLA CON SCROLL */
307
+ #datatable-interactiva .dash-table-container {
308
+ border-radius: 15px !important;
309
+ overflow: hidden !important;
310
+ max-height: 450px !important;
311
+ }
312
+
313
+ #datatable-interactiva .dash-spreadsheet-container {
314
+ max-height: 450px !important;
315
+ overflow-y: auto !important;
316
+ overflow-x: auto !important;
317
+ }
318
+
319
+ /* SCROLL PERSONALIZADO PARA LA TABLA PRINCIPAL */
320
+ #datatable-interactiva .dash-spreadsheet-container::-webkit-scrollbar {
321
+ width: 8px;
322
+ height: 8px;
323
+ }
324
+
325
+ #datatable-interactiva .dash-spreadsheet-container::-webkit-scrollbar-track {
326
+ background: rgba(30,30,38,1);
327
+ border-radius: 4px;
328
+ }
329
+
330
+ #datatable-interactiva .dash-spreadsheet-container::-webkit-scrollbar-thumb {
331
+ background: #4A4A4A;
332
+ border-radius: 4px;
333
+ }
334
+
335
+ #datatable-interactiva .dash-spreadsheet-container::-webkit-scrollbar-thumb:hover {
336
+ background: #6c6c6c;
337
+ }
338
+
339
+ #datatable-interactiva .dash-spreadsheet-container::-webkit-scrollbar-corner {
340
+ background: rgba(30,30,38,1);
341
+ }
342
+
343
+ /* ASEGURAR QUE EL HEADER PERMANEZCA VISIBLE */
344
+ #datatable-interactiva .dash-header {
345
+ position: sticky !important;
346
+ top: 0 !important;
347
+ z-index: 10 !important;
348
+ background-color: rgba(30,30,38,1) !important;
349
+ }
350
+
351
+ /* ESTILOS MEJORADOS PARA LOS KPIs VERTICALES DEL PILOTO */
352
+ #kpi-pilot .main-svg {
353
+ background: rgba(0,0,0,0) !important;
354
+ }
355
+
356
+ #kpi-pilot .indicator {
357
+ text-align: center !important;
358
+ }
359
+
360
+ /* RESPONSIVE: Ajustar en móvil */
361
+ @media (max-width: 768px) {
362
+ #datatable-interactiva .dash-spreadsheet-container {
363
+ max-height: 400px !important;
364
+ }
365
+
366
+ /* KPIs del piloto más compactos en móvil */
367
+ #kpi-pilot {
368
+ height: 220px !important;
369
+ }
370
+ }
371
+
372
+ /* ESTILOS PARA LA TABLA KPI DEL PILOTO */
373
+ #kpi-pilot .dash-table-container {
374
+ background: transparent !important;
375
+ border: none !important;
376
+ }
377
+
378
+ #kpi-pilot .dash-spreadsheet-container {
379
+ background: transparent !important;
380
+ border: none !important;
381
+ }
382
+
383
+ #kpi-pilot .dash-cell {
384
+ background: transparent !important;
385
+ border: none !important;
386
+ font-family: 'Lato, sans-serif' !important;
387
+ }
388
+
389
+ #kpi-pilot .dash-header {
390
+ display: none !important;
391
+ }
392
+
393
+ /* Hover effect sutil para las filas de KPI */
394
+ #kpi-pilot tr:hover {
395
+ background-color: rgba(255, 255, 255, 0.05) !important;
396
+ border-radius: 8px;
397
+ transition: background-color 0.2s ease;
398
+ }
399
+
400
+ /* Responsive para la tabla KPI */
401
+ @media (max-width: 768px) {
402
+ #kpi-pilot {
403
+ height: 220px !important;
404
+ }
405
+
406
+ #kpi-pilot .dash-cell {
407
+ padding: 8px 4px !important;
408
+ font-size: 12px !important;
409
+ }
410
+
411
+ #kpi-pilot .dash-cell[data-dash-column="value"] {
412
+ font-size: 18px !important;
413
+ }
414
+ }
415
+ #pilot-search-dropdown .Select-input > input,
416
+ #pilot-search-dropdown .Select-input input {
417
+ color: #FFFFFF !important;
418
+ background-color: transparent !important;
419
+ border: none !important;
420
+ outline: none !important;
421
+ font-family: 'Lato', sans-serif !important;
422
+ caret-color: #FFFFFF !important; /* Color del cursor de texto */
423
+ }
424
+
425
+ #pilot-search-dropdown .Select-input > input::placeholder {
426
+ color: #A0A0A0 !important;
427
+ opacity: 1 !important;
428
+ }
429
+
430
+ #pilot-search-dropdown .Select-placeholder {
431
+ color: #A0A0A0 !important;
432
+ }
433
+
434
+ #pilot-search-dropdown .Select-input > input:focus {
435
+ color: #FFFFFF !important;
436
+ background-color: rgba(11,11,19,1) !important;
437
+ }
requirements.txt CHANGED
Binary files a/requirements.txt and b/requirements.txt differ