import dash
from dash import dcc, html, Input, Output, State, callback_context
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
import dash_bootstrap_components as dbc
# Load data
df = pd.read_csv("represas_con_anomalias.csv") # Replace with the correct path
# Filter only dams with anomalies
df_anomalias = df[df['Is_Anomaly'] == 1]
# Get unique states for dropdown
estados_unicos = sorted(df_anomalias['State'].unique())
# Define metrics to compare in radar chart
metricas = [
'Dam Height (Ft)',
'NID Height (Ft)',
'Dam Length (Ft)',
'NID Storage (Acre-Ft)',
'Normal Storage (Acre-Ft)',
'Surface Area (Acres)',
'Drainage Area (Sq Miles)',
]
hazard_colors = {
'High': '#440154', # Morado oscuro
'Significant': '#FDE725', # Amarillo brillante (para destacar un riesgo considerable)
'Low': '#21918C', # Verde azulado
'Undetermined': '#90D7EC' # Azul claro (para indicar incertidumbre)
}
# Initialize Dash application with Bootstrap
app = dash.Dash(
__name__,
suppress_callback_exceptions=True,
external_stylesheets=[dbc.themes.SANDSTONE] # Modern Bootstrap theme
)
app.title = "US Dams Anomalies Dashboard"
# Dashboard layout using Bootstrap
app.layout = dbc.Container([
dbc.Row([
dbc.Col([
html.H1("🏞️ Distinct US Dams: A Comparative View",
className="text-center my-4 text-primary")
]),
]),
# Card with general dataset information
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H5(f"Total of Distintc US Dams: {len(df_anomalias)}", className="card-title"),
html.P(f"Shown here are US dams flagged for singularities using the Isolation Forest machine learning model, designed to isolate atypical data points for analysis, not safety assessment.",
className="card-text"),
])
], className="mb-4 shadow border border-primary")
])
]),
# State filter dropdown and info message as floating elements
dbc.Row([
# State filter dropdown as a stylish button
dbc.Col([
dbc.DropdownMenu(label="Filter by State: All States",
id="state-dropdown-button",
className="shadow-lg",
children=[
dbc.DropdownMenuItem("All States", id="all-states", active=True),
dbc.DropdownMenuItem(divider=True),
*[dbc.DropdownMenuItem(state, id=f"state-{state}") for state in estados_unicos],
],color="primary",
toggle_style={"font-weight": "bold", "border-radius": "10px",
"padding": "12px 20px",
"box-shadow": "0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08)"},
toggle_class_name="d-flex align-items-center"),
# Hidden dropdown that stores the actual selected value
dcc.Dropdown(id='state-dropdown',
options=[{'label': 'All States', 'value': 'all'},
*[{'label': state, 'value': state} for state in estados_unicos]],
value='all',style={'display': 'none'}),], width=4, className="mb-3 d-flex align-items-center"),
# Information message with improved styling
dbc.Col([
html.Div(
html.P(
[html.I(className="fas fa-info-circle me-2"),
"Click on any dam on the map to view detailed comparative analysis."],
className="text-muted fst-italic mb-0 px-3 py-2 rounded-pill bg-light shadow-sm"),
className="d-flex justify-content-center align-items-center h-100"
)
], width=8, className="mb-3"),
], className="mb-4"),
# Two charts in the same row
dbc.Row([
# Left column for the map
dbc.Col([
dbc.Card([
dbc.CardHeader(html.H4("🌐 Location of Singular Dams", className="text-center")),
dbc.CardBody([
dcc.Graph(id='mapa-represas', style={'height': '70vh'}
),
])
], className="shadow border border-primary"),
html.Hr(),
# Agregar un botón de descarga para los datos filtrados
html.Button("Download Filter data", id="btn-download"),
dcc.Download(id="download-dataframe-csv"),
], width=7),
# Right column for information and radar chart
dbc.Col([
dbc.Card([
dbc.CardHeader(html.H4("↔️ Comparing Distinct Features", className="text-center")),
dbc.CardBody([
html.Div(id='info-represa-seleccionada', className="mb-3"),
dcc.Graph(id='radar-chart', style={'height': '50vh'}
),
])
], className="shadow border border-primary")
], width=5),
], className="mb-4"),
# Footer with additional information
dbc.Row([
dbc.Col([
html.Div([
html.P([html.I(className="fas fa-info-circle me-2"),
"US National Inventory of Dams Dashboard © 2025 source:Data is Plutal"],
className="text-muted fst-italic mb-0 px-3 py-2 rounded-pill bg-light shadow-sm")
],className="d-flex justify-content-center align-items-center h-100")
], className="mb-3")
]),
# Stores to save state and dam selection
dcc.Store(id='represa-seleccionada'),
dcc.Store(id='estado-seleccionado', data='all'),
], fluid=True, className="px-4 py-3 bg-light")
# Callback to update the state-dropdown based on the button clicks
@app.callback(
[Output('state-dropdown', 'value'),
Output('estado-seleccionado', 'data'),
Output('state-dropdown-button', 'label')],
[Input('all-states', 'n_clicks')] +
[Input(f'state-{state}', 'n_clicks') for state in estados_unicos],
[State('estado-seleccionado', 'data')]
)
def actualizar_estado(*args):
# Determine which item was clicked
ctx = dash.callback_context
if not ctx.triggered:
# No clicks yet, return default
return 'all', 'all', "Filter by State: All States"
# Get the ID of the component that triggered the callback
button_id = ctx.triggered[0]['prop_id'].split('.')[0]
if button_id == 'all-states':
return 'all', 'all', "Filter by State: All States"
# Remove the 'state-' prefix to get the state name
for state in estados_unicos:
if button_id == f'state-{state}':
return state, state, f"Filter by State: {state}"
# If we get here, no specific button was matched
return dash.no_update, dash.no_update, dash.no_update
# Callback to update the map based on selected state
@app.callback(
Output('mapa-represas', 'figure'),
[Input('estado-seleccionado', 'data'),
Input('represa-seleccionada', 'data')]
)
def actualizar_mapa(estado_seleccionado, represa_seleccionada):
# Filter by state if a specific state is selected
if estado_seleccionado != 'all':
datos_mapa = df_anomalias[df_anomalias['State'] == estado_seleccionado]
else:
datos_mapa = df_anomalias
# Create scatter map
fig = px.scatter_map(
datos_mapa,
lat='Latitude',
lon='Longitude',
hover_name='Dam Name',
custom_data=['NID ID','County','Owner Names', 'Primary Owner Type', 'Year Completed'],
color='Hazard Potential Classification',
color_discrete_map=hazard_colors,
size='Anomaly_Score',
size_max=15,
zoom=3,
map_style='light',
opacity=0.8,
)
fig.update_traces(hovertemplate="<b>%{hovertext}</b><br>" +
"NID ID: %{customdata[0]}<br>" +
"County: %{customdata[1]}<br>" +
"Owner: %{customdata[2]}<br>" +
"Owner Type: %{customdata[3]}<br>" +
"Completed: %{customdata[4]}<extra></extra>")
# Highlight selected dam if there is one
if represa_seleccionada:
represa = df_anomalias[df_anomalias['NID ID'] == represa_seleccionada]
if not represa.empty and (estado_seleccionado == 'all' or represa['State'].values[0] == estado_seleccionado):
fig.add_trace(go.Scattermap(
lat=[represa['Latitude'].values[0]],
lon=[represa['Longitude'].values[0]],
mode='markers',
marker=dict(size=35, color='red', opacity=1),
hoverinfo='none',
showlegend=False
))
# Adjust the center of the map based on the data
if len(datos_mapa) > 0:
lat_centro = datos_mapa['Latitude'].mean()
lon_centro = datos_mapa['Longitude'].mean()
zoom_level = 3 if estado_seleccionado == 'all' else 5
else:
lat_centro = 39 # Default center of US
lon_centro = -98
zoom_level = 3
fig.update_layout(
margin=dict(l=0, r=0, t=0, b=0),
mapbox=dict(center=dict(lat=lat_centro, lon=lon_centro), zoom=zoom_level),
clickmode='event+select',
legend=dict(orientation="h",
yanchor="bottom",y=-0.20,xanchor="center",x=0.5)
)
return fig
# Callback to update selected dam when clicked on map
@app.callback(
Output('represa-seleccionada', 'data'),
[Input('mapa-represas', 'clickData')],
[State('represa-seleccionada', 'data')]
)
def actualizar_represa_seleccionada(click_data, represa_actual):
if click_data is None:
return represa_actual
# Get the NID ID of the clicked dam
nid_id = click_data['points'][0]['customdata'][0]
return nid_id
# Callback to display selected dam information
@app.callback(
Output('info-represa-seleccionada', 'children'),
[Input('represa-seleccionada', 'data')]
)
def mostrar_info_represa(represa_seleccionada):
if not represa_seleccionada:
return dbc.Alert(
"Select a dam on the map to view its comparative analysis",
color="info",
className="text-center"
)
represa = df[df['NID ID'] == represa_seleccionada]
if represa.empty:
return dbc.Alert(
"No information found for the selected dam",
color="warning",
className="text-center"
)
# Create a table with the dam's basic information using Bootstrap
info = [
html.H4(f"{represa['Dam Name'].values[0]}", className="text-center text-primary mb-3"),
dbc.Table([
html.Tbody([
html.Tr([
html.Td("Last Inspection Date:", className="font-weight-bold"),
html.Td(represa['Last Inspection Date'].values[0])
]),
html.Tr([
html.Td("Inspection Frequency:", className="font-weight-bold"),
html.Td(represa['Inspection Frequency'].values[0])
]),
html.Tr([
html.Td("Primary Purpose:", className="font-weight-bold"),
html.Td(represa['Primary Purpose'].values[0])
]),
html.Tr([
html.Td("Hazard Classification:", className="font-weight-bold"),
html.Td(represa['Hazard Potential Classification'].values[0])
]),
html.Tr([
html.Td("Condition Assessment:", className="font-weight-bold"),
html.Td(represa['Condition Assessment'].values[0] if not pd.isna(represa['Condition Assessment'].values[0]) else "Not available")
]),
html.Tr([
html.Td("Anomaly Score:", className="font-weight-bold"),
html.Td(
html.Span(
f"{represa['Anomaly_Score'].values[0]:.4f}",
className="badge bg-danger text-white p-2" if represa['Anomaly_Score'].values[0] > 0.7
else "badge bg-warning text-dark p-2" if represa['Anomaly_Score'].values[0] > 0.4
else "badge bg-success text-white p-2"
)
)
]),
])
], bordered=True, hover=True, size="sm", className="mb-0")
]
return html.Div(info)
#Callback to update radar chart
@app.callback(
Output('radar-chart', 'figure'),
[Input('represa-seleccionada', 'data')]
)
def actualizar_radar_chart(represa_seleccionada):
if not represa_seleccionada:
# If no dam is selected, show empty chart
fig = go.Figure()
fig.update_layout(
title="Select a dam to view comparison metrics",
xaxis=dict(visible=False),
yaxis=dict(visible=False),
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
font=dict(color="#2C3E50")
)
return fig
represa = df[df['NID ID'] == represa_seleccionada]
if represa.empty:
return go.Figure()
# Get hazard category of selected dam
categoria_peligro = represa['Hazard Potential Classification'].values[0]
# Calculate average metrics for all dams in the same category
df_categoria = df[df['Hazard Potential Classification'] == categoria_peligro]
# Prepare data for radar chart
datos_radar = []
# Normalize values for each metric
valores_represa = []
valores_promedio = []
etiquetas_metricas = []
for metrica in metricas:
# Create shorter label for the chart
etiqueta_corta = metrica.replace(' (Ft)', '').replace(' (Acre-Ft)', '').replace(' (Acres)', '').replace(' (Sq Miles)', '')
etiquetas_metricas.append(etiqueta_corta)
# Value of selected dam (with NaN handling)
valor_represa = represa[metrica].values[0] if not pd.isna(represa[metrica].values[0]) else 0
# Average value of the category (with NaN handling)
valor_promedio = df_categoria[metrica].mean() if not pd.isna(df_categoria[metrica].mean()) else 0
# Add values to lists
valores_represa.append(valor_represa)
valores_promedio.append(valor_promedio)
# Normalize values to be on a comparable scale
max_valores = [max(a, b) for a, b in zip(valores_represa, valores_promedio)]
max_valores = [val if val > 0 else 1 for val in max_valores] # Avoid division by zero
valores_represa_norm = [val / max_val for val, max_val in zip(valores_represa, max_valores)]
valores_promedio_norm = [val / max_val for val, max_val in zip(valores_promedio, max_valores)]
# Create radar chart with more attractive colors
fig = go.Figure()
fig.add_trace(go.Scatterpolar(
r=valores_represa_norm,
theta=etiquetas_metricas,
fill='toself',
name=f"{represa['Dam Name'].values[0]}",
line=dict(color='#3498DB'),
fillcolor='rgba(52, 152, 219, 0.3)'
))
fig.add_trace(go.Scatterpolar(
r=valores_promedio_norm,
theta=etiquetas_metricas,
fill='toself',
name=f"Average - {categoria_peligro}",
line=dict(color='#E74C3C'),
fillcolor='rgba(231, 76, 60, 0.3)'
))
fig.update_layout(
polar=dict(
radialaxis=dict(
visible=True,
range=[0, 1],
showticklabels=False,
showline=False,
ticks='',
gridcolor='#95a5a6'
),
angularaxis=dict(
gridcolor='#95a5a6'
),
bgcolor='rgba(255, 255, 255, 0.9)'
),
font=dict(color="#2C3E50"),
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
showlegend=True,
legend=dict(
orientation="h",
yanchor="bottom",
y=-0.2,
xanchor="center",
x=0.5
)
)
return fig
# Y el callback correspondiente
@app.callback(
Output("download-dataframe-csv", "data"),
Input("btn-download", "n_clicks"),
State("estado-seleccionado", "data"),
prevent_initial_call=True,
)
def func(n_clicks, estado_seleccionado):
if estado_seleccionado != 'all':
df_filtrado = df_anomalias[df_anomalias['State'] == estado_seleccionado]
else:
df_filtrado = df_anomalias
return dcc.send_data_frame(df_filtrado.to_csv, "represas_filtradas.csv")