import dash
from dash import dcc, html, Input, Output, State
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
import dash_bootstrap_components as dbc
# Inicializa la aplicación Dash con el tema Flatly de Dash Bootstrap
app = dash.Dash(__name__, external_stylesheets=[
dbc.themes.FLATLY,
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'
])
app.title = "US Borders-Crossing"
# Define la paleta de colores para los clústeres
CLUSTER_COLORS = ['#2E86AB', '#A23B72', '#F18F01', '#C73E1D', '#592E83', '#0C7C59', '#9A031E', '#FB8500']
# Se crea un mapa de colores explícito para garantizar la consistencia en todos los gráficos
CLUSTER_COLOR_MAP = {str(i): color for i, color in enumerate(CLUSTER_COLORS)}
# ========== CACHE GLOBAL PARA OPTIMIZACIÓN ==========
_cached_data = None
_cached_border_options = None
def get_cached_data():
"""Obtiene los datos del cache global, cargándolos solo una vez."""
global _cached_data, _cached_border_options
if _cached_data is None:
print("🔄 Cargando datos por primera vez...")
_cached_data = load_real_data()
# Calcular opciones de borde una sola vez
if not _cached_data.empty and 'Border' in _cached_data.columns:
unique_borders = _cached_data['Border'].unique().tolist()
_cached_border_options = [{'label': 'All Borders', 'value': 'all'}]
_cached_border_options.extend([{'label': border, 'value': border} for border in unique_borders])
else:
_cached_border_options = [{'label': 'All Borders', 'value': 'all'}]
print(f"✅ Datos cargados en cache: {len(_cached_data)} registros")
else:
print("⚡ Usando datos del cache (sin recarga)")
return _cached_data.copy(), _cached_border_options.copy()
# --- Supporting Functions ---
def load_real_data():
"""Carga datos reales de cruces fronterizos."""
try:
# Intentar cargar archivo parquet primero
try:
df = pd.read_parquet("Border_Crossing_Entry_Data.parquet")
print("✅ Datos cargados desde archivo parquet")
except FileNotFoundError:
# Fallback a CSV si parquet no existe
df = pd.read_csv("Border_Crossing_Entry_Data.csv")
print("✅ Datos cargados desde archivo CSV")
# Validar columnas requeridas
required_columns = ['Port Name', 'Date', 'Measure', 'Value', 'Latitude', 'Longitude']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
raise ValueError(f"Columnas faltantes en el dataset: {missing_columns}. "
f"Columnas disponibles: {df.columns.tolist()}")
# Limpiar y procesar datos
df['Value'] = pd.to_numeric(df['Value'], errors='coerce').fillna(0)
df = df.dropna(subset=['Port Name', 'Date', 'Measure', 'Latitude', 'Longitude'])
# Crear columna Border si no existe (basado en coordenadas aproximadas)
if 'Border' not in df.columns:
# Heurística simple: latitudes menores generalmente corresponden a frontera México
df['Border'] = df['Latitude'].apply(lambda lat: 'US-Mexico' if lat < 35 else 'US-Canada')
print("ℹ️ Columna 'Border' creada basada en coordenadas geográficas")
print(f"✅ Dataset procesado exitosamente: {len(df)} registros")
print(f"ℹ️ Puertos únicos: {df['Port Name'].nunique()}")
print(f"ℹ️ Medidas disponibles: {df['Measure'].unique().tolist()}")
print(f"ℹ️ Rango de fechas: {df['Date'].min()} a {df['Date'].max()}")
return df
except FileNotFoundError:
raise FileNotFoundError(
"❌ No se encontró el archivo de datos. "
"Asegúrate de tener 'Border_Crossing_Entry_Data.parquet' o 'Border_Crossing_Entry_Data.csv' "
"en el directorio de trabajo."
)
except Exception as e:
raise Exception(f"❌ Error al cargar los datos: {str(e)}")
def perform_ml_analysis(df, n_clusters=4, border_filter='all', analysis_type='volume'):
"""Realiza el análisis de PCA y clustering con manejo de errores mejorado."""
try:
if border_filter != 'all' and 'Border' in df.columns:
df = df[df['Border'] == border_filter].copy()
if df.empty:
raise ValueError("No hay datos después del filtrado")
if analysis_type == 'volume':
agg_df = df.groupby(['Port Name', 'Measure', 'Latitude', 'Longitude'])['Value'].sum().reset_index()
elif analysis_type == 'growth':
df['Year'] = pd.to_datetime(df['Date']).dt.year
yearly = df.groupby(['Port Name', 'Measure', 'Year', 'Latitude', 'Longitude'])['Value'].sum().reset_index()
yearly['Growth_Rate'] = yearly.groupby(['Port Name', 'Measure'])['Value'].pct_change()
yearly['Growth_Rate'] = yearly['Growth_Rate'].replace([np.inf, -np.inf], 0).fillna(0)
agg_df = yearly.groupby(['Port Name', 'Measure', 'Latitude', 'Longitude'])['Growth_Rate'].mean().reset_index()
agg_df['Value'] = agg_df['Growth_Rate']
else: # 'seasonal'
df['Month'] = pd.to_datetime(df['Date']).dt.month
monthly_avg = df.groupby(['Port Name', 'Measure', 'Month', 'Latitude', 'Longitude'])['Value'].mean().reset_index()
seasonal_stats = monthly_avg.groupby(['Port Name', 'Measure', 'Latitude', 'Longitude'])['Value'].agg(['mean', 'std']).reset_index()
seasonal_stats['mean'] = seasonal_stats['mean'].replace(0, 1)
seasonal_stats['std'] = seasonal_stats['std'].fillna(0)
seasonal_stats['Seasonal_Coef'] = seasonal_stats['std'] / seasonal_stats['mean']
agg_df = seasonal_stats[['Port Name', 'Measure', 'Seasonal_Coef', 'Latitude', 'Longitude']].copy()
agg_df['Value'] = agg_df['Seasonal_Coef']
pivot_df = agg_df.pivot_table(
index=['Port Name', 'Latitude', 'Longitude'],
columns='Measure',
values='Value',
fill_value=0
).reset_index()
if len(pivot_df) < n_clusters:
n_clusters = max(2, len(pivot_df) - 1)
feature_cols = [col for col in pivot_df.columns if col not in ['Port Name', 'Border', 'Latitude', 'Longitude']]
if len(feature_cols) == 0:
raise ValueError("No se encontraron columnas de características después del pivot")
X = pivot_df[feature_cols].fillna(0).replace([np.inf, -np.inf], 0)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
clusters = kmeans.fit_predict(X_scaled)
silhouette = silhouette_score(X_scaled, clusters) if len(set(clusters)) > 1 else 0
results_df = pivot_df.copy()
results_df['Cluster'] = clusters
n_components = min(X_scaled.shape[1], 4)
if n_components > 1:
pca = PCA(n_components=n_components)
X_pca = pca.fit_transform(X_scaled)
results_df['PC1'] = X_pca[:, 0]
results_df['PC2'] = X_pca[:, 1]
explained_variance = pca.explained_variance_ratio_
else:
results_df['PC1'] = X_scaled[:, 0] if X_scaled.shape[1] > 0 else np.zeros(len(X_scaled))
results_df['PC2'] = np.zeros(len(X_scaled))
explained_variance = np.array([1.0])
cluster_centroids = pd.DataFrame(scaler.inverse_transform(kmeans.cluster_centers_), columns=feature_cols)
cluster_centroids['Cluster'] = range(n_clusters)
return {
'results_df': results_df,
'feature_cols': feature_cols,
'silhouette_score': float(silhouette),
'cluster_centroids': cluster_centroids,
'explained_variance': explained_variance
}
except Exception as e:
print(f"❌ Error en el análisis ML: {str(e)}")
return {
'results_df': pd.DataFrame(),
'feature_cols': [],
'silhouette_score': 0.0,
'cluster_centroids': pd.DataFrame(),
'explained_variance': np.array([])
}
# --- Diseño de la aplicación ---
app.layout = dbc.Container(fluid=True,
style={
'backgroundColor': '#e9ecef',
'minHeight': '100vh',
'backgroundImage': 'radial-gradient(#d1d7de 1px, transparent 1px)',
'backgroundSize': '10px 10px'
},
children=[
# Componente para carga inicial única
dcc.Location(id='url', refresh=False),
# Encabezado y título con un diseño más limpio y un icono
dbc.Row(className="p-4 mb-4",
style={
'backgroundColor': '#002868',
'backgroundImage': 'linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px)',
'backgroundSize': '20px 20px',
'position': 'relative'
},
children=[
dbc.Col(className="text-center", children=[
html.H1([
html.Span("US ", className="me-2", style={'fontSize': '1.2em'}),
"Border Crossing Analysis Dashboard"
], className="mb-3", style={'color': 'white', 'textShadow': '2px 2px 4px rgba(0, 0, 0, 0.7)'}),
html.P("Machine Learning Analytics for Cross-Border Trade Patterns", className="mb-0", style={'color': 'white', 'textShadow': '2px 2px 4px rgba(0, 0, 0, 0.7)'})
])
]),
# Contenedor principal para el contenido con layout de tarjetas
dbc.Container(fluid=True, className="my-4", children=[
# Controles y filtros
dbc.Card(className="p-3 mb-4 shadow-sm", children=[
dbc.Row(children=[
dbc.Col(lg=4, md=6, children=[
html.Label("Number of Clusters", className="fw-bold"),
dcc.Slider(
id='cluster-slider',
min=2, max=8, step=1, value=4,
marks={i: str(i) for i in range(2, 9)},
tooltip={"placement": "bottom", "always_visible": True}
)
]),
dbc.Col(lg=4, md=6, children=[
html.Label("Border Filter", className="fw-bold"),
dcc.Dropdown(
id='border-filter',
options=[{'label': 'All Borders', 'value': 'all'}],
value='all'
)
]),
dbc.Col(lg=4, md=12, children=[
html.Div(className="d-flex align-items-center mb-2", children=[
html.Label("Analysis Type", className="fw-bold me-2", style={'margin-bottom': '0'}),
dbc.Button(
html.I(className="fas fa-question-circle text-info"),
id="open-analysis-modal",
n_clicks=0,
className="p-0 border-0 bg-transparent"
)
]),
dcc.Dropdown(
id='analysis-type',
options=[
{'label': 'Volume Analysis', 'value': 'volume'},
{'label': 'Growth Analysis', 'value': 'growth'},
{'label': 'Seasonal Analysis', 'value': 'seasonal'}
],
value='volume'
)
])
])
]),
# Tarjetas de métricas
dbc.Row(className="g-4 mb-4", children=[
dbc.Col(lg=3, md=6, children=[
dbc.Card(className="p-3 shadow-sm text-center", children=[
html.P("Total Ports", className="text-secondary mb-1"),
html.H4(id='total-ports', children="-", className="text-primary fw-bold")
])
]),
dbc.Col(lg=3, md=6, children=[
dbc.Card(className="p-3 shadow-sm text-center", children=[
html.P("Total Volume (M)", className="text-secondary mb-1"),
html.H4(id='total-volume', children="-", className="text-success fw-bold")
])
]),
dbc.Col(lg=3, md=6, children=[
dbc.Card(className="p-3 shadow-sm text-center", children=[
html.P("Silhouette Score", className="text-secondary mb-1"),
html.H4(id='silhouette-score', children="-", className="text-warning fw-bold")
])
]),
dbc.Col(lg=3, md=6, children=[
dbc.Card(className="p-3 shadow-sm text-center", children=[
html.P("PCA Variance (%)", className="text-secondary mb-1"),
html.H4(id='explained-variance', children="-", className="text-info fw-bold")
])
])
]),
# Gráficos de análisis
dbc.Row(className="g-4 mb-4", children=[
dbc.Col(lg=6, md=12, children=[
dbc.Card(className="shadow-sm", children=[
dbc.CardHeader("PCA Analysis - Port Clustering"),
dbc.CardBody(
dcc.Loading(type="circle", children=[
dcc.Graph(id='pca-plot', style={'height': '400px'})
])
)
])
]),
dbc.Col(lg=6, md=12, children=[
dbc.Card(className="shadow-sm", children=[
dbc.CardHeader("Cluster Characteristics"),
dbc.CardBody(
dcc.Loading(type="circle", children=[
dcc.Graph(id='cluster-centroids-plot', style={'height': '400px'})
])
)
])
])
]),
# Mapa y series de tiempo
dbc.Row(className="g-4", children=[
dbc.Col(lg=6, md=12, children=[
dbc.Card(className="shadow-sm", children=[
dbc.CardHeader("Cluster Map by Location"),
dbc.CardBody(
dcc.Loading(type="circle", children=[
dcc.Graph(id='cluster-map', style={'height': '400px'})
])
)
])
]),
dbc.Col(lg=6, md=12, children=[
dbc.Card(className="shadow-sm", children=[
dbc.CardHeader(children=[
html.Div(className="d-flex justify-content-between align-items-center", children=[
html.Span("Time Series for Selected Port"),
dcc.Dropdown(
id='timeseries-measure-filter',
options=[],
value=None,
placeholder="Select Measure",
style={'width': 'fit-content', 'minWidth': '250px'},
className="flex-shrink-0"
)
])
]),
dbc.CardBody(
dcc.Loading(type="circle", children=[
dcc.Graph(id='timeseries-plot', style={'height': '400px'})
])
)
])
])
])
]),
# Analysis Explanation Modal
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Understanding the Analysis Types")),
dbc.ModalBody([
html.Div([
html.H5("Volume Analysis"),
html.P("This analysis groups ports based on their total traffic volumes for each measure (e.g., cars, trucks). It is useful for identifying high, medium, and low-traffic ports, and for seeing if a port is more important for one type of crossing than another."),
html.Hr(),
html.H5("Growth Analysis"),
html.P("This analysis groups ports based on their average annual growth rate. It is ideal for identifying ports with accelerated or declining growth, helping to detect emerging trends or problems in border traffic."),
html.Hr(),
html.H5("Seasonal Analysis"),
html.P("This analysis groups ports based on the seasonal variability of their traffic. It uses a seasonality coefficient (standard deviation / mean) to identify ports with very consistent traffic patterns throughout the year (low coefficient) versus ports with pronounced seasonal peaks and valleys (high coefficient).")
])
]),
dbc.ModalFooter(
dbc.Button("Close", id="close-analysis-modal", className="ms-auto", n_clicks=0)
),
],
id="analysis-info-modal",
is_open=False,
),
# Almacenes de datos invisibles
dcc.Store(id='stored-data'),
dcc.Store(id='analysis-results'),
html.Footer(
dbc.Container(
dbc.Row(
dbc.Col(
html.P("Developed using Plotly|Dash 2025 US Borders Analysis. Thanks to Data.gov", className="text-center mb-0"),
className="p-3"
)
)
),
className="bg-secondary text-light mt-4",
)
])
# Callback para abrir/cerrar el modal
@app.callback(
Output("analysis-info-modal", "is_open"),
[Input("open-analysis-modal", "n_clicks"), Input("close-analysis-modal", "n_clicks")],
[State("analysis-info-modal", "is_open")],
)
def toggle_modal(n1, n2, is_open):
if n1 or n2:
return not is_open
return is_open
# ========== CALLBACK OPTIMIZADO - CARGA INICIAL ÚNICA ==========
@app.callback(
[Output('stored-data', 'data'),
Output('border-filter', 'options')],
[Input('url', 'pathname')] # Solo se ejecuta al cargar la página
)
def load_data_on_startup(pathname):
"""Carga los datos una sola vez al inicio de la aplicación."""
try:
df, border_options = get_cached_data()
if df.empty:
print("⚠️ DataFrame vacío después de la carga")
return [], [{'label': 'All Borders', 'value': 'all'}]
return df.to_dict('records'), border_options
except Exception as e:
print(f"❌ Error en la carga inicial: {str(e)}")
# Retornar estructura vacía para evitar crash
return [], [{'label': 'Error loading data', 'value': 'error', 'disabled': True}]
# Callback principal para el análisis y las tarjetas de métricas
@app.callback(
[Output('analysis-results', 'data'),
Output('total-ports', 'children'),
Output('total-volume', 'children'),
Output('silhouette-score', 'children'),
Output('explained-variance', 'children')],
[Input('stored-data', 'data'),
Input('cluster-slider', 'value'),
Input('border-filter', 'value'),
Input('analysis-type', 'value')]
)
def update_analysis(data, n_clusters, border_filter, analysis_type):
if not data:
return {}, "Error", "Error", "Error", "Error"
df = pd.DataFrame(data)
# Filtrar los datos en base a la selección del borde
if border_filter != 'all':
df_filtered = df[df['Border'] == border_filter].copy()
else:
df_filtered = df.copy()
unique_ports = df_filtered['Port Name'].nunique()
total_volume = df_filtered['Value'].sum() / 1_000_000
try:
results = perform_ml_analysis(df, n_clusters, border_filter, analysis_type)
if results['results_df'].empty:
return {}, unique_ports, f"{total_volume:.1f}", "-", "-"
silhouette = f"{results['silhouette_score']:.3f}"
explained_var = f"{sum(results['explained_variance'][:2]):.1%}" if len(results['explained_variance']) > 1 else "-"
serializable_results = {
'results_df': results['results_df'].to_dict('records'),
'feature_cols': results['feature_cols'],
'silhouette_score': results['silhouette_score'],
'cluster_centroids': results['cluster_centroids'].to_dict('records'),
'explained_variance': results['explained_variance'].tolist()
}
return (serializable_results, unique_ports, f"{total_volume:,.1f} M", silhouette, explained_var)
except Exception as e:
print(f"❌ Error en el callback de análisis: {str(e)}")
return {}, unique_ports, f"{total_volume:,.1f} M", "Error", "Error"
# Callback para actualizar el gráfico de PCA
@app.callback(
Output('pca-plot', 'figure'),
[Input('analysis-results', 'data')]
)
def update_pca_plot(results):
if not results or not results.get('results_df'):
return go.Figure().add_annotation(
text="No analysis results available",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
df = pd.DataFrame(results['results_df'])
df = df.sort_values('Cluster')
fig = px.scatter(df, x='PC1', y='PC2',
color=df['Cluster'].astype(str),
hover_data={'Port Name': True, 'PC1': False, 'PC2': False, 'Cluster': True},
color_discrete_map=CLUSTER_COLOR_MAP,
labels={'color': 'Cluster'},
title="PCA Analysis - Port Clustering")
fig.update_traces(marker=dict(size=10))
fig.update_layout(height=400, template='plotly_white')
return fig
# Callback para el gráfico de barras y su título dinámico
@app.callback(
Output('cluster-centroids-plot', 'figure'),
[Input('analysis-results', 'data'),
Input('analysis-type', 'value')]
)
def update_cluster_centroids_plot(results, analysis_type):
if not results or not results.get('cluster_centroids'):
return go.Figure().add_annotation(
text="No analysis results available",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
centroids_df = pd.DataFrame(results['cluster_centroids'])
centroids_df = centroids_df.sort_values('Cluster')
titles = {
'volume': 'Cluster Characteristics (Average Volume)',
'growth': 'Cluster Characteristics (Average Growth)',
'seasonal': 'Cluster Characteristics (Seasonality Coefficient)'
}
y_titles = {
'volume': 'Average Volume',
'growth': 'Average Growth',
'seasonal': 'Seasonality Coefficient'
}
fig = go.Figure()
for i, row in centroids_df.iterrows():
cluster_number = int(row["Cluster"])
cluster_color = CLUSTER_COLOR_MAP.get(str(cluster_number), CLUSTER_COLORS[0])
fig.add_trace(go.Bar(
x=results['feature_cols'],
y=row[results['feature_cols']],
name=f'Cluster {cluster_number}',
marker_color=cluster_color
))
fig.update_layout(
barmode='group',
title=titles.get(analysis_type, 'Cluster Characteristics'),
xaxis_title="Measures",
yaxis_title=y_titles.get(analysis_type, 'Average Value'),
height=400,
template='plotly_white',
legend_title_text='Cluster'
)
return fig
# Callback para actualizar el mapa
@app.callback(
Output('cluster-map', 'figure'),
[Input('analysis-results', 'data')]
)
def update_cluster_map(results):
if not results or not results.get('results_df') or pd.DataFrame(results['results_df']).empty:
return go.Figure().add_annotation(
text="No analysis results available",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
df = pd.DataFrame(results['results_df'])
df = df.sort_values('Cluster')
fig = px.scatter_map(df,
lat="Latitude",
lon="Longitude",
color=df['Cluster'].astype(str),
hover_name="Port Name",
hover_data={"Latitude": False, "Longitude": False, "Cluster": True},
color_discrete_map=CLUSTER_COLOR_MAP,
labels={'color': 'Cluster'},
map_style='carto-voyager',
title="Cluster Map by Location",
zoom=2)
fig.update_traces(marker=dict(size=10))
fig.update_layout(
height=400,
margin={"r":0,"t":40,"l":0,"b":0}
)
return fig
# --- CALLBACK UNIFICADO Y CORREGIDO para el gráfico de series de tiempo y el menú desplegable ---
@app.callback(
[Output('timeseries-plot', 'figure'),
Output('timeseries-measure-filter', 'options'),
Output('timeseries-measure-filter', 'value')],
[Input('pca-plot', 'clickData'),
Input('cluster-map', 'clickData')],
[State('stored-data', 'data'),
State('border-filter', 'value'),
State('timeseries-measure-filter', 'value')]
)
def handle_click_and_update_timeseries(pca_clickData, map_clickData, stored_data, border_filter, current_measure):
ctx = dash.callback_context
if not ctx.triggered or not stored_data:
figure = go.Figure().add_annotation(
text="Click on a port on the map or PCA plot to view its time series.",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
return (figure, [], None)
triggered_id = ctx.triggered[0]['prop_id'].split('.')[0]
clickData = pca_clickData if triggered_id == 'pca-plot' else map_clickData
selected_port = None
if clickData and 'points' in clickData and len(clickData['points']) > 0:
point_data = clickData['points'][0]
selected_port = point_data.get('hover_name')
if not selected_port and 'customdata' in point_data and len(point_data['customdata']) > 0:
selected_port = point_data['customdata'][0]
if not selected_port and 'text' in point_data:
selected_port = point_data['text']
if selected_port:
df = pd.DataFrame(stored_data)
df['Date'] = pd.to_datetime(df['Date'])
if border_filter != 'all':
df = df[df['Border'] == border_filter].copy()
available_measures = df[df['Port Name'] == selected_port]['Measure'].unique().tolist()
options = [{'label': m, 'value': m} for m in available_measures]
selected_measure = current_measure
if not selected_measure or selected_measure not in available_measures:
selected_measure = available_measures[0] if available_measures else None
if not selected_measure:
figure = go.Figure().add_annotation(
text=f"No measures available for {selected_port}.",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
return (figure, options, selected_measure)
port_df = df[(df['Port Name'] == selected_port) & (df['Measure'] == selected_measure)]
if port_df.empty:
figure = go.Figure().add_annotation(
text=f"No data available for {selected_port} and {selected_measure}",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
return (figure, options, selected_measure)
port_ts = port_df.groupby('Date')['Value'].sum().reset_index()
fig = px.line(port_ts, x='Date', y='Value', title=f"Time Series for {selected_port} ({selected_measure})")
fig.update_layout(
xaxis_title="Date",
yaxis_title=f"Value of {selected_measure}",
height=400,
template='plotly_white'
)
return (fig, options, selected_measure)
figure = go.Figure().add_annotation(
text="Click on a port on the map or PCA plot to view its time series.",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
return (figure, [], None)
# --- CALLBACK ADICIONAL para manejar los cambios del dropdown ---
@app.callback(
Output('timeseries-plot', 'figure', allow_duplicate=True),
[Input('timeseries-measure-filter', 'value')],
[State('stored-data', 'data'),
State('pca-plot', 'clickData'),
State('cluster-map', 'clickData'),
State('border-filter', 'value')],
prevent_initial_call=True
)
def update_timeseries_from_dropdown(selected_measure, stored_data, pca_clickData, map_clickData, border_filter):
if not stored_data or not selected_measure:
raise dash.exceptions.PreventUpdate
selected_port = None
if pca_clickData and 'points' in pca_clickData and len(pca_clickData['points']) > 0:
point_data = pca_clickData['points'][0]
selected_port = point_data.get('hover_name')
if not selected_port and 'customdata' in point_data and len(point_data['customdata']) > 0:
selected_port = point_data['customdata'][0]
if not selected_port and map_clickData and 'points' in map_clickData and len(map_clickData['points']) > 0:
point_data = map_clickData['points'][0]
selected_port = point_data.get('hover_name')
if not selected_port:
raise dash.exceptions.PreventUpdate
df = pd.DataFrame(stored_data)
df['Date'] = pd.to_datetime(df['Date'])
if border_filter != 'all':
df = df[df['Border'] == border_filter].copy()
port_df = df[(df['Port Name'] == selected_port) & (df['Measure'] == selected_measure)]
if port_df.empty:
return go.Figure().add_annotation(
text=f"No data available for {selected_port} and {selected_measure}",
xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
port_ts = port_df.groupby('Date')['Value'].sum().reset_index()
fig = px.line(port_ts, x='Date', y='Value', title=f"Time Series for {selected_port} ({selected_measure})")
fig.update_layout(
xaxis_title="Date",
yaxis_title=f"Value of {selected_measure}",
height=400,
template='plotly_white'
)
return fig
server = app.server