| """ |
| dashboard_helpers.py — Map-building and chart functions used by app.py |
| """ |
|
|
| import numpy as np |
| import pandas as pd |
| import geopandas as gpd |
| import leafmap |
| import matplotlib |
| import matplotlib.pyplot as plt |
| import matplotlib.patches as mpatches |
| import matplotlib.colors as mcolors |
|
|
|
|
| def _classify_terciles(series): |
| out = pd.Series(-1, index=series.index, dtype=int) |
| mask = series.notna() |
| if mask.sum() < 3: |
| return out |
| labels = pd.qcut(series[mask], q=3, labels=[0, 1, 2], duplicates="drop") |
| out[mask] = labels.astype(int) |
| return out |
|
|
|
|
| |
| |
| |
|
|
| BIVARIATE_COLORS = [ |
| "#8B0000", |
| "#B8860B", |
| "#1B5E20", |
| "#CD5C5C", |
| "#DAA520", |
| "#4CAF50", |
| "#F4CCCC", |
| "#FFF9C4", |
| "#C8E6C9", |
| ] |
|
|
| CMAP_NAME = "bivariate_9" |
|
|
|
|
| |
|
|
| def build_bivariate_map(con, x_col, x_label, y_col, y_label): |
| df = con.sql(f""" |
| SELECT GEOID, geometry_wkt, {x_col}, {y_col} |
| FROM block_groups |
| WHERE total_pop > 0 |
| AND {x_col} IS NOT NULL |
| AND {y_col} IS NOT NULL |
| """).df() |
|
|
| gdf = gpd.GeoDataFrame( |
| df, |
| geometry=gpd.GeoSeries.from_wkt(df["geometry_wkt"]), |
| crs="EPSG:4269" |
| ).drop(columns="geometry_wkt") |
|
|
| gdf["x_class"] = _classify_terciles(gdf[x_col]) |
| |
| if y_col == "pct_minority": |
| gdf["y_class"] = _classify_terciles(gdf[y_col]).map({2: 0, 1: 1, 0: 2, -1: -1}) |
| else: |
| gdf["y_class"] = _classify_terciles(gdf[y_col]) |
| |
| gdf["bv_class"] = gdf.apply( |
| lambda r: int(r["y_class"]) * 3 + int(r["x_class"]) |
| if r["x_class"] >= 0 and r["y_class"] >= 0 else float("nan"), |
| axis=1 |
| ) |
|
|
| gdf_valid = gdf[gdf["bv_class"].notna()].copy() |
| gdf_valid["bv_class"] = gdf_valid["bv_class"].astype(float) |
|
|
| cmap = mcolors.ListedColormap(BIVARIATE_COLORS, name=CMAP_NAME) |
| try: |
| matplotlib.colormaps.register(cmap, name=CMAP_NAME) |
| except Exception: |
| pass |
|
|
| gdf_valid["hex_color"] = gdf_valid["bv_class"].apply( |
| lambda c: BIVARIATE_COLORS[int(c)] if not np.isnan(c) else "#cccccc" |
| ) |
|
|
| |
| import tempfile, os |
| tmp = tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) |
| tmp.close() |
| gdf_valid[["geometry", "bv_class", "hex_color", "GEOID"]].to_file(tmp.name, driver="GeoJSON") |
|
|
| import folium |
| m = folium.Map(location=[33.45, -112.07], zoom_start=10, |
| tiles="CartoDB dark_matter") |
|
|
| for _, row in gdf_valid.iterrows(): |
| folium.GeoJson( |
| row.geometry.__geo_interface__, |
| style_function=lambda f, c=row["hex_color"]: { |
| "fillColor": c, |
| "fillOpacity": 0.75, |
| "color": "#444", |
| "weight": 0.4, |
| } |
| ).add_to(m) |
|
|
| return m |
|
|
|
|
| |
|
|
| def build_bivariate_legend(x_label, y_label, y_col): |
| palette = [ |
| ["#8B0000", "#B8860B", "#1B5E20"], |
| ["#CD5C5C", "#DAA520", "#4CAF50"], |
| ["#F4CCCC", "#FFF9C4", "#C8E6C9"], |
| ] |
|
|
| fig, ax = plt.subplots(figsize=(3.5, 3.5), facecolor="#1a1a2e") |
| ax.set_facecolor("#1a1a2e") |
|
|
| for row in range(3): |
| for col in range(3): |
| rect = mpatches.FancyBboxPatch( |
| (col, row), 1, 1, |
| boxstyle="square,pad=0", |
| facecolor=palette[row][col], |
| edgecolor="#333", linewidth=0.5 |
| ) |
| ax.add_patch(rect) |
|
|
| ax.set_xlim(0, 3) |
| ax.set_ylim(0, 3) |
|
|
| ax.set_xticks([0.5, 1.5, 2.5]) |
| ax.set_xticklabels(["Low", "Med", "High"], color="#c9d1d9", fontsize=8) |
|
|
| ax.set_yticks([0.5, 1.5, 2.5]) |
| if y_col == "pct_minority": |
| ax.set_yticklabels(["High", "Med", "Low"], color="#c9d1d9", fontsize=8) |
| else: |
| ax.set_yticklabels(["Low", "Med", "High"], color="#c9d1d9", fontsize=8) |
|
|
| ax.set_xlabel(x_label, color="#c9d1d9", fontsize=9, labelpad=8) |
| ax.set_ylabel(y_label, color="#c9d1d9", fontsize=9, labelpad=8) |
| ax.tick_params(length=0) |
| for spine in ax.spines.values(): |
| spine.set_visible(False) |
|
|
| fig.subplots_adjust(left=0.22, bottom=0.18, right=0.95, top=0.95) |
| return fig |
|
|
| |
|
|
| def build_summary_stats(con, x_col, y_col): |
| df = con.sql(f""" |
| SELECT {x_col}, {y_col}, tree_equity_score, ndvi_mean, total_pop |
| FROM block_groups |
| WHERE total_pop > 0 |
| AND {x_col} IS NOT NULL |
| AND {y_col} IS NOT NULL |
| """).df() |
|
|
| df["x_tercile"] = _classify_terciles(df[x_col]).map( |
| {-1: "No data", 0: f"Low", 1: f"Mid", 2: f"High"} |
| ) |
|
|
| summary = ( |
| df.groupby("x_tercile") |
| .agg( |
| Block_Groups=(x_col, "count"), |
| **{f"Avg_{x_col}": (x_col, lambda x: round(x.mean(), 2))}, |
| **{f"Avg_{y_col}": (y_col, lambda x: round(x.mean(), 1))}, |
| Avg_TES=("tree_equity_score", lambda x: round(x.mean(), 1) if x.notna().any() else np.nan), |
| Avg_NDVI=("ndvi_mean", lambda x: round(x.mean(), 3) if x.notna().any() else np.nan), |
| ) |
| .reindex(["Low", "Mid", "High"]) |
| .reset_index() |
| .rename(columns={"x_tercile": "Group"}) |
| ) |
|
|
| styled = summary.style.background_gradient( |
| cmap="RdYlGn", subset=[f"Avg_{x_col}"] |
| ).hide(axis="index") |
|
|
| return styled |
|
|
|
|
| |
|
|
| def build_need_chart(con, y_col): |
| df = con.sql(f""" |
| SELECT GEOID, ndvi_mean, tree_equity_score, {y_col} |
| FROM block_groups |
| WHERE ndvi_mean IS NOT NULL AND tree_equity_score IS NOT NULL |
| AND {y_col} IS NOT NULL AND total_pop > 0 |
| """).df() |
|
|
| if df.empty: |
| fig, ax = plt.subplots(facecolor="#161b22") |
| ax.text(0.5, 0.5, "No data yet", ha="center", va="center", color="#8b949e") |
| return fig |
|
|
| def minmax(s): |
| mn, mx = s.min(), s.max() |
| return (s - mn) / (mx - mn) if mx > mn else s * 0 |
|
|
| df["ndvi_norm"] = minmax(df["ndvi_mean"]) |
| df["tes_norm"] = minmax(df["tree_equity_score"]) |
| df["need"] = (1 - df["ndvi_norm"]) * 0.5 + (1 - df["tes_norm"]) * 0.5 |
|
|
| if y_col == "pct_minority": |
| df["shade_norm"] = minmax(df[y_col]) |
| else: |
| df["shade_norm"] = 1 - minmax(df[y_col]) |
|
|
| top15 = df.nlargest(15, "need").reset_index(drop=True) |
| labels = [str(g)[-6:] for g in top15["GEOID"]] |
| x = np.arange(len(top15)) |
|
|
| fig, ax = plt.subplots(figsize=(10, 3.2), facecolor="#161b22") |
| ax.set_facecolor("#161b22") |
|
|
| for i, row in top15.iterrows(): |
| alpha = 0.3 + 0.7 * row["shade_norm"] |
| ax.bar(i, row["need"], color="#e05c5c", alpha=alpha, width=0.6) |
|
|
| |
| y_label = "% Minority" if y_col == "pct_minority" else "Median Income" |
| from matplotlib.patches import Patch |
| legend_elements = [ |
| Patch(facecolor="#e05c5c", alpha=1.0, |
| label=f"High concern ({('high' if y_col == 'pct_minority' else 'low')} {y_label})"), |
| Patch(facecolor="#e05c5c", alpha=0.3, |
| label=f"Lower concern ({('low' if y_col == 'pct_minority' else 'high')} {y_label})"), |
| ] |
|
|
| ax.set_xticks(x) |
| ax.set_xticklabels(labels, rotation=45, ha="right", color="#8b949e", fontsize=8) |
| ax.set_ylabel("Tree Need Score", color="#8b949e", fontsize=8) |
| ax.set_title("Top 15 Block Groups by Tree Program Need (GEOID suffix)", |
| color="#c9d1d9", fontsize=9, pad=6) |
| ax.tick_params(colors="#8b949e", labelsize=8) |
| for sp in ax.spines.values(): |
| sp.set_color("#30363d") |
| ax.legend(handles=legend_elements, frameon=False, |
| labelcolor="#8b949e", fontsize=8, loc="upper right") |
| fig.tight_layout(pad=0.5) |
| return fig |
|
|
| def build_need_map(con, y_col): |
| df = con.sql(f""" |
| SELECT GEOID, geometry_wkt, ndvi_mean, tree_equity_score, {y_col} |
| FROM block_groups |
| WHERE ndvi_mean IS NOT NULL AND tree_equity_score IS NOT NULL |
| AND {y_col} IS NOT NULL AND total_pop > 0 |
| """).df() |
|
|
| def minmax(s): |
| mn, mx = s.min(), s.max() |
| return (s - mn) / (mx - mn) if mx > mn else s * 0 |
|
|
| df["ndvi_norm"] = minmax(df["ndvi_mean"]) |
| df["tes_norm"] = minmax(df["tree_equity_score"]) |
| df["need"] = (1 - df["ndvi_norm"]) * 0.5 + (1 - df["tes_norm"]) * 0.5 |
|
|
| top15_geoids = set(df.nlargest(15, "need")["GEOID"].tolist()) |
|
|
| gdf = gpd.GeoDataFrame( |
| df, |
| geometry=gpd.GeoSeries.from_wkt(df["geometry_wkt"]), |
| crs="EPSG:4269" |
| ).drop(columns="geometry_wkt") |
|
|
| import folium |
| m = folium.Map(location=[33.45, -112.07], zoom_start=9, |
| tiles="CartoDB dark_matter") |
|
|
| for _, row in gdf.iterrows(): |
| is_top15 = row["GEOID"] in top15_geoids |
| folium.GeoJson( |
| row.geometry.__geo_interface__, |
| style_function=lambda f, top=is_top15: { |
| "fillColor": "#e05c5c" if top else "#444444", |
| "fillOpacity": 0.85 if top else 0.3, |
| "color": "#e05c5c" if top else "#333333", |
| "weight": 1.5 if top else 0.3, |
| } |
| ).add_to(m) |
|
|
| return m |
|
|