import os
import pandas as pd
import numpy as np
import geopandas as gpd
import plotly.express as px
from dash import Dash, dcc, html, Input, Output, callback
import dash_bootstrap_components as dbc
external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
app = Dash(__name__, external_stylesheets=[dbc.themes.SLATE])
server = app.server # Required for deployment
# Load data using relative paths
flood_risk = gpd.read_file("data/final_flood_risk_scores.geojson")
flood_risk['Census Tract'] = flood_risk['Census Tract'].astype(int)
ami = pd.read_csv("data/acs_2023_ami_cleaned.csv")
age = pd.read_csv("data/acs_2023_age_cleaned.csv")
tenure = pd.read_csv("data/acs_2023_tenure_cleaned.csv")
race = pd.read_csv("data/race_percentages.csv")
# Adjust race dataframe for display
race[['NH White','NH Black or African American','NH Asian','Hispanic or Latino','NH Other']] = race[['NH White','NH Black or African American','NH Asian','Hispanic or Latino','NH Other']] * 100
race = race.rename(columns={'NH White': 'White', 'NH Black or African American': 'Black or African American','NH Asian' : 'Asian', 'NH Other': 'Other'})
race = race.set_index("geoid")
race.rename(index={'total': '111'}, inplace=True)
race.index = race.index.astype(int)
race.rename(index={111: 'San Francisco'}, inplace=True)
def normalize_scores(series):
"""Takes a series and normalizes values to be on a 0-100 range"""
max_val = series.max()
min_val = series.min()
scores = ((series - min_val) * 100) / (max_val - min_val)
return scores.round(1)
def race_plot(tract_id: str):
rows = race.loc[['San Francisco', tract_id], race.columns[0:5]]
race_fig = px.bar(
rows.T,
y=rows.index,
x=rows.columns,
barmode="group",
labels={'geoid': 'Geographic Area', 'index': 'Race or Ethnicity', 'value': 'Percent of Total Population'},
color_discrete_sequence=['lightgray', 'black']
)
race_fig.update_layout(xaxis={'categoryorder': 'total descending'})
race_fig.update_traces(
hovertemplate="%{x}
%{y:,.0f}%
"
)
race_fig.update_layout({'plot_bgcolor': 'rgba(0,0,0,0)'})
return race_fig
def baseline_race_plot():
rows = race.loc[['San Francisco'], race.columns[0:5]]
race_fig = px.bar(
rows.T,
y=rows.index,
x=rows.columns,
barmode="group",
labels={'geoid': 'Geographic Area', 'index': 'Race or Ethnicity', 'value': 'Percent of Total Population'},
color_discrete_sequence=['lightgray', 'black']
)
race_fig.update_layout(xaxis={'categoryorder': 'total descending'})
race_fig.update_traces(
hovertemplate="%{x}
%{y:,.0f}%
"
)
race_fig.update_layout({'plot_bgcolor': 'rgba(0,0,0,0)'})
return race_fig
def plot_data(df: pd.DataFrame, tract_id: str, column: str, x_label: str):
df = df.copy()
df['select'] = 0
df['opacity'] = 0.5
df.loc[df['geoid'] == tract_id, 'select'] = 1
df.loc[df['geoid'] == tract_id, 'opacity'] = 1
fig = px.strip(
df,
x=column,
color='select',
stripmode='overlay',
color_discrete_map={1: 'black', 0: 'darkgray'},
labels={column: x_label},
hover_name='geoid',
hover_data={column: True, 'select': False}
)
fig.update_layout(showlegend=False)
fig.update_layout({'plot_bgcolor': 'rgba(0,0,0,0)'})
fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
fig.update_layout(height=250)
fig.update_traces({'marker': {'size': 15}})
fig.update_xaxes(gridcolor='#f2f2f2')
fig.update_traces(
hovertemplate=f"Census Tract: {tract_id}
{x_label}: %{{x:,.0f}}
"
)
fig.data[0].marker.opacity = 0.2
fig.data[1].marker.opacity = 1.0
return fig
def baseline_plot(df: pd.DataFrame, column: str, x_label: str):
df = df.copy()
df['select'] = 0
df['opacity'] = 0.5
fig = px.strip(
df,
x=column,
color_discrete_sequence=['darkgray'],
stripmode='overlay',
labels={column: x_label},
hover_name='geoid',
hover_data={column: True, 'select': False},
custom_data=['geoid']
)
fig.update_layout(showlegend=False)
fig.update_layout({'plot_bgcolor': 'rgba(0,0,0,0)'})
fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
fig.update_layout(height=250)
fig.update_traces({'marker': {'size': 15}})
fig.update_xaxes(gridcolor='#f2f2f2')
fig.update_traces(marker=dict(opacity=0.2))
fig.update_traces(
hovertemplate="Census Tract: %{customdata[0]}
" + x_label + ": %{x:,.0f}
"
)
return fig
def update_figure(value1, value2, value3):
updated_flood = flood_risk.copy()
total_value = value1 + value2 + value3
factor1 = value1 / total_value
factor2 = value2 / total_value
factor3 = value3 / total_value
updated_flood["Weighted flood risk score"] = (
updated_flood["Floodplain coverage score"] * factor1 +
updated_flood["Drain quality score"] * factor2 +
updated_flood["Green infrastructure score"] * factor3
)
updated_flood["Weighted flood risk score"] = normalize_scores(updated_flood["Weighted flood risk score"])
sf_lat = 37.7749
sf_lon = -122.4194
updated_flood = updated_flood.set_index('Census Tract')
updated_flood = updated_flood.to_crs("EPSG:4326")
fig = px.choropleth_map(
updated_flood,
geojson=updated_flood.geometry,
locations=updated_flood.index,
color="Weighted flood risk score",
center={"lat": sf_lat, "lon": sf_lon},
map_style="carto-positron",
zoom=10,
color_continuous_scale='blues'
)
fig.update_layout(
transition_duration=200,
uirevision="constant",
clickmode='event+select',
width=800,
height=400,
margin={"r": 0, "t": 0, "l": 0, "b": 0},
coloraxis_colorbar=dict(orientation='h', len=0.8),
coloraxis_colorbar_title_font=dict(family='sans-serif')
)
fig.update_traces(
marker_line_width=0.5,
marker_opacity=0.75,
marker_line_color='white'
)
fig.update_traces(
selected=dict(marker=dict(opacity=1)),
unselected=dict(marker=dict(opacity=0.5))
)
fig.update_traces(
hovertemplate="Census Tract: %{location}
Weighted flood risk score: %{z:,.0f}"
)
return fig
def update_graphs(clicked_data):
if clicked_data is not None:
tract_id = clicked_data["points"][0]["location"]
ami_fig = plot_data(ami, tract_id, "median_income", 'Median Income')
tenure_fig = plot_data(tenure, tract_id, "pct_renter", 'Percent Renter Households')
under5_fig = plot_data(age, tract_id, 'Under 5 Years', 'Percent Residents Under 5 Years')
over65_fig = plot_data(age, tract_id, '65 Years and Over', 'Percent Residents 65 Years and Older')
race_fig = race_plot(tract_id)
return ami_fig, tenure_fig, under5_fig, over65_fig, race_fig
else:
ami_fig = baseline_plot(ami, "median_income", "Median Income")
tenure_fig = baseline_plot(tenure, "pct_renter", 'Percent Renter Households')
under5_fig = baseline_plot(age, 'Under 5 Years', 'Percent Residents Under 5 Years')
over65_fig = baseline_plot(age, '65 Years and Over', 'Percent Residents 65 Years and Older')
race_fig = baseline_race_plot()
return ami_fig, tenure_fig, under5_fig, over65_fig, race_fig
# Set initial state
initial_ami_fig, initial_tenure_fig, initial_under5_fig, initial_over65_fig, initial_race_fig = update_graphs(None)
initial_flood_map = update_figure(2, 2, 2)
# Build the app layout
app.layout = html.Div([
html.Div(
children="Analyzing Flood Risk in San Francisco",
style={'fontFamily': 'Arial', 'fontSize': '40px', "font-weight": "bold", 'textAlign': 'center', 'paddingTop': '30px'}
),
html.Div([
# Sliders
html.Div([
html.Div([
html.H3("Select a floodplain coverage weight:", style={'fontFamily': 'Arial', 'fontSize': '18px', "font-weight": "normal", 'letterSpacing': '.05px'}),
dcc.Slider(0, 4, step=None, marks={0: 'low priority', 1: '', 2: 'moderate priority', 3: '', 4: 'high priority'}, value=2, id='floodplain_slider')
]),
html.Div([
html.H3("Select a drain quality weight:", style={'fontFamily': 'Arial', 'fontSize': '18px', 'letterSpacing': '.05px'}),
dcc.Slider(0, 4, step=None, marks={0: 'low priority', 1: '', 2: 'moderate priority', 3: '', 4: 'high priority'}, value=2, id='drain_slider')
]),
html.Div([
html.H3("Select a green infrastructure weight:", style={'fontFamily': 'Arial', 'fontSize': '18px', 'letterSpacing': '.05px'}),
dcc.Slider(0, 4, step=None, marks={0: 'low priority', 1: '', 2: 'moderate priority', 3: '', 4: 'high priority'}, value=2, id='gi_slider')
]),
], style={'display': 'flex', 'flexDirection': 'column', 'flex': 1, 'marginTop': '20px', 'paddingRight': '40px', 'padding': '3%', 'justifyContent': 'center', "gap": "25px"}),
html.Div(id='slider-output-container'),
# Flood risk score map
html.Div(dcc.Graph(id='flood-risk-graph', figure=initial_flood_map), style={'padding': '3%', 'flex': 1})
], style={'display': 'flex', 'flexDirection': 'row'}),
# Census tract name display
html.Div([
html.Span("Census Tract:", style={'fontFamily': 'Arial', 'fontSize': '18px', 'marginRight': '5px'}),
html.Span(id='tract_id', style={'fontFamily': 'Arial', 'fontSize': '18px', 'font-weight': 'bold'})
], style={'display': 'flex', 'flexDirection': 'row', 'padding': '3%', 'justify-content': 'center'}),
# AMI and tenure charts
html.Div([
html.Div(dcc.Graph(id='ami_fig', figure=initial_ami_fig), style={'padding': '3%', 'flex': 1}),
html.Div(dcc.Graph(id='tenure_fig', figure=initial_tenure_fig), style={'padding': '3%', 'flex': 1}),
], style={'display': 'flex', 'flexDirection': 'row'}),
# Age charts
html.Div([
html.Div(dcc.Graph(id='under5_fig', figure=initial_under5_fig), style={'padding': '3%', 'flex': 1}),
html.Div(dcc.Graph(id='over65_fig', figure=initial_over65_fig), style={'padding': '3%', 'flex': 1}),
], style={'display': 'flex', 'flexDirection': 'row'}),
# Race bar chart
html.Div([
html.Div(dcc.Graph(id='race_fig', figure=initial_race_fig), style={'padding': '3%', 'flex': 1}),
], style={'display': 'flex', 'flexDirection': 'row', 'paddingLeft': '20%', 'paddingRight': '20%'}),
])
@callback(
[Output('flood-risk-graph', 'figure'),
Output('ami_fig', 'figure'),
Output('tenure_fig', 'figure'),
Output('under5_fig', 'figure'),
Output('over65_fig', 'figure'),
Output('race_fig', 'figure'),
Output('tract_id', 'children')],
[Input('floodplain_slider', 'value'),
Input('drain_slider', 'value'),
Input('gi_slider', 'value'),
Input('flood-risk-graph', 'clickData')]
)
def update_output(value1, value2, value3, clickData):
flood_risk_fig = update_figure(value1, value2, value3)
ami_fig, tenure_fig, under5_fig, over65_fig, race_fig = update_graphs(clickData)
if clickData is not None:
tract_id = clickData["points"][0]["location"]
else:
tract_id = 'Select a census tract on the map'
return flood_risk_fig, ami_fig, tenure_fig, under5_fig, over65_fig, race_fig, tract_id
if __name__ == '__main__':
app.run(debug=False, host="0.0.0.0", port=7860)