top-irating / app.py
daniel-saed's picture
Upload app.py
5797da3 verified
import dash
from dash import html, dcc, dash_table, Input, Output, State
import plotly.express as px
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import pycountry_convert as pc
import pycountry
import gunicorn
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import atexit
from datetime import datetime, timezone # <-- AÑADE timezone
iracing_ragions = {
'US':['US'],
'Mexico':['MX'],
'Brazil':['BR'],
'Canada':['CA'],
'Atlantic':['GL'],
'Japan':['JP'],
'South America':['AR','PE','UY','CL','PY','BO','EC','CO','VE','GY','PA','CR','NI','HN','GT','BZ','SV','JM','DO','BS'],
'Iberia':['ES','PT','AD'],
'International':['RU','IL'],
'France':['FR'],
'UK & I':['GB','IE'], # <-- CORREGIDO: ' IE' -> 'IE' y nombre
'Africa':['ZA','BW','ZW','ZM','CD','GA','BI','RW','UG','KE','SO','MG','SZ','NG','GH','CI','BF','NE','GW','GM','SN','MR','EH','MA','DZ','LY','TN','EG','DJ'],
'Italy':['IT'],
'Central EU':['PL','CZ','SK','HU','SI','HR','RS','ME','AL','RO','MD','UA','BY','EE','LV','LT'],
'Finland':['FI'],
'DE-AT-CH':['CH','AT','DE'], # <-- CORRECCIÓN: Eliminado el ''
'Scandinavia':['DK','SE','NO'],
'Australia & NZ':['AU','NZ'],
'Asia':['SA','JO','IQ','YE','OM','AE','QA','IN','PK','AF','NP','BD','MM','TH','KH','VN','MY','ID','CN','PH','KR','MN','KZ','KG','UZ','TJ','AF','TM','LK'],
'Benelux':['NL','BE','LU']
}
DATA_STATUS = {}
LAST_SUCCESSFUL_STATUS = {}
def load_and_process_data(filename):
"""Función para cargar y pre-procesar un archivo de disciplina."""
print(f"Loading and processing {filename}...")
df = pd.read_csv(filename)
# Nuevas columnas en mayúsculas
new_columns = [
'DRIVER', 'CUSTID', 'LOCATION', 'CLUB_NAME', 'STARTS', 'WINS',
'AVG_START_POS', 'AVG_FINISH_POS', 'AVG_POINTS', 'TOP25PCNT',
'LAPS', 'LAPSLEAD', 'AVG_INC', 'CLASS', 'IRATING', 'TTRATING',
'TOT_CLUBPOINTS', 'CHAMPPOINTS'
]
# Reemplazar las columnas (asumiendo que el orden es correcto)
if len(df.columns) == len(new_columns):
df.columns = new_columns
'''filename_parquet = filename.replace('.csv', '.parquet')
df = pd.read_parquet(filename_parquet)'''
df = df[df['IRATING'] > 1]
df = df[df['STARTS'] > 1]
df = df[df['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
country_to_region_map = {country: region for region, countries in iracing_ragions.items() for country in countries}
df['REGION'] = df['LOCATION'].map(country_to_region_map).fillna('International')
df['Rank World'] = df['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
df['Rank Region'] = df.groupby('REGION')['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
df['Rank Country'] = df.groupby('LOCATION')['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
df['CLASS'] = df['CLASS'].str[0]
print(f"Finished processing {filename}.")
return df
def update_all_data():
"""Actualiza todos los archivos de datos"""
global DATA_STATUS
print(f"\n{'='*60}")
print(f"🔄 SCHEDULED DATA UPDATE STARTED")
print(f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*60}")
files_to_update = {
'ROAD.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/ROAD.csv',
'FORMULA.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/FORMULA.csv',
'OVAL.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/OVAL.csv',
'DROAD.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DROAD.csv',
'DOVAL.csv': 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DOVAL.csv'
}
try:
status_url = 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/branch_actions/update_status.json'
# Leemos el JSON y lo guardamos en la variable de estado actual
current_status_data = pd.read_json(status_url, typ='series').to_dict()
DATA_STATUS = current_status_data
# Si el estado actual es exitoso, lo guardamos como el último éxito conocido.
if DATA_STATUS.get('status') == 'success':
LAST_SUCCESSFUL_STATUS = DATA_STATUS
print(f"✅ Status file loaded successfully: {DATA_STATUS}")
except Exception as e:
print(f"❌ Could not load status file: {e}")
# Si no podemos leer el archivo, el estado actual es desconocido.
DATA_STATUS = {'status': 'unknown', 'last_update_utc': datetime.now(timezone.utc).isoformat()}
print(f"🎉 SCHEDULED DATA UPDATE COMPLETED")
print(f"{'='*60}\n")
for filename in files_to_update:
try:
print(f"🔄 Updating {filename}...")
new_data = load_and_process_data(files_to_update[filename])
DISCIPLINE_DATAFRAMES[filename] = new_data
print(f"✅ {filename} updated successfully! ({len(new_data)} records)")
except Exception as e:
print(f"❌ Error updating {filename}: {str(e)}")
print(f"🎉 SCHEDULED DATA UPDATE COMPLETED")
print(f"{'='*60}\n")
try:
status_url = 'https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/branch_actions/update_status.json'
DATA_STATUS = pd.read_json(status_url, typ='series').to_dict()
# Si la carga inicial es exitosa, la guardamos como el último éxito.
if DATA_STATUS.get('status') == 'success':
LAST_SUCCESSFUL_STATUS = DATA_STATUS
print(f"✅ Initial status loaded: {DATA_STATUS}")
except Exception as e:
print(f"❌ Initial status load failed: {e}")
DATA_STATUS = {'status': 'unknown', 'last_update_utc': datetime.now(timezone.utc).isoformat()}
# CARGA INICIAL
DISCIPLINE_DATAFRAMES = {
'ROAD.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/ROAD.csv'),
'FORMULA.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/FORMULA.csv'),
'OVAL.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/OVAL.csv'),
'DROAD.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DROAD.csv'),
'DOVAL.csv': load_and_process_data('https://raw.githubusercontent.com/danielsaed/iRacing-dashboard/refs/heads/branch_actions/data/DOVAL.csv')
}
# CONFIGURAR EL SCHEDULER
scheduler = BackgroundScheduler()
scheduler.add_job(
func=update_all_data,
trigger=IntervalTrigger(hours=12), # Ejecutar cada 2 horas
id='data_update_job',
name='Update iRacing Data',
replace_existing=True
)
# INICIAR EL SCHEDULER
scheduler.start()
print("🚀 Automatic data updater started with APScheduler!")
print("📅 Updates scheduled every 2 hours")
# ASEGURAR QUE EL SCHEDULER SE CIERRE AL CERRAR LA APP
atexit.register(lambda: scheduler.shutdown())
def create_irating_trend_line_chart(df):
"""
Crea un gráfico de líneas que muestra el promedio de carreras corridas
para diferentes rangos de iRating, eliminando valores atípicos.
"""
# --- 1. Eliminar Outliers ---
# Calculamos el percentil 99 para las carreras y filtramos para
# que unos pocos pilotos con miles de carreras no desvíen el promedio.
max_starts = df['STARTS'].quantile(0.95)
min_starts = df['STARTS'].quantile(0.001)
print(max_starts)
print(min_starts)
df_filtered = df[df['STARTS'] <= max_starts]
df_filtered = df[df['STARTS'] >= min_starts]
# --- 2. Agrupar iRating en Bins ---
# Creamos los rangos de 0 a 11000, en pasos de 1000.
bins = list(range(0, 12000, 1000))
labels = [f'{i/1000:.0f}k - {i/1000+1-0.001:.1f}k' for i in bins[:-1]]
df_filtered['irating_bin'] = pd.cut(df_filtered['IRATING'], bins=bins, labels=labels, right=False)
# --- 3. Calcular el promedio de carreras por bin ---
trend_data = df_filtered.groupby('irating_bin').agg(
avg_starts=('STARTS', 'mean'),
num_pilots=('DRIVER', 'count')
).reset_index()
# --- 4. Crear el Gráfico ---
fig = go.Figure(data=go.Scatter(
x=trend_data['irating_bin'],
y=trend_data['avg_starts'],
mode='lines+markers', # Líneas y puntos en cada dato
marker=dict(
color='rgba(0, 111, 255, 1)',
size=8,
line=dict(width=1, color='white')
),
line=dict(color='rgba(0, 111, 255, 0.7)'),
customdata=trend_data['num_pilots'],
hovertemplate=(
"<b>iRating Range:</b> %{x}<br>" +
"<b>Average Races:</b> %{y:.0f}<br>" +
"<b>Drivers in this range:</b> %{customdata:,}<extra></extra>"
)
))
fig.update_layout(
template='plotly_dark',
paper_bgcolor='rgba(11,11,19,1)',
plot_bgcolor='rgba(11,11,19,1)',
font=GLOBAL_FONT,
xaxis=dict(
title_text='iRating Range', # Texto del título
title_font=dict(size=12), # Estilo de la fuente del título
showgrid=True,
gridwidth=1,
gridcolor='rgba(255,255,255,0.1)'
),
yaxis=dict(
title_text='Avg. Races', # Texto del título
title_font=dict(size=12), # Estilo de la fuente del título
showgrid=True,
gridwidth=1,
gridcolor='rgba(255,255,255,0.1)'
),
margin=dict(l=10, r=10, t=0, b=10),
)
return fig
def calculate_competitiveness(df):
"""
Calcula el iRating promedio de los 100 mejores pilotos para cada región y país.
Descarta grupos con menos de 100 pilotos.
"""
# --- Cálculo para Regiones ---
# Filtramos regiones con al menos 100 pilotos
region_counts = df['REGION'].value_counts()
valid_regions = region_counts[region_counts >= 100].index
region_scores = {}
for region in valid_regions:
# Tomamos el top 100 por iRating y calculamos el promedio
top_100 = df[df['REGION'] == region].nlargest(100, 'IRATING')
region_scores[region] = top_100['IRATING'].mean()
# Convertimos a DataFrame, ordenamos y tomamos el top 10
top_regions_df = pd.DataFrame(list(region_scores.items()), columns=['REGION', 'avg_irating'])
top_regions_df = top_regions_df.sort_values('avg_irating', ascending=False)
# --- Cálculo para Países ---
# Mismo proceso para países
country_counts = df['LOCATION'].value_counts()
valid_countries = country_counts[country_counts >= 100].index
country_scores = {}
for country in valid_countries:
top_100 = df[df['LOCATION'] == country].nlargest(100, 'IRATING')
country_scores[country] = top_100['IRATING'].mean()
top_countries_df = pd.DataFrame(list(country_scores.items()), columns=['LOCATION', 'avg_irating'])
top_countries_df = top_countries_df.sort_values('avg_irating', ascending=False)
return top_regions_df, top_countries_df
def create_region_bubble_chart(df):
df = df[df['REGION'] != 'Atlantic']
region_stats = df.groupby('REGION').agg(
avg_starts=('STARTS', 'mean'),
avg_irating=('IRATING', 'mean'),
num_pilots=('DRIVER', 'count')
).reset_index()
# --- ¡CLAVE PARA EL ORDEN! ---
# Al ordenar de menor a mayor, Plotly dibuja las burbujas pequeñas al final,
# asegurando que queden por encima de las grandes. ¡Esto ya está correcto!
region_stats = region_stats.sort_values('num_pilots', ascending=True)
hover_text_pilots = (region_stats['num_pilots'] / 1000).round(1).astype(str) + 'k'
fig = go.Figure()
fig.add_trace(go.Scatter(
x=region_stats['avg_irating'],
y=region_stats['avg_starts'],
mode='markers+text',
text=region_stats['REGION'],
textposition='top center',
# --- MODIFICACIÓN: Añadimos fondo al texto ---
textfont=dict(
size=8,
color='rgba(255, 255, 255, 0.9)',
family='Lato, sans-serif'
),
# --- FIN DE LA MODIFICACIÓN ---
marker=dict(
size=region_stats['num_pilots'],
sizemode='area',
sizeref=2.*max(region_stats['num_pilots'])/(50.**2.3),
sizemin=6,
# --- MODIFICACIÓN: Coloreamos por número de pilotos ---
color=region_stats['num_pilots'],
# Usamos la misma escala de colores que el mapa para coherencia
colorscale=[
[0.0, '#050A28'],
[0.05, '#0A1950'],
[0.15, '#0050B4'],
[0.3, '#006FFF'],
[0.5, '#3C96FF'],
[0.7, '#82BEFF'],
[1.0, '#DCEBFF']
],
cmin=0,
cmax=20000,
showscale=False
# --- FIN DE LA MODIFICACIÓN ---
),
customdata=np.stack((hover_text_pilots, region_stats['num_pilots']), axis=-1),
hovertemplate=(
"<b>%{text}</b><br>" +
"Avg. iRating: %{x:.0f}<br>" +
"Avg. Races: %{y:.1f}<br>" +
"Driver Qty: %{customdata[0]} (%{customdata[1]:,})<extra></extra>"
)
))
"""title=dict(
text='Regions (Avg. iRating, Avg. Races, Qty. Drivers)',
font=dict(color='white', size=14),
x=0.5,
xanchor='center'
),
font=GLOBAL_FONT,"""
fig.update_layout(
#xaxis_title='Avg. iRating',
#yaxis_title='Avg. Races',
template='plotly_dark',
paper_bgcolor='rgba(11,11,19,1)',
plot_bgcolor='rgba(11,11,19,1)',
# --- AÑADIMOS ESTILO DE GRID IGUAL AL HISTOGRAMA ---
xaxis=dict(
title_text='Avg. iRating', # Texto del título
title_font=dict(size=12), # Estilo de la fuente del título
showgrid=True,
gridwidth=1,
gridcolor='rgba(255,255,255,0.1)'
),
yaxis=dict(
title_text='Avg. Races', # Texto del título
title_font=dict(size=12), # Estilo de la fuente del título
showgrid=True,
gridwidth=1,
gridcolor='rgba(255,255,255,0.1)'
),
# --- FIN DEL ESTILO DE GRID ---
margin=dict(l=10, r=10, t=0, b=10),
)
return fig
def create_kpi_global(filtered_df, filter_context="World"):
total_pilots = len(filtered_df)
avg_irating = filtered_df['IRATING'].mean() if total_pilots > 0 else 0
avg_starts = filtered_df['STARTS'].mean() if total_pilots > 0 else 0
avg_wins = filtered_df['WINS'].mean() if total_pilots > 0 else 0
fig = go.Figure()
kpis = [
{'value': total_pilots, 'title': f"Drivers {filter_context}", 'format': ',.0f'},
{'value': avg_irating, 'title': "Avg iRating", 'format': ',.0f'},
{'value': avg_starts, 'title': "Avg Starts", 'format': '.1f'},
{'value': avg_wins, 'title': "Avg Wins", 'format': '.2f'}
]
for i, kpi in enumerate(kpis):
fig.add_trace(go.Indicator(
mode="number",
value=kpi['value'],
number={'valueformat': kpi['format'], 'font': {'size': 20}},
# --- MODIFICACIÓN: Añadimos <b> para poner el texto en negrita ---
title={"text": f"<b>{kpi['title']}</b>", 'font': {'size': 16}},
domain={'row': 0, 'column': i}
))
fig.update_layout(
grid={'rows': 1, 'columns': 4, 'pattern': "independent"},
template='plotly_dark',
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor="#BD1818",
margin=dict(l=20, r=20, t=50, b=10),
height=60,
font=GLOBAL_FONT
)
return fig
def create_kpi_pilot(filtered_df, pilot_info=None, filter_context="World"):
fig = go.Figure()
title_text = "Select a Driver"
# Si NO hay información del piloto, creamos una figura vacía y ocultamos los ejes.
if pilot_info is None:
fig.update_layout(
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
xaxis_visible=False,
yaxis_visible=False,
height=240, # Aumentamos la altura considerablemente
annotations=[
dict(
text="<b>Select or search a driver</b>",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font=dict(
size=12,
color="grey"
)
)
]
)
return fig
# Si SÍ hay información del piloto, procedemos como antes.
pilot_name = pilot_info.get('DRIVER', 'Piloto')
title_text = f"<b>{pilot_name}</b>"
rank_world = pilot_info.get('Rank World', 0)
rank_region = pilot_info.get('Rank Region', 0)
rank_country = pilot_info.get('Rank Country', 0)
percentil_world = (1 - (rank_world / len(df))) * 100 if len(df) > 0 else 0
region_df = df[df['REGION'] == pilot_info.get('REGION')]
percentil_region = (1 - (rank_region / len(region_df))) * 100 if len(region_df) > 0 else 0
country_df = df[df['LOCATION'] == pilot_info.get('LOCATION')]
percentil_country = (1 - (rank_country / len(country_df))) * 100 if len(country_df) > 0 else 0
kpis_piloto = [
{'rank': rank_world, 'percentil': percentil_world, 'title': "World Rank"},
{'rank': rank_region, 'percentil': percentil_region, 'title': "Region Rank"},
{'rank': rank_country, 'percentil': percentil_country, 'title': "Country Rank"}
]
# Layout vertical con más espacio
for i, kpi in enumerate(kpis_piloto):
fig.add_trace(go.Indicator(
mode="number",
value=kpi['rank'],
number={'prefix': "#", 'font': {'size': 18}}, # Número más grande
title={
"text": f"<b>{kpi['title']}</b><span style='font-size:11px;color:gray'>(Top {100-kpi['percentil']:.1f}%)</span>",
'font': {'size': 12} # Título más grande
},
domain={
'row': i,
'column': 0,
# AGREGAMOS ESPACIADO ESPECÍFICO PARA CADA KPI
'y': [0.75 - i*0.32, 0.95 - i*0.32] # Da más espacio vertical a cada KPI
}
))
fig.update_layout(
title={
'text': title_text,
'y': 0.98, 'x': 0.5, 'xanchor': 'center', 'yanchor': 'top',
'font': {'size': 28}
},
# CAMBIO: Eliminamos el grid automático y usamos dominios manuales
template='plotly_dark',
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
margin=dict(l=20, r=20, t=35, b=15), # Más margen arriba y abajo
height=240, # Altura aumentada considerablemente
font=GLOBAL_FONT,
showlegend=False
)
return fig
def create_density_heatmap(df):
# --- 1. Preparación de datos ---
num_bins_tendencia = 50
df_copy = df.copy()
df_copy['irating_bin'] = pd.cut(df_copy['IRATING'], bins=num_bins_tendencia)
# MODIFICACIÓN: Añadimos 'mean' al cálculo de agregación
stats_per_bin = df_copy.groupby('irating_bin')['AVG_INC'].agg(['max', 'min', 'mean']).reset_index()
stats_per_bin['irating_mid'] = stats_per_bin['irating_bin'].apply(lambda b: b.mid)
stats_per_bin = stats_per_bin.sort_values('irating_mid').dropna()
# --- 2. CÁLCULO DE LA REGRESIÓN LINEAL ---
# Coeficientes para máximos y mínimos (sin cambios)
max_coeffs = np.polyfit(stats_per_bin['irating_mid'], stats_per_bin['max'], 1)
max_line_func = np.poly1d(max_coeffs)
min_coeffs = np.polyfit(stats_per_bin['irating_mid'], stats_per_bin['min'], 1)
min_line_func = np.poly1d(min_coeffs)
# NUEVO: Coeficientes para la línea de promedios
mean_coeffs = np.polyfit(stats_per_bin['irating_mid'], stats_per_bin['mean'], 1)
mean_line_func = np.poly1d(mean_coeffs)
# Generamos los puntos Y para las líneas rectas
x_trend = stats_per_bin['irating_mid']
y_trend_max = max_line_func(x_trend)
y_trend_min = min_line_func(x_trend)
y_trend_mean = mean_line_func(x_trend) # NUEVO
# --- 3. Creación de las trazas del gráfico ---
heatmap_trace = go.Histogram2d(
x=df['IRATING'],
y=df['AVG_INC'],
colorscale='Plasma',
nbinsx=100, nbinsy=100, zmin=0, zmax=50,
name='Densidad'
)
max_line_trace = go.Scatter(
x=x_trend, y=y_trend_max, mode='lines',
name='Tendency Max AVG_INC',
line=dict(color='red', width=1, dash='dash')
)
min_line_trace = go.Scatter(
x=x_trend, y=y_trend_min, mode='lines',
name='Tendency Min AVG_INC',
line=dict(color='lime', width=1, dash='dash')
)
# NUEVO: Traza para la línea de promedio
mean_line_trace = go.Scatter(
x=x_trend,
y=y_trend_mean,
mode='lines',
name='Tendency Average Incidents',
line=dict(color='black', width=2, dash='solid')
)
# --- 4. Combinación de las trazas en una sola figura ---
# MODIFICACIÓN: Añadimos la nueva traza a la lista de datos
fig = go.Figure(data=[heatmap_trace, max_line_trace, min_line_trace, mean_line_trace])
fig.update_layout(
font=GLOBAL_FONT,
xaxis_title='iRating',
yaxis_title='Incidents Per Race',
template='plotly_dark',
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
xaxis=dict(range=[0, 12000], showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
yaxis=dict(range=[0,25], showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99)
)
return fig
def country_to_continent_code(country_code):
try:
# Convierte el código de país de 2 letras a código de continente
continent_code = pc.country_alpha2_to_continent_code(country_code)
return continent_code
except (KeyError, TypeError):
# Devuelve 'Otros' si el código no se encuentra o es inválido
return 'Otros'
def create_continent_map(df, selected_region='ALL', selected_country='ALL'):
# La preparación de datos es la misma
country_counts = df['LOCATION'].value_counts().reset_index()
country_counts.columns = ['LOCATION_2_LETTER', 'PILOTOS']
def alpha2_to_alpha3(code):
try:
return pycountry.countries.get(alpha_2=code).alpha_3
except (LookupError, AttributeError):
return None
country_counts['LOCATION_3_LETTER'] = country_counts['LOCATION_2_LETTER'].apply(alpha2_to_alpha3)
country_counts.dropna(subset=['LOCATION_3_LETTER'], inplace=True)
# Lógica de coloreado y hover avanzada (sin cambios)
show_scale = True
color_column = 'PILOTOS'
color_scale = [
[0.0, '#050A28'], # 1. Azul casi negro
[0.05, '#0A1950'], # 2. Azul marino oscuro
[0.15, '#0050B4'], # 3. Azul estándar
[0.3, '#006FFF'], # 4. Azul Eléctrico (punto focal)
[0.5, '#3C96FF'], # 5. Azul brillante
[0.7, '#82BEFF'], # 6. Azul claro (cielo)
[1.0, '#DCEBFF'] # 7. Resplandor azulado (casi blanco)
]
range_color_val = [0, 20000]
# Creación del mapa base
fig = px.choropleth(
country_counts,
locations="LOCATION_3_LETTER",
locationmode="ISO-3",
color=color_column,
# --- CORRECCIÓN CLAVE AQUÍ ---
# Ya no usamos hover_name, pasamos todo a custom_data
custom_data=['LOCATION_2_LETTER', 'PILOTOS'],
color_continuous_scale=color_scale,
projection="natural earth",
range_color=range_color_val
)
# Actualizamos la plantilla del hover para usar las variables correctas de custom_data
fig.update_traces(
hovertemplate="<b>%{customdata[0]}</b><br>Drivers: %{customdata[1]}<extra></extra>"
)
# Lógica de zoom dinámico (sin cambios)
if selected_country != 'ALL' and selected_country in country_coords:
zoom_level = 4 if selected_country not in ['US', 'CA', 'AU', 'BR', 'AR'] else 3
fig.update_geos(center=country_coords[selected_country], projection_scale=zoom_level)
elif selected_region != 'ALL':
countries_in_region = iracing_ragions.get(selected_region, [])
lats = [country_coords[c]['lat'] for c in countries_in_region if c in country_coords]
lons = [country_coords[c]['lon'] for c in countries_in_region if c in country_coords]
if lats and lons:
center_lat = sum(lats) / len(lats)
center_lon = sum(lons) / len(lons)
zoom_level = 2
if len(countries_in_region) < 5: zoom_level = 4
elif len(countries_in_region) < 15: zoom_level = 3
fig.update_geos(center={'lat': center_lat, 'lon': center_lon}, projection_scale=zoom_level)
else:
fig.update_geos(center={'lat': 20, 'lon': 0}, projection_scale=1)
else:
fig.update_geos(center={'lat': 20, 'lon': 0}, projection_scale=1)
fig.update_layout(
template='plotly_dark',
# --- BLOQUE MODIFICADO ---
paper_bgcolor='rgba(0,0,0,0)', # Fondo de toda la figura (transparente)
plot_bgcolor='rgba(0,0,0,0)', # Fondo del área del mapa (transparente)
# --- FIN DE LA MODIFICACIÓN ---
geo=dict(
bgcolor='rgba(0,0,0,0)', # Fondo específico del globo (transparente)
lakecolor='#4E5D6C',
landcolor='#323232',
subunitcolor='rgba(0,0,0,0)',
showframe=False, # <-- Oculta el marco exterior del globo
showcoastlines=False # <-- Oculta las líneas de la costa
),
margin={"r":0,"t":0,"l":0,"b":0},
coloraxis_showscale=show_scale,
coloraxis_colorbar=dict(
title='Drivers',
orientation='h',
yanchor='bottom',
y=-0.05,
xanchor='center',
x=0.5,
len=0.5,
thickness=10
)
)
return fig
def create_histogram_with_percentiles(df, column='IRATING', bin_width=100, highlight_irating=None, highlight_name=None):
# Crear bins específicos de 100 en 100
max_val = df[column].max()
# Por ejemplo, si max_val es 11250, (ceil(11250 / 100)) = 113, * 100 = 11300.
upper_limit = (np.ceil(max_val / bin_width)) * bin_width
# Creamos los bordes de los bins desde 0 hasta el límite superior, en pasos de 100.
# Esto generará [0, 100, 200, 300, ... , 11300]
bin_edges = np.arange(0, upper_limit + bin_width, bin_width)
# --- FIN DE LA CORRECCIÓN ---
hist, bin_edges = np.histogram(df[column], bins=bin_edges)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bin_widths = bin_edges[1:] - bin_edges[:-1]
total = len(df)
hover_text = []
# Transformación personalizada para hacer más visibles los valores pequeños
hist_transformed = []
for i in range(len(hist)):
# Percentil: % de pilotos con menos iRating que el límite inferior del bin superior
below = (df[column] < bin_edges[i+1]).sum()
percentile = below / total * 100
top_percent = 100 - percentile
# --- CORRECCIÓN: Mostrar el rango correctamente (ej. 0-99) ---
hover_text.append(
f"Range: {int(bin_edges[i])}-{int(bin_edges[i+1]-1)}<br>"
f"Drivers: {hist[i]}<br>"
f"Top: {top_percent:.2f}%"
)
# Transformación: valores pequeños (0-50) los amplificamos
if hist[i] <= 50 and hist[i] > 0:
hist_transformed.append(hist[i] + 2)
else:
hist_transformed.append(hist[i])
fig = go.Figure(data=go.Bar(
x=bin_centers,
y=hist_transformed, # <-- Usamos los valores transformados para visualización
width=bin_widths * 1,
hovertext=hover_text, # <-- Pero en el hover mostramos los valores reales
hovertemplate='%{hovertext}<extra></extra>',
marker=dict(color=hist, colorscale=[
[0.0, "#FFFFFF"], # Empieza con un azul claro (LightSkyBlue)
[0.1, "#A0B8EC"],
[0.3, "#668CDF"], # Pasa por un cian muy pálido (LightCyan)
[1.0, "rgba(0,111,255,1)"] # Termina en blanco
])
))
# --- NUEVO: Lógica para añadir la línea de señalización ---
if highlight_irating is not None and highlight_name is not None:
fig.add_vline(
x=highlight_irating,
line_width=.5,
line_dash="dot",
annotation_position="top left", # Mejor posición para texto vertical
annotation_textangle=-90, # <-- AÑADE ESTA LÍNEA PARA ROTAR EL TEXTO
line_color="white",
annotation_text=f"<b>{highlight_name}</b>",
annotation_font_size=10,
annotation_font_color="white"
)
# --- FIN DEL BLOQUE NUEVO ---
fig.update_layout(
font=GLOBAL_FONT,
xaxis=dict(
title_text='iRating', # Texto del título
title_font=dict(size=12), # Estilo de la fuente del título
showgrid=True,
gridwidth=1,
gridcolor='rgba(255,255,255,0.1)'
),
yaxis=dict(
title_text='Qty. Drivers', # Texto del título
title_font=dict(size=12), # Estilo de la fuente del título
showgrid=True,
gridwidth=1,
gridcolor='rgba(255,255,255,0.1)'
),
template='plotly_dark',
hovermode='x unified',
paper_bgcolor='rgba(18,18,26,0)',
plot_bgcolor='rgba(255,255,255,0)',
# --- MODIFICACIÓN: Reducir márgenes y tamaño de fuentes ---
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
)
return fig
def create_correlation_heatmap(df):
# Seleccionar solo columnas numéricas para la correlación
numeric_df = df.select_dtypes(include=np.number)
corr_matrix = numeric_df.corr()
fig = go.Figure(data=go.Heatmap(
z=corr_matrix.values,
x=corr_matrix.columns,
y=corr_matrix.columns,
colorscale='RdBu_r', # Rojo-Azul invertido (Rojo=positivo, Azul=negativo)
zmin=-1, zmax=1,
text=corr_matrix.values,
texttemplate="%{text:.2f}",
textfont={"size":12}
))
fig.update_layout(
title='🔁 Correlation between Variables',
template='plotly_dark',
margin=dict(l=40, r=20, t=40, b=20)
)
return fig
def create_starts_vs_irating_scatter(df):
"""Crea un gráfico de dispersión no interactivo para Carreras vs. iRating."""
# Para un rendimiento óptimo, si hay demasiados datos, tomamos una muestra aleatoria.
# Esto evita sobrecargar el navegador del cliente sin perder la forma general de la distribución.
if len(df) > 50000:
df_sample = df.sample(n=50000, random_state=42)
else:
df_sample = df
# Usamos go.Scattergl que está optimizado para grandes datasets.
fig = go.Figure(data=go.Scattergl(
x=df_sample['IRATING'],
y=df_sample['STARTS'],
mode='markers',
marker=dict(
color='rgba(0, 111, 255, 0.3)', # Color azul semitransparente
# --- CORRECCIÓN: Puntos 50% más grandes (de 4 a 6) ---
size=6,
line=dict(width=0)
),
# Desactivamos el hover para máxima velocidad ya que es estático.
hoverinfo='none'
))
fig.update_layout(
font=GLOBAL_FONT,
xaxis_title='iRating',
yaxis_title='Races (Starts)',
template='plotly_dark',
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
xaxis=dict(range=[0, 12000], showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
# --- CORRECCIÓN: Altura máxima de 1500 en el eje Y ---
yaxis=dict(range=[0, 1500], showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99)
)
return fig
def flag_img(code):
url = f"https://flagcdn.com/16x12/{code.lower()}.png"
# La función ahora asume que si el código llega aquí, es válido.
# La comprobación se hará una sola vez al crear el diccionario.
return f'![{code}]({url})'
GLOBAL_FONT = {'family': "Lato, sans-serif"}
country_coords = {
'ES': {'lat': 40.4, 'lon': -3.7}, 'US': {'lat': 39.8, 'lon': -98.5},
'BR': {'lat': -14.2, 'lon': -51.9}, 'DE': {'lat': 51.1, 'lon': 10.4},
'FR': {'lat': 46.2, 'lon': 2.2}, 'IT': {'lat': 41.8, 'lon': 12.5},
'GB': {'lat': 55.3, 'lon': -3.4}, 'PT': {'lat': 39.3, 'lon': -8.2},
'NL': {'lat': 52.1, 'lon': 5.2}, 'AU': {'lat': -25.2, 'lon': 133.7},
'JP': {'lat': 36.2, 'lon': 138.2}, 'CA': {'lat': 56.1, 'lon': -106.3},
'AR': {'lat': -38.4, 'lon': -63.6}, 'MX': {'lat': 23.6, 'lon': -102.5},
'CL': {'lat': -35.6, 'lon': -71.5}, 'BE': {'lat': 50.5, 'lon': 4.4},
'FI': {'lat': 61.9, 'lon': 25.7}, 'SE': {'lat': 60.1, 'lon': 18.6},
'NO': {'lat': 60.4, 'lon': 8.4}, 'DK': {'lat': 56.2, 'lon': 9.5},
'IE': {'lat': 53.4, 'lon': -8.2}, 'CH': {'lat': 46.8, 'lon': 8.2},
'AT': {'lat': 47.5, 'lon': 14.5}, 'PL': {'lat': 51.9, 'lon': 19.1},
}
# --- 1. Carga y Preparación de Datos ---
df = DISCIPLINE_DATAFRAMES['ROAD.csv']
df = df[df['IRATING'] > 1]
df = df[df['STARTS'] > 1]
#df = df[df['STARTS'] < 2000]
df = df[df['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
# --- AÑADE ESTE BLOQUE PARA CREAR LA COLUMNA 'REGION' ---
# 1. Invertir el diccionario iracing_ragions para un mapeo rápido
country_to_region_map = {country: region
for region, countries in iracing_ragions.items()
for country in countries}
# 2. Crear la nueva columna 'REGION' usando el mapa
df['REGION'] = df['LOCATION'].map(country_to_region_map)
# 3. Rellenar los países no encontrados con un valor por defecto
df['REGION'].fillna('International', inplace=True)
# --- FIN DEL BLOQUE AÑADIDO ---
# --- AÑADE ESTE BLOQUE PARA CALCULAR LOS RANKINGS ---
# --- CORRECCIÓN: Cambiamos method='dense' por method='first' para rankings únicos ---
df['Rank World'] = df['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
df['Rank Region'] = df.groupby('REGION')['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
df['Rank Country'] = df.groupby('LOCATION')['IRATING'].rank(method='first', ascending=False).fillna(0).astype(int)
# --- FIN DEL BLOQUE AÑADIDO ---
df['CONTINENT'] = df['LOCATION'].apply(country_to_continent_code)
df['CLASS'] = df['CLASS'].str[0]
#df = df[df['IRATING'] < 10000]
# --- MODIFICACIÓN: Definimos las columnas que queremos en la tabla ---
# Usaremos esta lista más adelante para construir el df_table dinámicamente
TABLE_COLUMNS = ['DRIVER', 'IRATING', 'LOCATION', 'Rank World', 'Rank Region', 'Rank Country']
df_table = df[TABLE_COLUMNS]
df_for_graphs = df.copy() # Usamos una copia completa para los gráficos
# --- MODIFICACIÓN: Nos aseguramos de que el df principal tenga todas las columnas necesarias ---
df = df[['DRIVER','IRATING','LOCATION','STARTS','WINS','AVG_START_POS','AVG_FINISH_POS','AVG_INC','TOP25PCNT', 'REGION', 'Rank World', 'Rank Region', 'Rank Country','CLASS']]
# Aplica solo el emoji/código si está in country_flags, si no deja el valor original
# OJO: Esta línea ahora debe ir dentro del callback, ya que las columnas cambian
# df_table['LOCATION'] = df_table['LOCATION'].map(lambda x: flag_img(x) if x in country_flags else x)
#df['LOCATION'] = 'a'
density_heatmap = dcc.Graph(
id='density-heatmap',
# --- CORRECCIÓN: Ajustamos la altura para que sea más visible ---
style={'height': '600px', 'borderRadius': '15px', 'overflow': 'hidden'},
figure=create_density_heatmap(df_for_graphs),
config={'displayModeBar': False} # <-- AÑADIMOS ESTO
)
correlation_heatmap = dcc.Graph(
id='correlation-heatmap',
# --- CORRECCIÓN: Ajustamos la altura a un valor más estándar ---
style={'height': '500px'},
# Usamos las columnas numéricas del dataframe original
figure=create_correlation_heatmap(df[['IRATING', 'STARTS', 'WINS','TOP25PCNT','AVG_INC','AVG_FINISH_POS']]),
config={'displayModeBar': False} # <-- AÑADIMOS ESTO
)
kpi_global = dcc.Graph(id='kpi-global', style={'height': '6vh', 'marginBottom': '0px', 'marginTop': '20px'})
kpi_pilot = dcc.Graph(id='kpi-pilot', style={'height': '3hv', 'marginBottom': '10px'})
histogram_irating = dcc.Graph(
id='histogram-plot',
# --- MODIFICACIÓN: Ajustamos el estilo del contenedor del gráfico ---
style={
'height': '26vh',
'borderRadius': '10px', # Coincide con el radio de los filtros
'border': '1px solid #4A4A4A', # Coincide con el borde de los filtros
'overflow': 'hidden'
},
# --- FIN DE LA MODIFICACIÓN ---
figure=create_histogram_with_percentiles(df, 'IRATING', 100) # 100 = ancho de cada bin
)
# --- MODIFICACIÓN: Simplificamos la definición inicial de la tabla ---
# Las columnas se generarán dinámicamente en el callback
interactive_table = dash_table.DataTable(
id='datatable-interactiva',
# columns se define en el callback
data=[], # Inicialmente vacía
sort_action="custom",
sort_mode="single",
page_action="custom",
page_current=0,
page_size=100, # CAMBIO: De 20 a 100 elementos por página
page_count=len(df_table) // 100 + (1 if len(df_table) % 100 > 0 else 0), # CAMBIO: Actualizar cálculo
virtualization=False,
style_as_list_view=True,
active_cell={'row': 21,'column':1},
style_table={
'overflowX': 'auto',
'overflowY': 'auto', # SCROLL VERTICAL habilitado
'height': '100%', # CAMBIO: Usar 100% del contenedor padre
'maxHeight': '100%', # CAMBIO: Usar 100% del contenedor padre
'minHeight': '700px', # CAMBIO: Altura mínima aumentada
'width': '100%',
'borderRadius': '15px',
'overflow': 'hidden',
'backgroundColor': 'rgba(11,11,19,1)',
'border': '1px solid #4A4A4A'
},
style_cell={
'textAlign': 'center',
'padding': '6px 3px', # CAMBIO: Padding más compacto para aprovechar espacio
'backgroundColor': 'rgba(11,11,19,1)',
'color': 'rgb(255, 255, 255,.8)',
'border': '1px solid rgba(255, 255, 255, 0.1)',
'overflow': 'hidden',
'textOverflow': 'ellipsis',
'whiteSpace': 'nowrap',
'maxWidth': 0,
'fontSize': '11px' # CAMBIO: Fuente ligeramente más pequeña para más filas
},
style_header={
'backgroundColor': 'rgba(30,30,38,1)',
'fontWeight': 'bold',
'color': 'white',
'border': '1px solid rgba(255, 255, 255, 0.2)',
'textAlign': 'center',
'fontSize': '12px',
'position': 'sticky', # Header pegajoso
'top': 0, # Se queda arriba al hacer scroll
'zIndex': 10, # Prioridad visual
'padding': '6px 3px' # CAMBIO: Padding más compacto
},
# --- AÑADIMOS ESTILO PARA LA FILA SELECCIONADA Y LAS CLASES ---
style_data_conditional=[
{
'if': {'state': 'active'},
'backgroundColor': 'rgba(0, 111, 255, 0.3)',
'border': '1px solid rgba(0, 111, 255)'
},
{
'if': {'state': 'selected'},
'backgroundColor': 'rgba(0, 111, 255, 0)',
'border': '1px solid rgba(0, 111, 255,0)'
},
# --- REGLAS MEJORADAS CON BORDES REDONDEADOS ---
{'if': {'filter_query': '{CLASS} contains "P"','column_id': 'CLASS'},
'backgroundColor': 'rgba(54,54,62,255)', 'color': 'rgba(166,167,171,255)', 'fontWeight': 'bold','border': '1px solid rgba(134,134,142,255)'},
{'if': {'filter_query': '{CLASS} contains "A"','column_id': 'CLASS'},
'backgroundColor': 'rgba(0,42,102,255)', 'color': 'rgba(107,163,238,255)', 'fontWeight': 'bold','border': '1px solid rgba(35,104,195,255)'},
{'if': {'filter_query': '{CLASS} contains "B"','column_id': 'CLASS'},
'backgroundColor': 'rgba(24,84,14,255)', 'color': 'rgba(139,224,105,255)', 'fontWeight': 'bold','border': '1px solid rgba(126,228,103,255)'},
{'if': {'filter_query': '{CLASS} contains "C"','column_id': 'CLASS'},
'backgroundColor': 'rgba(81,67,6,255)', 'color': 'rgba(224,204,109,255)', 'fontWeight': 'bold','border': '1px solid rgba(220,193,76,255)'},
{'if': {'filter_query': '{CLASS} contains "D"','column_id': 'CLASS'},
'backgroundColor': 'rgba(102,40,3,255)', 'color': 'rgba(255,165,105,255)', 'fontWeight': 'bold','border': '1px solid rgba(208,113,55,255)'},
{'if': {'filter_query': '{CLASS} contains "R"','column_id': 'CLASS'},
'backgroundColor': 'rgba(91,19,20,255)', 'color': 'rgba(225,125,123,255)', 'fontWeight': 'bold','border': '1px solid rgba(172,62,61,255)'},
],
# --- MODIFICACIÓN: Forzamos el ancho de las columnas ---
style_cell_conditional=[
{'if': {'column_id': 'CLASS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
{'if': {'column_id': 'Rank World'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'Rank Region'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'Rank Country'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'DRIVER'}, 'width': '30%', 'minWidth': '30%', 'maxWidth': '30%', 'textAlign': 'left'},
{'if': {'column_id': 'IRATING'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'LOCATION'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'WINS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
{'if': {'column_id': 'STARTS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
{'if': {'column_id': 'REGION'}, 'width': '15%', 'minWidth': '15%', 'maxWidth': '15%'},
],
style_data={
'whiteSpace':'normal',
'textAlign': 'center',
'fontSize': 10,},
# Renderizado virtual
)
scatter_irating_starts = dcc.Graph(
id='scatter-irating',
style={'height': '30vh','borderRadius': '15px','overflow': 'hidden'},
# Usamos go.Scattergl en lugar de px.scatter para un rendimiento masivo
figure=go.Figure(data=go.Scattergl(
x=df['IRATING'],
y=df['STARTS'],
mode='markers',
marker=dict(
color='rgba(0,111,255,.3)', # Color semitransparente
size=5,
line=dict(width=0)
),
# Desactivamos el hover para máxima velocidad
hoverinfo='none'
)).update_layout(
title='Relación iRating vs. Carreras Iniciadas',
xaxis_title='iRating',
yaxis_title='Carreras Iniciadas (Starts)',
template='plotly_dark',
paper_bgcolor='rgba(30,30,38,1)', # Fondo de toda la figura (transparente)
plot_bgcolor='rgba(30,30,38,1)', # Fondo del área de las barras (transparente)
# --- FIN DE LAS LÍNEAS AÑADIDAS ---
xaxis=dict(showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)'),
yaxis=dict(showgrid=True, gridwidth=1, gridcolor='rgba(255,255,255,0.1)')
),
# Hacemos el gráfico estático (no interactivo) para que sea aún más rápido
config={'staticPlot': True}
)
continent_map = dcc.Graph(
id='continent-map',
style={'height': '55vh'},
figure=create_continent_map(df_for_graphs)
)
region_bubble_chart = dcc.Graph(
id='region-bubble-chart',
style={'height': '33vh','borderRadius': '10px','border': '1px solid #4A4A4A',
'overflow': 'hidden'},
figure=create_region_bubble_chart(df)
)
# --- 3. Inicialización de la App ---
app = dash.Dash(__name__,title='Top iRating')
server = app.server # <-- AÑADE ESTA LÍNEA
# Layout principal MODIFICADO
app.layout = html.Div(
style={
'margin': '0',
'padding': '0',
'backgroundColor': 'rgba(5,5,15,255)',
'color': '#ffffff',
'fontFamily': 'Lato, sans-serif',
'minHeight': '100vh'
},
children=[
# CONTENEDOR PRINCIPAL CENTRADO
html.Div(
style={
'maxWidth': '1400px',
'margin': '0 auto',
'padding': '20px 5vw',
'minHeight': '100vh',
'display': 'flex',
'flexDirection': 'column',
'gap': '30px'
},
children=[
# 1. SECCIÓN HEADER - Título y Botones
html.Div(
style={
'textAlign': 'center',
'marginBottom': '20px'
},
children=[
html.H1(
"🏁 Top iRating",
style={
'fontSize': 'clamp(36px, 5vw, 48px)',
'color': 'white',
'margin': '0 0 20px 0',
'fontWeight': '900'
}
),
# --- AÑADE ESTE COMPONENTE AQUÍ ---
html.P(
id='last-update-display',
style={
'color': '#A0A0A0',
'fontSize': '12px',
'margin': '-15px 0 20px 0', # Margen para acercarlo al título
'fontStyle': 'italic'
}
),
html.P(
"Only drivers with 1 < race start and 1 < irating are consider",
style={
'color': '#A0A0A0',
'fontSize': '12px',
'margin': '-15px 0 20px 0', # Margen para acercarlo al título
'fontStyle': 'italic'
}
),
# --- FIN DEL BLOQUE A AÑADIR ---
html.Div(
style={
'display': 'flex',
'justifyContent': 'center',
'gap': '10px',
'flexWrap': 'wrap'
},
children=[
html.Button('Sports Car', id='btn-road', n_clicks=0, className='dashboard-type-button'),
html.Button('Formula', id='btn-formula', n_clicks=0, className='dashboard-type-button'),
html.Button('Oval', id='btn-oval', n_clicks=0, className='dashboard-type-button'),
html.Button('Dirt Road', id='btn-dirt-road', n_clicks=0, className='dashboard-type-button'),
html.Button('Dirt Oval', id='btn-dirt-oval', n_clicks=0, className='dashboard-type-button'),
]
)
]
),
# 2. SECCIÓN MAPA Y KPIs GLOBALES - Solo KPIs globales superpuestos
html.Div(
style={
'position': 'relative',
'top': '0px',
'backgroundColor': 'transparent', # Fondo transparente
'borderRadius': '0px', # Sin bordes redondeados
'border': '0px solid #4A4A4A',
'padding': '20px',
'overflow': 'hidden'
},
children=[
# Solo KPIs globales superpuestos
html.Div(
style={
'position': 'absolute',
'top': '0px',
'left': '50%',
'transform': 'translateX(-50%)',
'width': '80%',
'maxWidth': '800px',
'zIndex': '10',
'backgroundColor': 'rgba(11,11,19,0)',
'borderRadius': '10px',
'border': '0px solid #4A4A4A',
'padding': '10px'
},
children=[
dcc.Graph(id='kpi-global', style={'height': '50px', 'margin': '0'})
]
),
# Mapa de fondo
dcc.Graph(
id='continent-map',
style={'height': '650px',
'backgroundColor': 'transparent',
'margin': '0'},
config={'displayModeBar': False}
)
]
),
# 3. SECCIÓN FILTROS Y TABLA - Lado a lado CON KPIs DEL PILOTO
html.Div(
style={
'display': 'flex',
'gap': '20px',
'flexWrap': 'wrap'
},
children=[
# Contenedor de Filtros CON KPIs DEL PILOTO
html.Div(
style={
'flex': '1',
'minWidth': '300px',
'maxWidth': '600px',
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '20px'
},
children=[
html.H3(
"Filters",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '20px',
'fontWeight': '700'
}
),
# Filtros de región y país
html.Div(
style={
'display': 'flex',
'gap': '15px',
'marginBottom': '20px',
'flexDirection': 'column'
},
children=[
html.Div([
html.Label(
"Region:",
style={
'color': 'white',
'fontSize': '14px',
'marginBottom': '5px',
'display': 'block',
'textAlign': 'center'
}
),
dcc.Dropdown(
id='region-filter',
options=[{'label': 'All', 'value': 'ALL'}] +
[{'label': region, 'value': region} for region in sorted(iracing_ragions.keys())],
value='ALL',
className='iracing-dropdown'
)
]),
html.Div([
html.Label(
"Country:",
style={
'color': 'white',
'fontSize': '14px',
'marginBottom': '5px',
'display': 'block',
'textAlign': 'center'
}
),
dcc.Dropdown(
id='country-filter',
options=[{'label': 'All', 'value': 'ALL'}],
value='ALL',
className='iracing-dropdown'
)
])
]
),
# Búsqueda de piloto
html.Div([
html.Label(
"Search Driver:",
style={
'color': 'white',
'fontSize': '14px',
'marginBottom': '5px',
'display': 'block',
'textAlign': 'center'
}
),
dcc.Dropdown(
id='pilot-search-dropdown',
options=[],
placeholder='Search Driver...',
className='iracing-dropdown',
searchable=True,
clearable=True,
search_value=''
),
# --- NUEVOS COMPONENTES PARA DEBOUNCING ---
dcc.Interval(
id='search-debounce-interval',
interval=400, # 400ms de delay
n_intervals=0,
disabled=True # Inicialmente deshabilitado
),
dcc.Store(id='last-search-store', data='')
]),
# NUEVA SECCIÓN: KPIs del piloto debajo de los filtros
html.Div(
style={
'marginTop': '30px',
'padding': '15px',
'backgroundColor': 'rgba(11,11,19,0.8)',
'borderRadius': '10px',
'border': '1px solid #4A4A4A'
},
children=[
html.H4(
"Selected Driver",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '15px',
'fontSize': '16px',
'fontWeight': '700'
}
),
dcc.Graph(
id='kpi-pilot',
style={
'height': '260px', # Aumentamos altura del contenedor
'margin': '0'
}
)
]
)
]
),
# Contenedor de Tabla MODIFICADO
html.Div(
style={
'flex': '2',
'minWidth': '300px',
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '20px',
'display': 'flex', # CAMBIO: Usar flexbox
'flexDirection': 'column', # CAMBIO: Dirección vertical
'height': '880px' # CAMBIO: Altura fija del contenedor
},
children=[
html.H3(
"Rankings",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '20px',
'fontWeight': '700',
'flexShrink': 0 # CAMBIO: No se encoge
}
),
# Contenedor específico para la tabla
html.Div(
style={
'flex': '1', # CAMBIO: Ocupa todo el espacio restante
'display': 'flex',
'flexDirection': 'column',
'minHeight': 0 # CAMBIO: Permite que se encoja si es necesario
},
children=[
dash_table.DataTable(
id='datatable-interactiva',
data=[],
sort_action="custom",
sort_mode="single",
page_action="custom",
page_current=0,
page_size=100, # CAMBIO: 100 elementos por página
page_count=len(df_table) // 100 + (1 if len(df_table) % 100 > 0 else 0),
virtualization=False,
style_as_list_view=True,
active_cell={'row': 101,'column':1},
style_table={
'overflowX': 'auto',
'overflowY': 'auto',
'height': '100%', # CAMBIO: Usa todo el espacio del contenedor padre
'maxHeight': '100%', # CAMBIO: No limitar altura
'minHeight': '820px',
'width': '100%',
'borderRadius': '15px',
'overflow': 'hidden',
'backgroundColor': 'rgba(11,11,19,1)',
'border': '1px solid #4A4A4A'
},
style_cell={
'textAlign': 'center',
'padding': '3px 1px', # Padding más compacto
'backgroundColor': 'rgba(11,11,19,1)',
'height': '15px',
'color': 'rgb(255, 255, 255,.8)',
'border': '1px solid rgba(255, 255, 255, 0.1)',
'overflow': 'hidden',
'textOverflow': 'ellipsis',
'whiteSpace': 'nowrap',
'maxWidth': 0,
'fontSize': '11px' # Fuente más compacta
},
style_header={
'backgroundColor': 'rgba(30,30,38,1)',
'fontWeight': 'bold',
'color': 'white',
'border': '1px solid rgba(255, 255, 255, 0.2)',
'textAlign': 'center',
'fontSize': '12px',
'position': 'sticky',
'top': 0,
'zIndex': 10,
'padding': '6px 3px'
},
# Mantén todos tus estilos existentes
style_data_conditional=[
{
'if': {'state': 'active'},
'backgroundColor': 'rgba(0, 111, 255, 0.3)',
'border': '1px solid rgba(0, 111, 255)'
},
{
'if': {'state': 'selected'},
'backgroundColor': 'rgba(0, 111, 255, 0)',
'border': '1px solid rgba(0, 111, 255,0)'
},
# --- REGLAS MEJORADAS CON BORDES REDONDEADOS ---
{'if': {'filter_query': '{CLASS} contains "P"','column_id': 'CLASS'},
'backgroundColor': 'rgba(54,54,62,255)', 'color': 'rgba(166,167,171,255)', 'fontWeight': 'bold','border': '1px solid rgba(134,134,142,255)'},
{'if': {'filter_query': '{CLASS} contains "A"','column_id': 'CLASS'},
'backgroundColor': 'rgba(0,42,102,255)', 'color': 'rgba(107,163,238,255)', 'fontWeight': 'bold','border': '1px solid rgba(35,104,195,255)'},
{'if': {'filter_query': '{CLASS} contains "B"','column_id': 'CLASS'},
'backgroundColor': 'rgba(24,84,14,255)', 'color': 'rgba(139,224,105,255)', 'fontWeight': 'bold','border': '1px solid rgba(126,228,103,255)'},
{'if': {'filter_query': '{CLASS} contains "C"','column_id': 'CLASS'},
'backgroundColor': 'rgba(81,67,6,255)', 'color': 'rgba(224,204,109,255)', 'fontWeight': 'bold','border': '1px solid rgba(220,193,76,255)'},
{'if': {'filter_query': '{CLASS} contains "D"','column_id': 'CLASS'},
'backgroundColor': 'rgba(102,40,3,255)', 'color': 'rgba(255,165,105,255)', 'fontWeight': 'bold','border': '1px solid rgba(208,113,55,255)'},
{'if': {'filter_query': '{CLASS} contains "R"','column_id': 'CLASS'},
'backgroundColor': 'rgba(91,19,20,255)', 'color': 'rgba(225,125,123,255)', 'fontWeight': 'bold','border': '1px solid rgba(172,62,61,255)'},
],
style_cell_conditional=[
{'if': {'column_id': 'CLASS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
{'if': {'column_id': 'Rank World'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'Rank Region'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'Rank Country'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'DRIVER'}, 'width': '30%', 'minWidth': '30%', 'maxWidth': '30%', 'textAlign': 'center'},
{'if': {'column_id': 'IRATING'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'LOCATION'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%', 'justify-content': 'center', 'align-items': 'center'},
{'if': {'column_id': 'WINS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
{'if': {'column_id': 'STARTS'}, 'width': '5%', 'minWidth': '5%', 'maxWidth': '5%'},
{'if': {'column_id': 'REGION'}, 'width': '20%', 'minWidth': '20%', 'maxWidth': '20%'},
]
)
]
)
]
)
]
),
# 4. SECCIÓN HISTOGRAMA - Ancho completo (sin cambios)
html.Div(
style={
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '20px'
},
children=[
html.H3(
"iRating Distribution",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '20px',
'fontWeight': '700'
}
),
dcc.Graph(
id='histogram-plot',
style={'height': '350px'},
config={'displayModeBar': False}
)
]
),
# 5. SECCIÓN GRÁFICOS ADICIONALES (sin cambios)
html.Div(
style={
'display': 'flex',
'flexDirection': 'column',
'gap': '20px'
},
children=[
# Primera fila: Tabla de competitividad (sin cambios)
html.Div(
style={
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '20px'
},
children=[
html.H3(
"Top Competitive Regions & Countries",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '10px',
'fontWeight': '700'
}
),
html.P(
"Based on average iRating of top 100 drivers per region/country (minimum 100 drivers required)",
style={
'color': '#CCCCCC',
'textAlign': 'center',
'marginBottom': '20px',
'fontSize': '12px',
'fontStyle': 'italic'
}
),
html.Div(id='competitiveness-tables-container')
]
),
# CAMBIO: Ahora los gráficos están en columna vertical
# Primer gráfico: Regional Analysis
html.Div(
style={
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '20px'
},
children=[
html.H3(
"Regional Analysis",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '5px',
'fontWeight': '700'
}
),
html.P(
"Average races with average iRating relation, bubble size represents quantity drivers in region.",
style={
'color': '#CCCCCC',
'textAlign': 'center',
'marginBottom': '20px',
'fontSize': '12px',
'fontStyle': 'italic'
}
),
dcc.Graph(
id='region-bubble-chart',
style={'height': '400px'}, # Aumentamos altura ya que ahora ocupa todo el ancho
config={'displayModeBar': False}
)
]
),
# Segundo gráfico: Experience vs Performance
html.Div(
style={
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '20px'
},
children=[
html.H3(
"Average Races vs iRating",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '5px',
'fontWeight': '700'
}
),
html.P(
"Average races for iRating ranges",
style={
'color': '#CCCCCC',
'textAlign': 'center',
'marginBottom': '20px',
'fontSize': '12px',
'fontStyle': 'italic'
}
),
dcc.Graph(
id='irating-starts-scatter',
style={'height': '400px'}, # Aumentamos altura ya que ahora ocupa todo el ancho
config={'displayModeBar': False}
)
]
),
# --- INICIO DE GRÁFICOS AÑADIDOS ---
# Tercer gráfico: Density Heatmap (iRating vs Incidents)
html.Div(
style={
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '10px'
},
children=[
html.H3(
"Incidents vs iRating",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '5px',
'fontWeight': '700'
}
),
html.P(
"Correlation between incidents and iRating",
style={
'color': '#CCCCCC',
'textAlign': 'center',
'marginBottom': '0px',
'fontSize': '12px',
'fontStyle': 'italic'
}
),
# Usamos la variable que ya definimos
density_heatmap
]
),
html.Div(
style={
'backgroundColor': 'rgba(18,18,26,.5)',
'borderRadius': '15px',
'border': '1px solid #4A4A4A',
'padding': '10px'
},
children=[
html.H3(
"Races vs iRating", # <-- Título actualizado
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '5px',
'fontWeight': '700'
}
),
html.P(
"iRating - races scatter",
style={
'color': '#CCCCCC',
'textAlign': 'center',
'marginBottom': '0px',
'fontSize': '12px',
'fontStyle': 'italic'
}
),
# Añadimos el nuevo gráfico aquí
dcc.Graph(
id='starts-vs-irating-heatmap',
style={'height': '600px'},
config={'staticPlot': True} # <-- CLAVE: Hace el gráfico no interactivo
)
]
)
# --- FIN DE GRÁFICOS AÑADIDOS ---
]
)
]
),
# Componentes ocultos (sin cambios)
dcc.Store(id='active-discipline-store', data='ROAD.csv'),
dcc.Store(id='shared-data-store', data={}),
dcc.Store(id='shared-data-store_1', data={}),
html.Div(id='pilot-info-display', style={'display': 'none'}),
# --- AÑADE ESTE COMPONENTE AQUÍ ---
dcc.Interval(
id='update-timestamp-interval',
interval=60 * 1000, # Cada minuto (en milisegundos)
n_intervals=0
)
# --- FIN DEL BLOQUE A AÑADIR ---
]
)
# --- 4. Callbacks ---
# --- CALLBACK MODIFICADO PARA MOSTRAR LA FECHA CON ESTADO Y COLORES ---
@app.callback(
Output('last-update-display', 'children'),
Input('update-timestamp-interval', 'n_intervals')
)
def update_timestamp_display(n):
if not DATA_STATUS:
return "Checking data status..."
status = DATA_STATUS.get('status', 'unknown')
try:
# Convertimos la fecha UTC del archivo a un objeto datetime
last_update_utc = datetime.fromisoformat(DATA_STATUS['last_update_utc'].replace('Z', '+00:00'))
# Calculamos cuánto tiempo ha pasado
age = datetime.now(timezone.utc) - last_update_utc
# Convertimos a la hora local para mostrarla
last_update_local = last_update_utc.astimezone(tz=None)
formatted_time = last_update_local.strftime('%B %d, %Y')
# Lógica de colores y mensajes
if status.lower() != 'success':
color = '#FF5A5A' # Rojo para fallos
message = f"⚠️ Last update attempt failed on: {formatted_time}"
elif age.total_seconds() > (13 * 3600): # Si tiene más de 13 horas (damos 1h de margen)
color = '#FFA500' # Naranja para datos viejos
message = f"Warning: Data is over 12 hours old. Last updated: {formatted_time}"
else:
color = '#50C878' # Verde para éxito
message = f"Data last updated on: {formatted_time}"
except Exception as e:
color = '#FF5A5A'
message = f"Error reading update status: {e}"
# Devolvemos un componente html.P para poder aplicarle el estilo de color
return html.P(message, style={'color': color, 'margin': 0, 'padding': 0})
# --- FIN DEL CALLBACK MODIFICADO ---
# --- NUEVO CALLBACK PARA ACTUALIZAR GRÁFICOS DE LA COLUMNA DERECHA ---
@app.callback(
Output('competitiveness-tables-container', 'children'),
Output('region-bubble-chart', 'figure'),
Output('irating-starts-scatter', 'figure'),
Output('density-heatmap', 'figure'),
# --- CAMBIO: Reemplazamos la salida del gráfico de correlación por el nuevo ---
Output('starts-vs-irating-heatmap', 'figure'),
Input('active-discipline-store', 'data'))
def update_right_column_graphs(filename):
# 1. Cargar y procesar los datos de la disciplina seleccionada
df_discipline = DISCIPLINE_DATAFRAMES[filename]
df_discipline = df_discipline[df_discipline['IRATING'] > 1]
df_discipline = df_discipline[df_discipline['STARTS'] > 1]
df_discipline = df_discipline[df_discipline['CLASS'].str.contains('D|C|B|A|P|R', na=False)]
country_to_region_map = {country: region for region, countries in iracing_ragions.items() for country in countries}
df_discipline['REGION'] = df_discipline['LOCATION'].map(country_to_region_map).fillna('International')
# 2. Calcular y crear las tablas de competitividad
top_regions, top_countries = calculate_competitiveness(df_discipline)
top_regions.insert(0, '#', range(1, 1 + len(top_regions)))
top_countries.insert(0, '#', range(1, 1 + len(top_countries)))
# Traducir códigos de país a nombres completos
def get_country_name(code):
try:
return pycountry.countries.get(alpha_2=code).name
except (LookupError, AttributeError):
return code
top_countries['LOCATION'] = top_countries['LOCATION'].apply(get_country_name)
# ESTILOS CORREGIDOS - Control estricto de ancho
table_style_base = {
'style_table': {
'borderRadius': '10px',
'overflow': 'hidden',
'border': '1px solid #4A4A4A',
'backgroundColor': 'rgba(11,11,19,1)',
'height': '350px',
'overflowY': 'auto',
'overflowX': 'hidden', # CLAVE: Prevenir scroll horizontal
'width': '100%',
'maxWidth': '100%' # CLAVE: Forzar límite de ancho
},
'style_cell': {
'textAlign': 'center',
'padding': '6px 4px', # Padding más compacto
'backgroundColor': 'rgba(11,11,19,1)',
'color': 'rgb(255, 255, 255,.8)',
'border': 'none',
'fontSize': '11px', # Fuente más pequeña
'textOverflow': 'ellipsis',
'whiteSpace': 'nowrap',
'overflow': 'hidden',
'maxWidth': '0' # CLAVE: Forzar truncado de texto
},
'style_header': {
'backgroundColor': 'rgba(30,30,38,1)',
'fontWeight': 'bold',
'color': 'white',
'border': 'none',
'textAlign': 'center',
'fontSize': '12px',
'padding': '6px 4px',
'overflow': 'hidden',
'textOverflow': 'ellipsis'
},
'style_cell_conditional': [
{'if': {'column_id': '#'}, 'width': '10%', 'minWidth': '10%', 'maxWidth': '10%'},
{'if': {'column_id': 'REGION'}, 'width': '60%', 'minWidth': '60%', 'maxWidth': '60%', 'textAlign': 'left'},
{'if': {'column_id': 'LOCATION'}, 'width': '60%', 'minWidth': '60%', 'maxWidth': '60%', 'textAlign': 'left'},
{'if': {'column_id': 'avg_irating'}, 'width': '30%', 'minWidth': '30%', 'maxWidth': '30%'},
]
}
# CONTENEDOR CON CONTROL ESTRICTO DE ANCHO
competitiveness_tables = html.Div(
style={
'display': 'grid',
'gridTemplateColumns': '1fr 1fr',
'gap': '15px', # Gap más pequeño
'width': '100%',
'maxWidth': '100%',
'overflow': 'hidden',
'boxSizing': 'border-box' # CLAVE: Incluir padding en el ancho
},
children=[
# Tabla de Regiones
html.Div(
style={
'width': '100%',
'maxWidth': '100%',
'overflow': 'hidden',
'boxSizing': 'border-box' # CLAVE: Control de ancho
},
children=[
html.H4(
"Top Regions",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '10px',
'fontSize': '14px',
'margin': '0 0 10px 0'
}
),
html.Div(
style={
'width': '100%',
'maxWidth': '100%',
'overflow': 'hidden'
},
children=[
dash_table.DataTable(
columns=[
{'name': '#', 'id': '#'},
{'name': 'Region', 'id': 'REGION'},
{'name': 'Avg iRating', 'id': 'avg_irating', 'type': 'numeric', 'format': {'specifier': '.0f'}}
],
data=top_regions.to_dict('records'),
page_action='none',
style_table=table_style_base['style_table'],
style_cell=table_style_base['style_cell'],
style_header=table_style_base['style_header'],
style_cell_conditional=table_style_base['style_cell_conditional']
)
]
)
]
),
# Tabla de Países
html.Div(
style={
'width': '100%',
'maxWidth': '100%',
'overflow': 'hidden',
'boxSizing': 'border-box' # CLAVE: Control de ancho
},
children=[
html.H4(
"Top Countries",
style={
'color': 'white',
'textAlign': 'center',
'marginBottom': '10px',
'fontSize': '14px',
'margin': '0 0 10px 0'
}
),
html.Div(
style={
'width': '100%',
'maxWidth': '100%',
'overflow': 'hidden'
},
children=[
dash_table.DataTable(
columns=[
{'name': '#', 'id': '#'},
{'name': 'Country', 'id': 'LOCATION'},
{'name': 'Avg iRating', 'id': 'avg_irating', 'type': 'numeric', 'format': {'specifier': '.0f'}}
],
data=top_countries.to_dict('records'),
page_action='none',
style_table=table_style_base['style_table'],
style_cell=table_style_base['style_cell'],
style_header=table_style_base['style_header'],
style_cell_conditional=table_style_base['style_cell_conditional']
)
]
)
]
)
]
)
# 3. Crear los otros gráficos
bubble_chart_fig = create_region_bubble_chart(df_discipline)
line_chart_fig = create_irating_trend_line_chart(df_discipline)
# --- AÑADIMOS LA CREACIÓN DE LOS NUEVOS GRÁFICOS ---
density_fig = create_density_heatmap(df_discipline)
# --- CAMBIO: Creamos la figura para el nuevo gráfico ---
starts_scatter_fig = create_starts_vs_irating_scatter(df_discipline)
# 4. Devolver todos los componentes actualizados
# --- CAMBIO: Devolvemos la nueva figura en lugar de la antigua ---
return competitiveness_tables, bubble_chart_fig, line_chart_fig, density_fig, starts_scatter_fig
# --- ELIMINA EL CALLBACK update_data_source ---
@app.callback(
Output('active-discipline-store', 'data'),
Input('btn-road', 'n_clicks'),
Input('btn-formula', 'n_clicks'),
Input('btn-oval', 'n_clicks'),
Input('btn-dirt-road', 'n_clicks'),
Input('btn-dirt-oval', 'n_clicks'),
prevent_initial_call=True
)
def update_active_discipline(road, formula, oval, dr, do):
ctx = dash.callback_context
button_id = ctx.triggered_id.split('.')[0]
file_map = {
'btn-road': 'ROAD.csv',
'btn-formula': 'FORMULA.csv',
'btn-oval': 'OVAL.csv',
'btn-dirt-road': 'DROAD.csv',
'btn-dirt-oval': 'DOVAL.csv'
}
return file_map.get(button_id, 'ROAD.csv')
# NUEVO CALLBACK: Actualiza las opciones del filtro de país según la región seleccionada
@app.callback(
Output('country-filter', 'options'),
Input('region-filter', 'value')
)
def update_country_options(selected_region):
# --- MODIFICACIÓN: Traducir códigos de país a nombres completos ---
if not selected_region or selected_region == 'ALL':
# Si no hay región o es 'ALL', toma todos los códigos de país únicos del dataframe
country_codes = df['LOCATION'].dropna().unique()
else:
# Si se selecciona una región, toma solo los países de esa región
country_codes = iracing_ragions.get(selected_region, [])
options = [{'label': 'All', 'value': 'ALL'}]
# Crear una lista de tuplas (nombre_completo, codigo) para poder ordenarla por nombre
country_list_for_sorting = []
for code in country_codes:
try:
# Busca el país por su código de 2 letras y obtiene el nombre
country_name = pycountry.countries.get(alpha_2=code).name
country_list_for_sorting.append((country_name, code))
except (LookupError, AttributeError):
# Si no se encuentra (código inválido), usa el código como nombre para no romper la app
country_list_for_sorting.append((code, code))
# Ordenar la lista alfabéticamente por el nombre completo del país
sorted_countries = sorted(country_list_for_sorting)
# Crear las opciones para el dropdown a partir de la lista ordenada
for country_name, country_code in sorted_countries:
options.append({'label': country_name, 'value': country_code})
return options
# --- FIN DE LA MODIFICACIÓN ---
# --- NUEVO CALLBACK: FILTRO POR PAÍS AL HACER CLIC EN EL MAPA ---
@app.callback(
Output('country-filter', 'value'),
Input('continent-map', 'clickData'),
prevent_initial_call=True
)
def update_country_filter_on_map_click(clickData):
# Si no hay datos de clic (por ejemplo, al cargar la página), no hacemos nada.
if not clickData:
return dash.no_update
# Extraemos el código de país de 2 letras del 'customdata' que definimos en el gráfico.
# clickData['points'][0] se refiere al primer país clickeado.
# ['customdata'][0] se refiere al primer elemento de nuestra lista custom_data, que es 'LOCATION_2_LETTER'.
country_code = clickData['points'][0]['customdata'][0]
# Devolvemos el código del país, que actualizará el valor del dropdown 'country-filter'.
return country_code
# CALLBACK 1: Inicia el temporizador de debouncing cuando el usuario escribe.
@app.callback(
Output('search-debounce-interval', 'disabled'),
Input('pilot-search-dropdown', 'search_value')
)
def start_search_debounce(search_value):
# Si el texto de búsqueda es muy corto, deshabilita el temporizador.
if not search_value or len(search_value) < 3:
return True # Deshabilitado
# Si hay texto válido, habilita el temporizador (se disparará en 400ms).
return False # Habilitado
# CALLBACK 2: Ejecuta la búsqueda real cuando el temporizador se dispara.
@app.callback(
Output('pilot-search-dropdown', 'options'),
Output('last-search-store', 'data'),
Output('search-debounce-interval', 'disabled', allow_duplicate=True), # Deshabilita el timer después de usarlo
Input('search-debounce-interval', 'n_intervals'), # Se activa por el temporizador
State('pilot-search-dropdown', 'search_value'),
State('pilot-search-dropdown', 'value'),
State('region-filter', 'value'),
State('country-filter', 'value'),
State('active-discipline-store', 'data'),
State('last-search-store', 'data'),
prevent_initial_call=True
)
def update_pilot_search_options_debounced(n, search_value, current_selected_pilot,
region_filter, country_filter, active_discipline_filename,
last_search):
# --- OPTIMIZACIONES CLAVE ---
# 1. Si la búsqueda es inválida o es la misma que la anterior, no hacer nada.
if not search_value or len(search_value) < 3 or search_value == last_search:
# Devolvemos dash.no_update para las opciones y el store, y deshabilitamos el timer.
return dash.no_update, dash.no_update, True
print(f"🚀 EXECUTING OPTIMIZED SEARCH for: '{search_value}'")
# --- LÓGICA DE BÚSQUEDA (sin cambios, pero ahora se ejecuta mucho menos) ---
df_current_discipline = DISCIPLINE_DATAFRAMES[active_discipline_filename]
# Filtramos el DataFrame una sola vez
filtered_df = df_current_discipline
if region_filter and region_filter != 'ALL':
filtered_df = filtered_df[filtered_df['REGION'] == region_filter]
if country_filter and country_filter != 'ALL':
filtered_df = filtered_df[filtered_df['LOCATION'] == country_filter]
# Búsqueda de coincidencias (más eficiente en un DF ya filtrado)
# Usamos `na=False` para evitar errores con valores nulos
matches = filtered_df[filtered_df['DRIVER'].str.contains(search_value, case=False, na=False)]
# OPTIMIZACIÓN: Devolvemos menos resultados para que la respuesta sea más ligera.
top_matches = matches.nlargest(15, 'IRATING')
options = [{'label': row['DRIVER'], 'value': row['DRIVER']}
for _, row in top_matches.iterrows()]
# Asegurarse de que el piloto seleccionado no desaparezca de las opciones
if current_selected_pilot and not any(opt['value'] == current_selected_pilot for opt in options):
options.insert(0, {'label': current_selected_pilot, 'value': current_selected_pilot})
# Devolvemos las nuevas opciones, actualizamos el 'last_search' y deshabilitamos el timer.
return options, search_value, True
# CALLBACK 3: Limpia las opciones si el usuario borra el texto.
@app.callback(
Output('pilot-search-dropdown', 'options', allow_duplicate=True),
Input('pilot-search-dropdown', 'search_value'),
State('pilot-search-dropdown', 'value'),
prevent_initial_call=True
)
def clear_options_on_empty_search(search_value, current_selected_pilot):
if not search_value:
# Si no hay texto, solo muestra la opción del piloto seleccionado (si existe).
if current_selected_pilot:
return [{'label': current_selected_pilot, 'value': current_selected_pilot}]
return []
return dash.no_update
# --- CALLBACK para limpiar la búsqueda si cambian los filtros ---
@app.callback(
Output('pilot-search-dropdown', 'value'),
Input('region-filter', 'value'),
Input('country-filter', 'value'),
Input('active-discipline-store', 'data') # <-- AÑADE ESTE INPUT
)
def clear_pilot_search_on_filter_change(region, country, discipline_data): # <-- AÑADE EL ARGUMENTO
# Cuando un filtro principal o la disciplina cambia, reseteamos la selección del piloto
return None
# --- CALLBACK PARA ESTILO DE FILTROS ACTIVOS (MODIFICADO) ---
@app.callback(
Output('region-filter', 'className'),
Output('country-filter', 'className'),
Output('pilot-search-dropdown', 'className'), # <-- AÑADIMOS LA SALIDA
Input('region-filter', 'value'),
Input('country-filter', 'value'),
Input('pilot-search-dropdown', 'value') # <-- AÑADIMOS LA ENTRADA
)
def update_filter_styles(region_value, country_value, pilot_value):
# Clases base para los dropdowns
default_class = 'iracing-dropdown'
active_class = 'iracing-dropdown active-filter'
# Asignar clases según el valor de cada filtro
region_class = active_class if region_value and region_value != 'ALL' else default_class
country_class = active_class if country_value and country_value != 'ALL' else default_class
# NUEVA LÓGICA: El filtro de piloto está activo si tiene un valor
pilot_class = active_class if pilot_value else default_class
return region_class, country_class, pilot_class
# --- CALLBACK PARA ESTILO DE BOTONES ACTIVOS ---
@app.callback(
Output('btn-formula', 'style'), # <-- AÑADIDO
Output('btn-road', 'style'),
Output('btn-oval', 'style'),
Output('btn-dirt-road', 'style'),
Output('btn-dirt-oval', 'style'),
Input('btn-formula', 'n_clicks'), # <-- AÑADIDO
Input('btn-road', 'n_clicks'),
Input('btn-oval', 'n_clicks'),
Input('btn-dirt-road', 'n_clicks'),
Input('btn-dirt-oval', 'n_clicks')
)
def update_button_styles(formula_clicks, road_clicks, oval_clicks, dirt_road_clicks, dirt_oval_clicks): # <-- AÑADIDO
# Estilos base para los botones
base_style = {'width': '120px'} # Asegura que todos tengan el mismo ancho
active_style = {
'backgroundColor': 'rgba(0, 111, 255, 0.3)',
'border': '1px solid rgb(0, 111, 255)',
'width': '120px'
}
# Determinar qué botón fue presionado
ctx = dash.callback_context
if not ctx.triggered_id:
# Estado inicial: 'Road' activo por defecto
return base_style, active_style, base_style, base_style, base_style # <-- MODIFICADO
button_id = ctx.triggered_id
# Devolver el estilo activo para el botón presionado y el base para los demás
if button_id == 'btn-formula': # <-- AÑADIDO
return active_style, base_style, base_style, base_style, base_style
elif button_id == 'btn-road':
return base_style, active_style, base_style, base_style, base_style # <-- MODIFICADO
elif button_id == 'btn-oval':
return base_style, base_style, active_style, base_style, base_style # <-- MODIFICADO
elif button_id == 'btn-dirt-road':
return base_style, base_style, base_style, active_style, base_style # <-- MODIFICADO
elif button_id == 'btn-dirt-oval':
return base_style, base_style, base_style, base_style, active_style # <-- MODIFICADO
# Fallback por si acaso
return base_style, base_style, base_style, base_style, base_style # <-- MODIFICADO
# --- CALLBACK CONSOLIDADO: BÚSQUEDA Y TABLA (MODIFICADO PARA LEER DEL STORE) ---
@app.callback(
Output('datatable-interactiva', 'data'),
Output('datatable-interactiva', 'page_count'),
Output('datatable-interactiva', 'columns'),
Output('datatable-interactiva', 'page_current'),
Output('histogram-plot', 'figure'),
Output('continent-map', 'figure'),
Output('kpi-global', 'figure'),
Output('kpi-pilot', 'figure'),
Output('shared-data-store', 'data'),
# --- ELIMINAMOS LOS BOTONES COMO INPUTS ---
# Input('btn-road', 'n_clicks'),
# Input('btn-formula', 'n_clicks'),
# Input('btn-oval', 'n_clicks'),
# Input('btn-dirt-road', 'n_clicks'),
# Input('btn-dirt-oval', 'n_clicks'),
# --- LOS INPUTS AHORA EMPIEZAN CON LOS FILTROS ---
Input('region-filter', 'value'),
Input('country-filter', 'value'),
Input('pilot-search-dropdown', 'value'),
Input('datatable-interactiva', 'page_current'),
Input('datatable-interactiva', 'page_size'),
Input('datatable-interactiva', 'sort_by'),
Input('datatable-interactiva', 'active_cell'),
# --- AÑADIMOS EL STORE COMO STATE ---
State('active-discipline-store', 'data'),
# --- AÑADIMOS UN INPUT DEL STORE PARA REACCIONAR AL CAMBIO ---
Input('active-discipline-store', 'data')
)
def update_table_and_search(
region_filter, country_filter, selected_pilot,
page_current, page_size, sort_by, state_active_cell,
active_discipline_filename,
discipline_change_trigger
):
ctx = dash.callback_context
triggered_id = ctx.triggered[0]['prop_id'].split('.')[0] if ctx.triggered else 'region-filter'
# --- 1. LECTURA DEL ARCHIVO DESDE EL STORE ---
# Ya no necesitamos el file_map aquí, simplemente usamos el nombre del archivo guardado.
filename = active_discipline_filename
# --- 2. PROCESAMIENTO DE DATOS (se hace cada vez) ---
# Leemos y procesamos el archivo seleccionado
#df = pd.read_csv(filename)
df = DISCIPLINE_DATAFRAMES[active_discipline_filename]
df_for_graphs = df.copy() # Copia para gráficos que no deben ser filtrados
# Lógica de columnas dinámicas
base_cols = ['DRIVER', 'IRATING', 'LOCATION', 'REGION','CLASS', 'STARTS', 'WINS' ]
if country_filter and country_filter != 'ALL':
dynamic_cols = ['Rank Country'] + base_cols
elif region_filter and region_filter != 'ALL':
dynamic_cols = ['Rank Region'] + base_cols
else:
dynamic_cols = ['Rank World'] + base_cols
# Filtrado de datos
if not region_filter: region_filter = 'ALL'
if not country_filter: country_filter = 'ALL'
filtered_df = df[dynamic_cols].copy()
if region_filter != 'ALL':
filtered_df = filtered_df[filtered_df['REGION'] == region_filter]
if country_filter != 'ALL':
filtered_df = filtered_df[filtered_df['LOCATION'] == country_filter]
# --- 3. ORDENAMIENTO ---
if sort_by:
filtered_df = filtered_df.sort_values(
by=sort_by[0]['column_id'],
ascending=sort_by[0]['direction'] == 'asc'
)
# --- 4. LÓGICA DE BÚSQUEDA Y NAVEGACIÓN (CORREGIDA) ---
target_page = page_current
new_active_cell = state_active_cell
# Si el callback fue disparado por un cambio en los filtros,
# reseteamos la celda activa y la página.
if triggered_id in [
'region-filter', 'country-filter',
'active-discipline-store'
]:
new_active_cell = None
target_page = 0 # <-- Esto hace que siempre se muestre la primera página
# Si la búsqueda de piloto activó el callback, calculamos la nueva página y celda activa
elif triggered_id == 'pilot-search-dropdown' and selected_pilot:
match_index = filtered_df.index.get_loc(df[df['DRIVER'] == selected_pilot].index[0])
if match_index is not None:
target_page = match_index // 100 # CAMBIO: Usar 100 en lugar de page_size
driver_column_index = list(filtered_df.columns).index('DRIVER')
new_active_cell = {
'row': match_index % 100, # CAMBIO: Usar 100 en lugar de page_size
'row_id': match_index % 100, # CAMBIO: Usar 100
'column': driver_column_index,
'column_id': 'DRIVER'
}
# --- 5. GENERACIÓN DE COLUMNAS PARA LA TABLA ---
columns_definition = []
for col_name in filtered_df.columns:
if col_name == "LOCATION":
columns_definition.append({"name": "Country", "id": col_name, "presentation": "markdown", 'type': 'text'})
elif col_name == "IRATING":
columns_definition.append({"name": "iRating", "id": col_name})
elif col_name.startswith("Rank "):
columns_definition.append({"name": col_name.replace("Rank ", ""), "id": col_name})
elif col_name == "CLASS":
columns_definition.append({"name": "SR", "id": col_name})
else:
columns_definition.append({"name": col_name.title(), "id": col_name})
# --- 6. PAGINACIÓN ---
page_size = 100 # FORZAR: Siempre usar 100 elementos por página
start_idx = target_page * page_size
end_idx = start_idx + page_size
# Aplicamos el formato de bandera a los datos de la página actual
page_df = filtered_df.iloc[start_idx:end_idx].copy()
page_df['LOCATION'] = page_df['LOCATION'].map(lambda x: flag_img(x))
page_data = page_df.to_dict('records')
total_pages = len(filtered_df) // page_size + (1 if len(filtered_df) % page_size > 0 else 0)
# --- 7. ACTUALIZACIÓN DE GRÁFICOS ---
graph_indices = filtered_df.index
highlight_irating = None
highlight_name = None
pilot_info_for_kpi = None # Variable para guardar los datos del piloto
pilot_to_highlight = selected_pilot
if triggered_id == 'datatable-interactiva' and new_active_cell:
row_index_in_df = (target_page * page_size) + new_active_cell['row']
if row_index_in_df < len(filtered_df):
pilot_to_highlight = filtered_df.iloc[row_index_in_df]['DRIVER']
if pilot_to_highlight:
pilot_data = df[df['DRIVER'] == pilot_to_highlight]
if not pilot_data.empty:
pilot_info_for_kpi = pilot_data.iloc[0] # <-- Guardamos toda la info del piloto
highlight_irating = pilot_info_for_kpi['IRATING']
highlight_name = pilot_info_for_kpi['DRIVER']
elif not filtered_df.empty:
top_pilot_in_view = filtered_df.nlargest(1, 'IRATING').iloc[0]
highlight_irating = top_pilot_in_view['IRATING']
highlight_name = top_pilot_in_view['DRIVER']
# --- NUEVO: Generamos el gráfico de KPIs ---
filter_context = "World"
if country_filter and country_filter != 'ALL':
try:
# Traducimos el código de país a nombre para el KPI
filter_context = pycountry.countries.get(alpha_2=country_filter).name
except (LookupError, AttributeError):
filter_context = country_filter # Usamos el código si no se encuentra
elif region_filter and region_filter != 'ALL':
filter_context = region_filter
kpi_global_fig = create_kpi_global(filtered_df, filter_context)
kpi_pilot_fig = create_kpi_pilot(filtered_df, pilot_info_for_kpi, filter_context)
updated_histogram_figure = create_histogram_with_percentiles(
df.loc[graph_indices],
'IRATING',
100,
highlight_irating=highlight_irating,
highlight_name=highlight_name
)
updated_map_figure = create_continent_map(df_for_graphs, region_filter, country_filter)
shared_data = {
'active_cell': new_active_cell,
'selected_pilot': selected_pilot or '',
'timestamp': str(pd.Timestamp.now())
}
'''return (page_data, total_pages, columns_definition, target_page,
new_active_cell, updated_histogram_figure, updated_map_figure)'''
return (page_data, total_pages, columns_definition, target_page,
updated_histogram_figure, updated_map_figure,
kpi_global_fig,
kpi_pilot_fig,
shared_data)
# --- NUEVO CALLBACK: IMPRIMIR DATOS DEL PILOTO SELECCIONADO ---
@app.callback(
Output('pilot-info-display', 'children'), # Necesitarás añadir este componente al layout
Input('datatable-interactiva', 'active_cell'),
State('datatable-interactiva', 'data'),
State('region-filter', 'value'),
State('country-filter', 'value'),
prevent_initial_call=True
)
def print_selected_pilot_data(active_cell, table_data, region_filter, country_filter):
if not active_cell or not table_data:
return "No driver selected"
# Obtener el nombre del piloto de la fila seleccionada
selected_row = active_cell['row']
if selected_row >= len(table_data):
return "Invalid row"
pilot_name = table_data[selected_row]['DRIVER']
# Buscar todos los datos del piloto en el DataFrame original
pilot_data = df[df['DRIVER'] == pilot_name]
if pilot_data.empty:
return f"No data found for {pilot_name}"
# Obtener la primera (y única) fila del piloto
pilot_info = pilot_data.iloc[0]
# IMPRIMIR EN CONSOLA todos los datos del piloto
print("\n" + "="*50)
print(f"SELECTED DRIVER DATA: {pilot_name}")
print("="*50)
for column, value in pilot_info.items():
print(f"{column}: {value}")
print("="*50 + "\n")
# También retornar información para mostrar en la interfaz (opcional)
return f"Selected driver: {pilot_name} (See console for full data)"
@app.callback(
Output('datatable-interactiva', 'active_cell'),
Output('shared-data-store_1', 'data'),
Input('datatable-interactiva', 'active_cell'),
State('shared-data-store', 'data'),
State('shared-data-store_1', 'data'),
Input('region-filter', 'value'),
Input('country-filter', 'value'),
prevent_initial_call=True
)
def update_active_cell_from_store(active_cell,ds,ds1,a,b):
print(ds1)
print(ds)
print(active_cell)
if not ds:
return None
if not ds1:
ds1 = ds
if ds.get('selected_pilot', '') == '':
return active_cell,ds1
return ds.get('active_cell'),ds1
if ds.get('selected_pilot', '') == ds1.get('selected_pilot', ''):
ds1 = ds
if active_cell == ds1.get('active_cell'):
return None,ds1
ds1['active_cell'] = active_cell
return active_cell,ds1
else:
ds1 = ds
return ds.get('active_cell'),ds1
if __name__ == "__main__":
app.run(debug=False)