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)