{ "cells": [ { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# Setup: install everything this notebook needs, regardless of environment.\n", "# (pandas/numpy/matplotlib often come preinstalled in Colab, but this makes the\n", "# notebook self-contained even in a bare Python environment.)\n", "!pip install -q pandas numpy matplotlib altair geopandas folium contextily shapely\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# Self-healing data fetch: if HouseholdIncome.csv / FHSZSRA_23_3.gdb aren't already\n", "# sitting next to this notebook (e.g. you opened just the .ipynb from GitHub/Colab\n", "# rather than downloading the whole repo), pull them from the project repo instead.\n", "import os\n", "\n", "if not os.path.exists('HouseholdIncome.csv') or not os.path.exists('FHSZSRA_23_3.gdb'):\n", " print('Local data files not found -- cloning them from the project repo...')\n", " !rm -rf /tmp/_lafires_data\n", " !git clone --depth 1 https://github.com/kalafejzo1/lafiresfinalproject.git /tmp/_lafires_data\n", " !cp /tmp/_lafires_data/HouseholdIncome.csv .\n", " !cp -r /tmp/_lafires_data/FHSZSRA_23_3.gdb .\n", " print('Done.')\n", "else:\n", " print('Local data files found -- using those.')\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "Gi8ZeogNWrSy" }, "outputs": [], "source": "import pandas as pd\nimport geopandas as gpd\nimport altair as alt" }, { "cell_type": "markdown", "metadata": { "id": "VXBGcFcDmBdP" }, "source": "Loading data (running locally in VS Code, not Colab)\n\nThis notebook originally used Colab's `files.upload()` picker. Running locally instead, so we point directly at the files already on disk / their public source URLs." }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "#Pull Zillow ZIP-level data directly from their public research URL (same source used later for Butte County)\n", "zillow_url = \"https://files.zillowstatic.com/research/public_csvs/zhvi/Zip_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv\"\n", "zillow = pd.read_csv(zillow_url)\n", "\n", "income = pd.read_csv(\"HouseholdIncome.csv\")" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "hazard = gpd.read_file(\n", " \"FHSZSRA_23_3.gdb\"\n", ")\n", "\n", "zip_shapes = gpd.read_file(\n", " \"https://www2.census.gov/geo/tiger/TIGER2024/ZCTA520/tl_2024_us_zcta520.zip\"\n", ")\n", "\n", "zillow['zip'] = zillow['RegionName'].astype(str).str.zfill(5)\n", "\n", "print(zip_shapes.head())\n", "print(zip_shapes.columns)\n", "\n", "hazard.head()" ] }, { "cell_type": "markdown", "metadata": { "id": "qK3qthU1lmWG" }, "source": [ "The Fire Hazard Severity Zone dataset was provided as a polygon shapefile, while the Zillow and Census datasets were organized by ZIP code. To make these datasets comparable, we created a ZIP-level wildfire exposure metric.\n", "\n", "First, Zillow data was filtered to Los Angeles County ZIP codes, which defined the geographic scope of our analysis. ZIP code boundary polygons were then obtained from the U.S. Census and filtered to match the ZIP codes present in the Zillow dataset.\n", "\n", "Next, both the ZIP code boundaries and Fire Hazard Severity Zone polygons were projected into the California Albers coordinate system (EPSG:3310) to ensure accurate area calculations. We then performed a spatial intersection between ZIP code polygons and fire hazard polygons. This allowed us to determine how much land area within each ZIP code fell into each fire hazard category (Moderate, High, and Very High).\n", "\n", "For each ZIP code, we calculated the total area classified as either High or Very High fire hazard and divided that value by the total land area of the ZIP code. This produced a wildfire exposure metric:\n", "\n", "Wildfire Exposure=\n", "Total ZIP Code Area /Area in High or Very High Hazard Zones\n", "\t\u200b\n", "\n", "\n", "The resulting variable, pct_high_very_high_hazard, ranges from 0 to 1 and represents the proportion of each ZIP code's land area that is exposed to elevated wildfire risk.\n", "\n", "This preprocessing step was necessary because the original fire hazard data was spatial (polygon-based), whereas our housing and demographic datasets were ZIP-level tabular datasets. Creating a ZIP-level wildfire exposure measure allowed us to merge wildfire risk, housing values, and income data into a single dataset and directly test our hypotheses regarding the relationship between income, housing markets, and wildfire risk." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "tXD38nDReUxT" }, "outputs": [], "source": [ "hazard_col = \"FHSZ_Description\"\n", "\n", "# 1. Clean Zillow ZIPs and filter to Los Angeles County\n", "zillow['zip'] = zillow['RegionName'].astype(str).str.zfill(5)\n", "\n", "zillow_la = zillow[\n", " zillow['CountyName'].str.contains(\"Los Angeles\", case=False, na=False)\n", "].copy()\n", "\n", "la_zips = set(zillow_la['zip'])\n", "\n", "# 2. Clean ZIP boundary ZIPs\n", "zip_shapes['zip'] = zip_shapes['ZCTA5CE20'].astype(str).str.zfill(5)\n", "\n", "zip_shapes_la = zip_shapes[\n", " zip_shapes['zip'].isin(la_zips)\n", "].copy()\n", "\n", "# 3. Project both layers into California Albers for accurate area calculations\n", "hazard_3310 = hazard.to_crs(\"EPSG:3310\")\n", "zip_shapes_la_3310 = zip_shapes_la.to_crs(\"EPSG:3310\")\n", "\n", "# 4. Calculate total area of each ZIP code\n", "zip_shapes_la_3310['zip_total_area'] = zip_shapes_la_3310.geometry.area\n", "\n", "zip_area = zip_shapes_la_3310[['zip', 'zip_total_area']].copy()\n", "\n", "# 5. Intersect ZIP polygons with fire hazard polygons\n", "hazard_zip = gpd.overlay(\n", " zip_shapes_la_3310[['zip', 'geometry']],\n", " hazard_3310[[hazard_col, 'geometry']],\n", " how='intersection'\n", ")\n", "\n", "# 6. Calculate area of each hazard category within each ZIP\n", "hazard_zip['hazard_area'] = hazard_zip.geometry.area\n", "\n", "hazard_summary = (\n", " hazard_zip\n", " .groupby(['zip', hazard_col])['hazard_area']\n", " .sum()\n", " .reset_index()\n", ")\n", "\n", "# 7. Pivot hazard categories into columns\n", "hazard_pivot = hazard_summary.pivot(\n", " index='zip',\n", " columns=hazard_col,\n", " values='hazard_area'\n", ").fillna(0).reset_index()\n", "\n", "# 8. Add High + Very High area\n", "hazard_pivot['high_very_high_area'] = 0\n", "\n", "for col in hazard_pivot.columns:\n", " if col in ['High', 'Very High']:\n", " hazard_pivot['high_very_high_area'] += hazard_pivot[col]\n", "\n", "# 9. Merge in total ZIP area\n", "hazard_pivot = hazard_pivot.merge(\n", " zip_area,\n", " on='zip',\n", " how='left'\n", ")\n", "\n", "# 10. Correct metric: % of ENTIRE ZIP area in High / Very High hazard zones\n", "hazard_pivot['pct_high_very_high_hazard'] = (\n", " hazard_pivot['high_very_high_area'] / hazard_pivot['zip_total_area']\n", ")\n", "\n", "# 11. Keep final clean hazard table\n", "hazard_clean = hazard_pivot[['zip', 'pct_high_very_high_hazard']].copy()\n", "\n", "# 12. Sanity checks\n", "print(hazard_clean.shape)\n", "print(hazard_clean['pct_high_very_high_hazard'].describe())\n", "\n", "hazard_clean.sort_values(\n", " 'pct_high_very_high_hazard',\n", " ascending=False\n", ").head(20)" ] }, { "cell_type": "markdown", "metadata": { "id": "9wiap0RGkLnO" }, "source": [ "Clean Zillow and create home value variables" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "QF-fXJ8Sjpmb" }, "outputs": [], "source": [ "date_cols = [c for c in zillow_la.columns if c[:4].isdigit()]\n", "\n", "latest_col = date_cols[-1] # latest Zillow date\n", "old_col = \"2015-05-31\" # 10 years before 2025-05\n", "\n", "zillow_clean = zillow_la[['zip', 'City', 'CountyName', latest_col, old_col]].copy()\n", "\n", "zillow_clean = zillow_clean.rename(columns={\n", " latest_col: 'current_home_value',\n", " old_col: 'home_value_2015'\n", "})\n", "\n", "zillow_clean['appreciation_10yr'] = (\n", " zillow_clean['current_home_value'] - zillow_clean['home_value_2015']\n", ") / zillow_clean['home_value_2015']" ] }, { "cell_type": "markdown", "metadata": { "id": "lKlgu-JGkNpx" }, "source": [ "Clean Census income" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "fnXW7FGQjqrq" }, "outputs": [], "source": [ "income_clean = income.copy()\n", "\n", "income_clean['zip'] = income_clean['NAME'].str.extract(r'(\\d{5})')\n", "\n", "income_clean = income_clean.rename(columns={\n", " 'B19013_001E': 'median_household_income'\n", "})\n", "\n", "income_clean['median_household_income'] = pd.to_numeric(\n", " income_clean['median_household_income'],\n", " errors='coerce'\n", ")\n", "\n", "income_clean = income_clean[['zip', 'median_household_income']]" ] }, { "cell_type": "markdown", "metadata": { "id": "Der1svQHkRnF" }, "source": [ "Merge everything" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "tzYONbaRjt56" }, "outputs": [], "source": "final_df = (\n zillow_clean\n .merge(income_clean, on='zip', how='left')\n .merge(hazard_clean, on='zip', how='left')\n)\n\n# ZIPs with no spatial overlap with any fire hazard polygon never get a row from the\n# intersection step, so they come back as NaN here. Treat \"no overlap\" as 0% exposure.\nfinal_df['pct_high_very_high_hazard'] = final_df['pct_high_very_high_hazard'].fillna(0)\n\nfinal_df.head()" }, { "cell_type": "markdown", "metadata": { "id": "8doITl7WkUD4" }, "source": [ "Inspect data before visualizing" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "HkHKunL2kGWx" }, "outputs": [], "source": [ "final_df[['zip',\n", " 'median_household_income',\n", " 'current_home_value',\n", " 'appreciation_10yr',\n", " 'pct_high_very_high_hazard']].head()" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "RChQGU3GkYOy" }, "outputs": [], "source": [ "final_df.isna().sum()" ] }, { "cell_type": "markdown", "metadata": { "id": "6SFTPwSUkl2-" }, "source": [ "Hypothesis 1:\n", "Higher-income ZIP codes experience lower wildfire exposure than lower-income ZIP codes.\n", "\n", "Test hypothesis numerically" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "hNHidVOPkfcx" }, "outputs": [], "source": [ "eda_df = final_df.dropna(\n", " subset=['median_household_income', 'pct_high_very_high_hazard']\n", ").copy()\n", "\n", "eda_df[['median_household_income',\n", " 'pct_high_very_high_hazard']].corr()" ] }, { "cell_type": "markdown", "metadata": { "id": "WW20_6SNnlbw" }, "source": [ "Visualize with scatter and linear regression" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "9kP8m0IDnnQ-" }, "outputs": [], "source": [ "scatter1 = alt.Chart(eda_df).mark_circle(\n", " size=60,\n", " opacity=0.5\n", ").encode(\n", " x=alt.X(\n", " 'median_household_income:Q',\n", " title='Median Household Income ($)'\n", " ),\n", " y=alt.Y(\n", " 'pct_high_very_high_hazard:Q',\n", " title='% of ZIP in High/Very High Fire Hazard Zone'\n", " ),\n", " tooltip=[\n", " 'zip',\n", " 'City',\n", " 'median_household_income',\n", " 'pct_high_very_high_hazard'\n", " ]\n", ").properties(\n", " title='Income vs Wildfire Exposure',\n", " width=700,\n", " height=450\n", ")\n", "\n", "scatter1" ] }, { "cell_type": "markdown", "metadata": { "id": "_I0pOa2cosNL" }, "source": [ "The initial numerical analysis and scatterplot do not support our hypothesis. Instead of observing lower wildfire exposure among higher-income ZIP codes, we found a weak positive relationship between income and wildfire exposure (r = 0.212). The scatterplot suggests that several of the highest-income ZIP codes also experience some of the highest levels of wildfire risk. This unexpected result motivated further exploration." ] }, { "cell_type": "markdown", "metadata": { "id": "u3Dvy5PQo8wP" }, "source": [ "Creating income quartiles" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "hjzC5h7aow44" }, "outputs": [], "source": [ "eda_df['income_group'] = pd.qcut(\n", " eda_df['median_household_income'],\n", " q=4,\n", " labels=[\n", " 'Lowest Income',\n", " 'Lower-Middle Income',\n", " 'Upper-Middle Income',\n", " 'Highest Income'\n", " ]\n", ")\n", "\n", "eda_df[['median_household_income', 'income_group']].head()" ] }, { "cell_type": "markdown", "metadata": { "id": "N7vHnTa1o-Ng" }, "source": [ "Creating boxplot" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "Hve0h5_lpA8y" }, "outputs": [], "source": [ "income_order = [\n", " 'Lowest Income',\n", " 'Lower-Middle Income',\n", " 'Upper-Middle Income',\n", " 'Highest Income'\n", "]\n", "\n", "boxplot = alt.Chart(eda_df).mark_boxplot(size=50).encode(\n", " x=alt.X(\n", " 'income_group:N',\n", " sort=income_order,\n", " title='Income Group'\n", " ),\n", " y=alt.Y(\n", " 'pct_high_very_high_hazard:Q',\n", " title='% of ZIP in High / Very High Fire Hazard Zones'\n", " ),\n", " tooltip=[\n", " 'income_group',\n", " alt.Tooltip('pct_high_very_high_hazard:Q', format='.2f')\n", " ]\n", ").properties(\n", " title='Wildfire Exposure Across Income Groups',\n", " width=700,\n", " height=450\n", ")\n", "\n", "boxplot" ] }, { "cell_type": "markdown", "metadata": { "id": "gOAR7O32pUa2" }, "source": [ "Your highest-income group has the widest spread and reaches the highest wildfire exposure values. That does not support the hypothesis that higher-income ZIPs are less exposed to wildfire risk. It suggests some wealthy ZIPs are highly exposed, likely hillside/coastal areas like Malibu, Topanga, and Calabasas." ] }, { "cell_type": "markdown", "metadata": { "id": "q5MRyKxSpe9F" }, "source": [ "Colored Scatterplot" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "GcNDKeoVpjrq" }, "outputs": [], "source": [ "alt.Chart(eda_df).mark_circle(\n", " size=150,\n", " opacity=0.75\n", ").encode(\n", " x=alt.X(\n", " 'median_household_income:Q',\n", " title='Median Household Income ($)'\n", " ),\n", " y=alt.Y(\n", " 'pct_high_very_high_hazard:Q',\n", " title='% of ZIP in High / Very High Fire Hazard Zones'\n", " ),\n", " color=alt.Color(\n", " 'current_home_value:Q',\n", " title='Current Home Value ($)',\n", " scale=alt.Scale(scheme='viridis')\n", " ),\n", " tooltip=[\n", " 'zip',\n", " 'City',\n", " alt.Tooltip('median_household_income:Q', format=',.0f'),\n", " alt.Tooltip('current_home_value:Q', format='$,.0f'),\n", " alt.Tooltip('pct_high_very_high_hazard:Q', format='.2f')\n", " ]\n", ").properties(\n", " title='Income, Home Values, and Wildfire Exposure',\n", " width=700,\n", " height=500\n", ")" ] }, { "cell_type": "markdown", "metadata": { "id": "VvVLDimgp-cu" }, "source": [ "This visualization incorporates a third variable, current home value, into the relationship between income and wildfire exposure. Color allows us to identify whether high-risk ZIP codes are also high-value real estate markets. The chart reveals that several ZIP codes with both high incomes and substantial wildfire exposure also have some of the highest housing values in Los Angeles County." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 535 }, "id": "VMM_Yb5TsOek", "outputId": "44bf767a-85ab-4425-fc39-a3d792fe2e26" }, "outputs": [ { "data": { "text/html": [ "\n", "\n", "
\n", "" ], "text/plain": [ "alt.LayerChart(...)" ] }, "execution_count": 1, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import pandas as pd\n", "import altair as alt\n", "\n", "#Pull Zillow County-level Data directly from their public URL\n", "#This bypasses the need for any local CSV uploads\n", "zillow_url = \"https://files.zillowstatic.com/research/public_csvs/zhvi/County_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv\"\n", "df = pd.read_csv(zillow_url)\n", "\n", "#Filter for California\n", "ca_df = df[df['State'] == 'CA'].copy()\n", "\n", "#Focus on Butte County (Camp Fire, Nov 2018) vs. State Average\n", "#We will look at Jan 2017 through early 2024 to see the before and after\n", "date_cols = [c for c in ca_df.columns if c.startswith('2017') or c.startswith('2018') or\n", " c.startswith('2019') or c.startswith('2020') or c.startswith('2021') or\n", " c.startswith('2022') or c.startswith('2023') or c.startswith('2024')]\n", "\n", "#Calculate CA average across all counties\n", "ca_avg = ca_df[date_cols].mean()\n", "\n", "#Get Butte County specifically\n", "butte = ca_df[ca_df['RegionName'] == 'Butte County'][date_cols].mean()\n", "\n", "#Combine for Altair plotting\n", "plot_df = pd.DataFrame({\n", " 'Date': pd.to_datetime(date_cols),\n", " 'Butte County': butte.values,\n", " 'California Average': ca_avg.values\n", "}).melt(id_vars=['Date'], var_name='Region', value_name='Typical Home Value')\n", "\n", "#Visualize the trend\n", "line_chart = alt.Chart(plot_df).mark_line(size=3).encode(\n", " x=alt.X('Date:T', title='Year'),\n", " y=alt.Y('Typical Home Value:Q', scale=alt.Scale(zero=False), title='Zillow Home Value Index ($)'),\n", " color=alt.Color('Region:N', title='Area', scale=alt.Scale(scheme='set1')),\n", " tooltip=[alt.Tooltip('Date:T', format='%b %Y'), 'Region:N', alt.Tooltip('Typical Home Value:Q', format='$,.0f')]\n", ").properties(\n", " title='Housing Value Recovery Post-2018 Camp Fire (Butte County vs. CA Average)',\n", " width=700,\n", " height=450\n", ")\n", "\n", "#Add a vertical line for the Camp Fire (November 8, 2018)\n", "fire_date = pd.DataFrame({'Date': [pd.to_datetime('2018-11-08')]})\n", "rule = alt.Chart(fire_date).mark_rule(color='red', strokeDash=[5, 5], size=2).encode(x='Date:T')\n", "\n", "#Combine and show\n", "(line_chart + rule)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 535 }, "id": "kn49Pxt2OuVt", "outputId": "b883f88d-ac5f-4a51-9178-ce276da0c692" }, "outputs": [ { "data": { "text/html": [ "\n", "\n", "
\n", "" ], "text/plain": [ "alt.LayerChart(...)" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "#Identify the Zillow date column corresponding to the fire (Nov 2018)\n", "fire_month_col = [c for c in date_cols if '2018-11' in c][0]\n", "\n", "#Baseline both series to 100 at the time of the fire\n", "butte_indexed = (butte / butte[fire_month_col]) * 100\n", "ca_avg_indexed = (ca_avg / ca_avg[fire_month_col]) * 100\n", "\n", "#Prepare data for plotting\n", "plot_idx_df = pd.DataFrame({\n", " 'Date': pd.to_datetime(date_cols),\n", " 'Butte County': butte_indexed.values,\n", " 'California Average': ca_avg_indexed.values\n", "}).melt(id_vars=['Date'], var_name='Region', value_name='Index Value')\n", "\n", "#Filter to only show the timeline from the fire onward (Nov 2018 to early 2024)\n", "plot_idx_df = plot_idx_df[plot_idx_df['Date'] >= '2018-10-01']\n", "\n", "#Visualize the Indexed Trend\n", "indexed_chart = alt.Chart(plot_idx_df).mark_line(size=3).encode(\n", " x=alt.X('Date:T', title='Year'),\n", " y=alt.Y('Index Value:Q', scale=alt.Scale(domain=[90, 160]), title='Indexed Home Value (Nov 2018 = 100)'),\n", " color=alt.Color('Region:N', title='Area', scale=alt.Scale(scheme='set1')),\n", " tooltip=[alt.Tooltip('Date:T', format='%b %Y'), 'Region:N', alt.Tooltip('Index Value:Q', format='.1f')]\n", ").properties(\n", " title='Indexed Property Value Recovery Post-Camp Fire (Base 100)',\n", " width=700,\n", " height=450\n", ")\n", "\n", "#Combine with the fire date rule\n", "(indexed_chart + rule)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 380 }, "id": "mtvHryd8PguT", "outputId": "c46ffac7-c38b-4393-c870-ae6998915ef3" }, "outputs": [ { "data": { "text/html": [ "\n", "\n", "
\n", "" ], "text/plain": [ "alt.Chart(...)" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "#Identify 1-year, 3-year, and 5-year post-fire date columns\n", "yr1_col = [c for c in date_cols if '2019-11' in c][0]\n", "yr3_col = [c for c in date_cols if '2021-11' in c][0]\n", "yr5_col = [c for c in date_cols if '2023-11' in c][0]\n", "\n", "#Calculate percentage changes for Butte and CA Average\n", "def calc_appreciation(series, start_col, end_col):\n", " return ((series[end_col] - series[start_col]) / series[start_col]) * 100\n", "\n", "butte_appr = {\n", " '1-Year': calc_appreciation(butte, fire_month_col, yr1_col),\n", " '3-Year': calc_appreciation(butte, fire_month_col, yr3_col),\n", " '5-Year': calc_appreciation(butte, fire_month_col, yr5_col)\n", "}\n", "\n", "ca_appr = {\n", " '1-Year': calc_appreciation(ca_avg, fire_month_col, yr1_col),\n", " '3-Year': calc_appreciation(ca_avg, fire_month_col, yr3_col),\n", " '5-Year': calc_appreciation(ca_avg, fire_month_col, yr5_col)\n", "}\n", "\n", "#Calculate Relative Performance (Butte - CA Average)\n", "rel_perf = {\n", " '1-Year': butte_appr['1-Year'] - ca_appr['1-Year'],\n", " '3-Year': butte_appr['3-Year'] - ca_appr['3-Year'],\n", " '5-Year': butte_appr['5-Year'] - ca_appr['5-Year']\n", "}\n", "\n", "perf_df = pd.DataFrame(list(rel_perf.items()), columns=['Timeframe', 'Relative Appreciation (%)'])\n", "perf_df['Performance'] = perf_df['Relative Appreciation (%)'].apply(lambda x: 'Outperform' if x > 0 else 'Underperform')\n", "\n", "#Visualize Relative Performance\n", "bar_chart = alt.Chart(perf_df).mark_bar().encode(\n", " y=alt.Y('Timeframe:N', sort=['1-Year', '3-Year', '5-Year'], title='Time Since Fire'),\n", " x=alt.X('Relative Appreciation (%):Q', title='Appreciation Relative to CA Average (%)'),\n", " color=alt.Color('Performance:N', scale=alt.Scale(domain=['Outperform', 'Underperform'], range=['#4c78a8', '#f58518'])),\n", " tooltip=['Timeframe', alt.Tooltip('Relative Appreciation (%):Q', format='.2f')]\n", ").properties(\n", " title='Butte County Home Value Performance Relative to CA Average',\n", " width=700,\n", " height=300\n", ")\n", "\n", "bar_chart" ] }, { "cell_type": "markdown", "metadata": { "id": "bhs9BwDLPipW" }, "source": [ "Interactive Map: Putting the ZIP Codes on the Map\n", "\n", "Every visualization so far (scatterplots, boxplot, colored scatter) treats ZIP codes as abstract points on an income/wildfire axis, with no sense of where they actually are in Los Angeles County. Since we already have ZIP boundary polygons (`zip_shapes_la`) from the spatial join used to build our wildfire exposure metric, we can reuse those same geometries to build an interactive choropleth map. This lets a user toggle between wildfire exposure, income, home value, and appreciation as the fill color, and hover over any ZIP to see all four metrics at once \u2014 giving geographic context that a scatterplot can't." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import folium\n", "from folium.features import GeoJsonTooltip\n", "\n", "# --- Build a single GeoDataFrame combining ZIP boundaries with all ZIP-level metrics ---\n", "map_df = zip_shapes_la[['zip', 'geometry']].merge(final_df, on='zip', how='inner')\n", "map_df = map_df.set_geometry('geometry')\n", "\n", "if map_df.crs is None or map_df.crs.to_epsg() != 4326:\n", " map_df = map_df.to_crs(epsg=4326)\n", "\n", "map_df['wildfire_pct'] = map_df['pct_high_very_high_hazard'] * 100\n", "\n", "map_df = map_df.dropna(subset=[\n", " 'median_household_income', 'current_home_value',\n", " 'appreciation_10yr', 'wildfire_pct'\n", "])\n", "\n", "# --- Base map centered on LA County ---\n", "m = folium.Map(location=[34.05, -118.25], zoom_start=9, tiles='cartodbpositron')\n", "\n", "metrics = [\n", " ('wildfire_pct', 'Wildfire Exposure (%)', 'YlOrRd'),\n", " ('median_household_income', 'Median Household Income ($)', 'BuGn'),\n", " ('current_home_value', 'Current Home Value ($)', 'PuBu'),\n", " ('appreciation_10yr', '10-Year Appreciation (fraction)', 'YlGnBu'),\n", "]\n", "\n", "choropleth_names = []\n", "for i, (col, label, palette) in enumerate(metrics):\n", " choropleth = folium.Choropleth(\n", " geo_data=map_df,\n", " data=map_df,\n", " columns=['zip', col],\n", " key_on='feature.properties.zip',\n", " fill_color=palette,\n", " fill_opacity=0.75,\n", " line_opacity=0.2,\n", " legend_name=label,\n", " name=label,\n", " show=(i == 0), # only the first metric is visible on load\n", " overlay=True, # a real overlay, not a \"base layer\" -- keeps it from fighting the tile layer\n", " ).add_to(m)\n", " choropleth_names.append(choropleth.get_name())\n", "\n", " # Only keep one legend rendered at a time so they don't stack on screen\n", " if i != 0:\n", " for key in list(choropleth._children):\n", " if key.startswith('color_map'):\n", " del choropleth._children[key]\n", "\n", "# --- Hover tooltip overlay: shows all metrics regardless of which layer is toggled on ---\n", "tooltip_layer = folium.GeoJson(\n", " map_df,\n", " style_function=lambda x: {'fillOpacity': 0, 'color': 'transparent'},\n", " tooltip=GeoJsonTooltip(\n", " fields=['zip', 'City', 'median_household_income',\n", " 'current_home_value', 'appreciation_10yr', 'wildfire_pct'],\n", " aliases=['ZIP:', 'City:', 'Median Income ($):', 'Home Value ($):',\n", " '10-Yr Appreciation:', 'Wildfire Exposure (%):'],\n", " localize=True,\n", " ),\n", " name='ZIP details (hover)',\n", " overlay=True,\n", " control=True,\n", ").add_to(m)\n", "\n", "folium.LayerControl(collapsed=False).add_to(m)\n", "\n", "# Enforce \"only one metric visible at a time\" via JS instead of Folium's base-layer mechanism\n", "# (base layers are mutually exclusive with EACH OTHER *and* the tile layer, which was silently\n", "# hiding the street basemap every time a different metric was picked).\n", "exclusive_js = f\"\"\"\n", "\n", "\"\"\"\n", "m.get_root().html.add_child(folium.Element(exclusive_js))\n", "\n", "m" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "What's informative about this view: The map makes the earlier finding concrete \u2014 the highest-income, highest-wildfire-exposure ZIP codes (Malibu, Topanga, Calabasas) are visibly clustered along the coastal and foothill edges of the county, not scattered randomly. Toggling between layers shows that wildfire exposure and home value follow a similar geographic footprint, while median income spreads out differently, which is exactly the mismatch our correlation analysis picked up on. This is also the closest thing to an actual \"prototype interaction\" for the mid-term demo: it has a layer toggle and hover tooltips a user can click through live during the presentation.\n", "\n", "What could be improved: The four layers use different color scales and bin breaks, so \"toggle and compare\" still relies on the viewer remembering the previous layer's colors rather than seeing both at once. A future iteration could add a side-by-side small-multiples view, or a bivariate color scheme (e.g., income x wildfire exposure in one legend) to show the mismatch directly instead of requiring the user to switch layers back and forth. We could also overlay the Woolsey and Palisades fire perimeters on this same map to connect it to Hypotheses 2 and 3." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Numbered Highlight Pins: Top ZIP Codes to Watch\n", "\n", "To make the map easier to read at a glance during the presentation, we add numbered pins (styled after city-guide \"top spots\" maps) marking the 6 ZIP codes that best capture our central finding from Hypothesis 1: high home values sitting in areas of high wildfire exposure. These are the ZIP codes an investor or homebuyer would most want to interrogate further \u2014 high current value, but also high fire risk." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import warnings\n", "warnings.filterwarnings('ignore', category=UserWarning)\n", "\n", "# --- Rank ZIPs by wildfire exposure, restricted to above-median home value ---\n", "# This surfaces the \"wealthy but exposed\" ZIP codes our Hypothesis 1 analysis found\n", "highlight_df = (\n", " map_df[map_df['current_home_value'] > map_df['current_home_value'].median()]\n", " .sort_values('wildfire_pct', ascending=False)\n", " .head(6)\n", " .reset_index(drop=True)\n", ")\n", "highlight_df['rank'] = highlight_df.index + 1\n", "\n", "for _, row in highlight_df.iterrows():\n", " centroid = row.geometry.centroid\n", "\n", " pin_html = f\"\"\"\n", "
\n", " {row['rank']}\n", "
\n", " \"\"\"\n", "\n", " popup_html = f\"\"\"\n", " #{row['rank']} \u2014 {row['City']} ({row['zip']})
\n", " Wildfire Exposure: {row['wildfire_pct']:.1f}%
\n", " Home Value: ${row['current_home_value']:,.0f}
\n", " Median Income: ${row['median_household_income']:,.0f}\n", " \"\"\"\n", "\n", " folium.Marker(\n", " location=[centroid.y, centroid.x],\n", " icon=folium.DivIcon(html=pin_html),\n", " popup=folium.Popup(popup_html, max_width=250),\n", " tooltip=f\"#{row['rank']}: {row['City']}\",\n", " ).add_to(m)\n", "\n", "m" ] }, { "cell_type": "code", "source": [ "# Save as a standalone HTML file \u2014 VS Code's notebook renderer can be finicky about\n", "# raw HTML/JS outputs like Folium maps, so opening this file directly in a browser\n", "# is the most reliable way to view/demo it (also better for a live presentation).\n", "m.save(\"la_wildfire_map.html\")\n", "print(\"Saved to la_wildfire_map.html \u2014 open it directly in your browser\")" ], "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": "Bivariate Hex Map: A Different Visual Language\n\nThe interactive map above already flagged one limitation: comparing wildfire exposure and home value means switching between layers, since each uses its own color scale. Inspired by bivariate dot-density maps used in climate reporting (e.g. drought-intensity maps, where color encodes severity and dot size encodes frequency), this static hex-mosaic map encodes both variables at once: color = wildfire exposure, size = current home value. Each hexagon is a small sample point spatially joined to its containing ZIP polygon, so the mosaic naturally follows the real shape of LA County rather than showing one blob per ZIP.", "metadata": {} }, { "cell_type": "code", "source": [ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "from matplotlib.patches import RegularPolygon\n", "from matplotlib.collections import PatchCollection\n", "from matplotlib.colors import LinearSegmentedColormap, Normalize\n", "from shapely.geometry import Point\n", "import contextily as cx\n", "\n", "hex_df = map_df.to_crs('EPSG:3310')\n", "minx, miny, maxx, maxy = hex_df.total_bounds\n", "\n", "# --- Build a fine hex lattice covering the bounding box ---\n", "dx = 1900\n", "dy = dx * np.sqrt(3) / 2\n", "\n", "xs = np.arange(minx - dx, maxx + dx, dx)\n", "centers = []\n", "row = 0\n", "y = miny - dy\n", "while y < maxy + dy:\n", " x_offset = (dx / 2) if (row % 2) else 0\n", " for x in xs + x_offset:\n", " centers.append((x, y))\n", " y += dy\n", " row += 1\n", "\n", "pts = gpd.GeoDataFrame(geometry=[Point(c) for c in centers], crs='EPSG:3310')\n", "pts['hx'] = [c[0] for c in centers]\n", "pts['hy'] = [c[1] for c in centers]\n", "\n", "# Assign each hex point the wildfire/home-value stats of the ZIP polygon it falls inside\n", "joined = gpd.sjoin(\n", " pts, hex_df[['zip', 'wildfire_pct', 'current_home_value', 'geometry']],\n", " how='inner', predicate='within'\n", ")\n", "print(f\"Hex points total: {len(pts)}, matched inside LA ZIPs: {len(joined)}\")\n", "\n", "jminx, jminy, jmaxx, jmaxy = joined['hx'].min(), joined['hy'].min(), joined['hx'].max(), joined['hy'].max()\n", "pad = 4000\n", "jminx, jminy, jmaxx, jmaxy = jminx - pad, jminy - pad, jmaxx + pad, jmaxy + pad\n", "\n", "cmap = LinearSegmentedColormap.from_list(\n", " 'wildfire',\n", " ['#f7ecd1', '#f7c948', '#f2822a', '#e8412c', '#c31e5c', '#5b1a8c'],\n", ")\n", "vmax = joined['wildfire_pct'].quantile(0.98)\n", "color_norm = Normalize(vmin=0, vmax=vmax)\n", "\n", "val = joined['current_home_value'].clip(upper=joined['current_home_value'].quantile(0.98))\n", "vmin_, vmax_ = val.min(), val.max()\n", "size_norm = (np.sqrt(val) - np.sqrt(vmin_)) / (np.sqrt(vmax_) - np.sqrt(vmin_))\n", "radius = 550 + size_norm.values * 1450\n", "\n", "fig, ax = plt.subplots(figsize=(11, 11.5), dpi=220)\n", "fig.patch.set_facecolor('#f2ede2')\n", "\n", "patches = []\n", "colors = []\n", "for (x, y), r, c in zip(joined[['hx', 'hy']].values, radius, joined['wildfire_pct'].values):\n", " patches.append(RegularPolygon((x, y), numVertices=6, radius=r, orientation=0))\n", " colors.append(c)\n", "\n", "pc = PatchCollection(patches, cmap=cmap, norm=color_norm)\n", "pc.set_array(np.array(colors))\n", "pc.set_edgecolor('none')\n", "pc.set_alpha(0.85)\n", "ax.add_collection(pc)\n", "\n", "ax.set_xlim(jminx, jmaxx)\n", "ax.set_ylim(jminy, jmaxy)\n", "ax.set_aspect('equal')\n", "ax.axis('off')\n", "\n", "# Real street basemap underneath the hex dots (reproject tiles into our CA Albers CRS)\n", "cx.add_basemap(ax, crs=hex_df.crs.to_string(), source=cx.providers.CartoDB.Positron)\n", "\n", "# --- City labels ---\n", "city_labels = {\n", " 'Malibu': (-118.7798, 34.0259),\n", " 'Santa Monica': (-118.4912, 34.0195),\n", " 'Downtown LA': (-118.2437, 34.0407),\n", " 'Pasadena': (-118.1445, 34.1478),\n", " 'Long Beach': (-118.1937, 33.7701),\n", " 'Santa Clarita': (-118.5426, 34.3917),\n", "}\n", "labels_gdf = gpd.GeoDataFrame(\n", " {'name': list(city_labels.keys())},\n", " geometry=[Point(v) for v in city_labels.values()],\n", " crs='EPSG:4326',\n", ").to_crs('EPSG:3310')\n", "\n", "for name, geom in zip(labels_gdf['name'], labels_gdf.geometry):\n", " if not (jminx <= geom.x <= jmaxx and jminy <= geom.y <= jmaxy):\n", " continue\n", " ax.plot(geom.x, geom.y, marker='o', markersize=3.5, color='#2b2118', zorder=5)\n", " ax.annotate(name, (geom.x, geom.y), xytext=(6, 4), textcoords='offset points',\n", " fontsize=10, color='#2b2118', fontweight='medium', zorder=5)\n", "\n", "# --- Annotated callouts (leader-line style, like the reference chart) ---\n", "def callout(zip_code, text, xytext, ha='left'):\n", " row = hex_df[hex_df['zip'] == zip_code].iloc[0]\n", " pt = row.geometry.representative_point()\n", " ax.annotate(\n", " text, xy=(pt.x, pt.y), xytext=xytext, textcoords='offset points',\n", " fontsize=9.3, color='#2b2118', ha=ha, va='center',\n", " arrowprops=dict(arrowstyle='-', color='#2b2118', lw=0.9,\n", " connectionstyle='arc3,rad=0.15'),\n", " bbox=dict(boxstyle='round,pad=0.35', fc='#f2ede2', ec='none', alpha=0.85),\n", " zorder=6,\n", " )\n", " ax.plot(pt.x, pt.y, marker='o', markersize=4, mfc='none', mec='#2b2118', mew=1.2, zorder=6)\n", "\n", "callout('90290', 'Topanga: 94% of the ZIP sits in\\nhigh/very-high fire hazard zones \u2014\\nthe most exposed ZIP in LA County',\n", " (140, 90))\n", "callout('90265', 'Malibu: $3.19M median home value\\ndespite 59% wildfire exposure',\n", " (150, -70))\n", "callout('91307', '\"West Hills\" \u2014 labeled just \"Los Angeles\"\\nin Zillow\\'s data \u2014 has 47% wildfire\\nexposure and a $1.0M median home value',\n", " (-260, -40), ha='right')\n", "\n", "# --- Bivariate diamond legend (own inset axes, own coordinate system) ---\n", "lax = fig.add_axes([0.065, 0.70, 0.20, 0.20])\n", "lax.set_xlim(-1, 11)\n", "lax.set_ylim(-2.2, 9)\n", "lax.axis('off')\n", "lax.set_facecolor('none')\n", "\n", "n = 4\n", "step_l = 1.55\n", "for i in range(n):\n", " for j in range(n):\n", " lx = 5 + (i - j) * step_l * 0.62\n", " ly = (i + j) * step_l * 0.5\n", " c_val = (i / (n - 1)) * vmax\n", " s_val = 0.30 + (j / (n - 1)) * 0.42\n", " lax.add_patch(RegularPolygon((lx, ly), numVertices=6, radius=s_val,\n", " orientation=0, facecolor=cmap(color_norm(c_val)),\n", " edgecolor='none'))\n", "\n", "lax.text(5, 9.0, 'Bivariate key', fontsize=9.5, color='#2b2118', ha='center', fontweight='medium')\n", "lax.text(1.4, -1.3, 'More wildfire\\nexposure', fontsize=8.3, color='#2b2118', ha='center', va='top')\n", "lax.text(8.6, -1.3, 'Higher\\nhome value', fontsize=8.3, color='#2b2118', ha='center', va='top')\n", "\n", "# --- Title ---\n", "fig.text(0.065, 0.955, 'Wildfire Exposure & Home Values Across LA County',\n", " fontsize=21, color='#2b2118', fontfamily='serif')\n", "fig.text(0.065, 0.932,\n", " 'Color = share of ZIP in high/very-high fire hazard zone | Size = current median home value',\n", " fontsize=11.5, color='#6b5d4f')\n", "\n", "plt.subplots_adjust(top=0.90)\n", "plt.savefig(\"la_wildfire_hexmap.png\", bbox_inches='tight', facecolor=fig.get_facecolor())\n", "plt.show()" ], "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": "What's informative about this view: Encoding both variables at once \u2014 color for wildfire exposure, size for home value \u2014 makes the Hypothesis 1 finding visible in a single glance rather than requiring a layer toggle: the Malibu/Topanga/West Hills corridor along the county's western edge shows up as large, dark, saturated hexagons \u2014 high value and high risk in the same place. It also surfaces the ZIP-labeling issue directly on the map: neighborhoods like West Hills and Chatsworth get folded into a generic \"Los Angeles\" label in Zillow's data despite being distinct, well-known areas with very different wildfire exposure than downtown.\n\nWhat could be improved: The bivariate encoding is powerful but has a steeper learning curve than a single-variable choropleth \u2014 the diamond legend takes a few seconds to parse the first time. It's also static, so it loses the hover-for-exact-values interactivity of the Folium map above. A combined approach \u2014 this bivariate encoding rendered as an interactive layer with tooltips \u2014 would likely be the strongest version for a final deliverable.", "metadata": {} }, { "cell_type": "markdown", "source": [ "Bringing It Together: The LA Wildfire Risk & Investment Explorer\n", "\n", "Our proposal set out to let users \"explore wildfire activity alongside housing market, insurance, and demographic data\" and to \"identify whether some communities continue to experience strong housing growth despite wildfire risk, while others show signs of increased vulnerability.\" This version reframes the earlier dynamic map explicitly around that real-estate decision \u2014 the layer names, legend, and pins below are written for an investor or homebuyer, not just for our own analysis. It combines:\n", "\n", "- **5 continuous ZIP-level layers**, still toggleable: Wildfire Risk, Median Household Income, Median Home Value, 10-Year Price Appreciation, and a new **Investment Opportunity Score** \u2014 each ZIP's percentile rank on 10-year appreciation minus its percentile rank on wildfire exposure, weighted equally, so a ZIP can't top the ranking purely on growth the way a raw-value formula would let it. It is based on wildfire risk, historical appreciation, and housing market characteristics, and is intended as a screening tool for further due diligence, not a definitive investment recommendation.\n", "- **An \"Investment Category\" layer** \u2014 the same risk/appreciation quadrant as before, relabeled in investor-facing language: **Opportunity Zone** (fire-exposed but still appreciating above the county median), **Red Flag** (fire-exposed without the appreciation to match), **Safe Bet**, and **Low Priority**.\n", "- **Numbered \"Top Investment Picks\" pins** marking the 8 ZIPs with the highest Investment Score, each with a popup summarizing home value, appreciation, wildfire risk, and category \u2014 a concrete, ranked answer to \"where should I look first?\"\n", "- **Historical major fires** (CAL FIRE FRAP's public statewide dataset, filtered to fires \u22653,000 acres near LA County) as a toggleable overlay of bubbles sized by acres burned and colored by decade, including Woolsey (2018, ~89,551 acres) and Palisades (2025, ~23,449 acres) \u2014 so a user can check whether a \"Top Pick\" ZIP has actually burned before, not just whether it's modeled as hazardous. (An earlier version drew these as filled fire perimeters, but overlapping polygons stacked into a muddy mess \u2014 bubbles read far more cleanly.)\n", "\n", "**Not yet included:** insurance availability and cost data (e.g., the California FAIR Plan enrollment data named in our proposal). We haven't sourced and cleaned that dataset yet, so rather than guess at insurance figures, this map leaves that layer out \u2014 for a real real-estate tool, insurance cost/availability would likely change the Investment Score more than any other single input, since a ZIP can look like a \"Safe Bet\" on appreciation alone while quietly becoming uninsurable." ], "metadata": {} }, { "cell_type": "code", "source": [ "import os\n", "import urllib.request\n", "import numpy as np\n", "from shapely.geometry import Point\n", "from matplotlib.colors import LinearSegmentedColormap, Normalize\n", "import matplotlib as mpl\n", "\n", "# --- Fetch CAL FIRE FRAP's public historical fire perimeter dataset (cached locally: ~250MB, one-time) ---\n", "FIRE_CACHE = \"ca_fire_perimeters.geojson\"\n", "FIRE_URL = \"https://hub.arcgis.com/api/download/v1/items/c3c10388e3b24cec8a954ba10458039d/geojson?redirect=true&layers=0\"\n", "\n", "if not os.path.exists(FIRE_CACHE):\n", " print(\"Downloading CAL FIRE historical fire perimeters (one-time, ~250MB)...\")\n", " urllib.request.urlretrieve(FIRE_URL, FIRE_CACHE)\n", "\n", "la_bounds_3857 = map_df.to_crs(epsg=3857).total_bounds\n", "buf = 20000\n", "bbox = (la_bounds_3857[0] - buf, la_bounds_3857[1] - buf, la_bounds_3857[2] + buf, la_bounds_3857[3] + buf)\n", "\n", "fires = gpd.read_file(FIRE_CACHE, bbox=bbox).to_crs(epsg=4326)\n", "fires = fires[fires['GIS_ACRES'] >= 3000].copy()\n", "fires = fires.dropna(subset=['DECADES'])\n", "fires['YEAR_'] = fires['YEAR_'].astype('Int64')\n", "fires['acres_fmt'] = fires['GIS_ACRES'].round(0).astype(int)\n", "fires['fire_lat'] = fires.geometry.centroid.y\n", "fires['fire_lon'] = fires.geometry.centroid.x\n", "print(f\"Historical fires kept (>=3,000 acres, near LA County): {len(fires)}\")\n", "\n", "# --- Investment Opportunity Score: risk-adjusted appreciation ---\n", "# A simple, transparent illustrative score (NOT a real underwriting metric, and not\n", "# investment advice -- intended as a screening tool for further due diligence).\n", "#\n", "# Earlier version: appreciation_10yr / (1 + wildfire_pct / 100). That divisor is\n", "# bounded between 1 and ~1.94 across our whole dataset (max observed wildfire_pct is\n", "# ~94%), so even the highest-risk ZIP in LA County only had its score cut roughly in\n", "# half, while appreciation_10yr itself ranges from -0.09 to 1.85 -- a much wider span.\n", "# Net effect: correlation between the old score and raw appreciation was 0.96 (and\n", "# only -0.27 with wildfire risk), so growth was doing almost all the work and 7 of\n", "# the \"top 8\" picks had exactly 0% measured wildfire exposure.\n", "#\n", "# Fix: percentile-rank both components onto the same 0-100 scale first, so neither\n", "# can dominate just by having a wider raw numeric range, then combine with explicit,\n", "# adjustable weights. (method='min' means every ZIP tied at 0% wildfire exposure gets\n", "# the single best -- not average -- risk percentile, rather than being penalized\n", "# relative to each other for a tie at the safest possible value.)\n", "GROWTH_WEIGHT = 0.5\n", "RISK_WEIGHT = 0.5\n", "\n", "growth_percentile = map_df['appreciation_10yr'].rank(method='min', pct=True) * 100\n", "risk_percentile = map_df['wildfire_pct'].rank(method='min', pct=True) * 100\n", "\n", "map_df['investment_score'] = (\n", " GROWTH_WEIGHT * growth_percentile - RISK_WEIGHT * risk_percentile\n", ")\n", "# Ranges roughly -50 (worst: max risk, min growth) to +50 (best: min risk, max growth)\n", "\n", "# --- Investment Category (same risk/growth quadrant as before, real-estate-facing labels) ---\n", "# wildfire_pct is heavily zero-inflated (most ZIPs have 0% exposure), so a median split on\n", "# risk is degenerate. Use \"any measurable hazard-zone overlap\" as the risk cutoff instead.\n", "med_growth = map_df['appreciation_10yr'].median()\n", "\n", "def classify(row):\n", " hi_risk = row['wildfire_pct'] > 0\n", " hi_growth = row['appreciation_10yr'] >= med_growth\n", " if hi_risk and hi_growth:\n", " return 'Opportunity Zone (high risk, high appreciation)'\n", " if hi_risk and not hi_growth:\n", " return 'Red Flag (high risk, low appreciation)'\n", " if not hi_risk and hi_growth:\n", " return 'Safe Bet (low risk, high appreciation)'\n", " return 'Low Priority (low risk, low appreciation)'\n", "\n", "map_df['investment_category'] = map_df.apply(classify, axis=1)\n", "print(map_df['investment_category'].value_counts())\n", "\n", "category_colors = {\n", " 'Opportunity Zone (high risk, high appreciation)': '#841c79',\n", " 'Red Flag (high risk, low appreciation)': '#df3838',\n", " 'Safe Bet (low risk, high appreciation)': '#f2892d',\n", " 'Low Priority (low risk, low appreciation)': '#f7de9b',\n", "}\n", "\n", "# --- Build a hex-grid of sample points over LA County (in meters), then reproject to lon/lat ---\n", "map_df_m = map_df.to_crs(epsg=3310)\n", "minx, miny, maxx, maxy = map_df_m.total_bounds\n", "dx = 2400\n", "dy = dx * np.sqrt(3) / 2\n", "xs = np.arange(minx - dx, maxx + dx, dx)\n", "centers = []\n", "row_i = 0\n", "y = miny - dy\n", "while y < maxy + dy:\n", " x_offset = (dx / 2) if (row_i % 2) else 0\n", " for x in xs + x_offset:\n", " centers.append((x, y))\n", " y += dy\n", " row_i += 1\n", "\n", "pts = gpd.GeoDataFrame(geometry=[Point(c) for c in centers], crs='EPSG:3310')\n", "joined_m = gpd.sjoin(pts, map_df_m[['zip', 'geometry']], how='inner', predicate='within')\n", "hex_pts = gpd.GeoDataFrame(joined_m[['zip']], geometry=joined_m.geometry, crs='EPSG:3310').to_crs(epsg=4326)\n", "hex_pts = hex_pts.merge(\n", " map_df[['zip', 'City', 'wildfire_pct', 'median_household_income', 'current_home_value',\n", " 'appreciation_10yr', 'investment_score', 'investment_category']],\n", " on='zip', how='left'\n", ")\n", "hex_pts['lon'] = hex_pts.geometry.x\n", "hex_pts['lat'] = hex_pts.geometry.y\n", "print(f\"Hex points: {len(hex_pts)}\")\n", "\n", "cmap = LinearSegmentedColormap.from_list(\n", " 'wildfire', ['#f7ecd1', '#f7c948', '#f2822a', '#e8412c', '#c31e5c', '#5b1a8c']\n", ")\n", "\n", "def hex_color(value, norm):\n", " return mpl.colors.to_hex(cmap(norm(value)))\n", "\n", "# --- Base map: cream background instead of map tiles, to match the bivariate hex map's look ---\n", "dyn_map = folium.Map(location=[34.05, -118.25], zoom_start=9, prefer_canvas=True, tiles=None)\n", "folium.TileLayer(\n", " tiles='cartodbpositron', name='Streets Basemap (CARTO)',\n", " attr='Basemap by CARTO, data by OpenStreetMap contributors', control=True,\n", ").add_to(dyn_map)\n", "\n", "metrics = [\n", " ('wildfire_pct', 'Wildfire Risk (%)'),\n", " ('median_household_income', 'Median Household Income ($)'),\n", " ('current_home_value', 'Median Home Value ($)'),\n", " ('appreciation_10yr', '10-Year Price Appreciation (Fraction)'),\n", " ('investment_score', 'Investment Opportunity Score'),\n", "]\n", "\n", "exclusive_fg_names = []\n", "for i, (col, label) in enumerate(metrics):\n", " vmin = hex_pts[col].quantile(0.02)\n", " vmax = hex_pts[col].quantile(0.98)\n", " norm = Normalize(vmin=vmin, vmax=vmax)\n", " fg = folium.FeatureGroup(name=label, show=(i == 0), overlay=True)\n", " rmin, rmax = 2, 6.5\n", " for _, r in hex_pts.iterrows():\n", " v = r[col]\n", " radius = rmin + np.clip((v - vmin) / (vmax - vmin), 0, 1) * (rmax - rmin)\n", " folium.CircleMarker(\n", " location=(r['lat'], r['lon']),\n", " radius=radius,\n", " color='white',\n", " weight=0.8,\n", " fill=True,\n", " fill_color=hex_color(v, norm),\n", " fill_opacity=0.88,\n", " ).add_to(fg)\n", " fg.add_to(dyn_map)\n", " exclusive_fg_names.append(fg.get_name())\n", "\n", "# --- Wildfire Exposure background texture (same muted treatment as the bubble map) ---\n", "# An optional, always-available context layer, independent of whichever metric/category is selected\n", "texture_layer = folium.FeatureGroup(name='Wildfire Exposure (background texture)', show=False, overlay=True)\n", "tex_norm = Normalize(vmin=0, vmax=hex_pts['wildfire_pct'].quantile(0.95))\n", "for _, r in hex_pts.iterrows():\n", " v = min(r['wildfire_pct'], hex_pts['wildfire_pct'].quantile(0.95))\n", " radius = 1.2 + tex_norm(v) * 3.5\n", " folium.CircleMarker(\n", " location=(r['lat'], r['lon']), radius=radius, color=None, fill=True,\n", " fill_color='#5b7ca8', fill_opacity=0.35, weight=0,\n", " ).add_to(texture_layer)\n", "texture_layer.add_to(dyn_map)\n", "\n", "# --- Investment Category layer (categorical dots, same radio group as the 5 metrics above) ---\n", "fg_cat = folium.FeatureGroup(name='Investment Category', show=False, overlay=True)\n", "for _, r in hex_pts.iterrows():\n", " folium.CircleMarker(\n", " location=(r['lat'], r['lon']),\n", " radius=4.5,\n", " color='white',\n", " weight=0.7,\n", " fill=True,\n", " fill_color=category_colors[r['investment_category']],\n", " fill_opacity=0.88,\n", " ).add_to(fg_cat)\n", "fg_cat.add_to(dyn_map)\n", "exclusive_fg_names.append(fg_cat.get_name())\n", "\n", "# --- Hover tooltip overlay: property-card style with everything an investor would want ---\n", "map_df['investment_score_fmt'] = map_df['investment_score'].round(2)\n", "folium.GeoJson(\n", " map_df,\n", " style_function=lambda x: {'fillOpacity': 0, 'color': 'transparent'},\n", " tooltip=GeoJsonTooltip(\n", " fields=['zip', 'City', 'investment_category', 'current_home_value',\n", " 'appreciation_10yr', 'wildfire_pct', 'investment_score_fmt'],\n", " aliases=['ZIP:', 'City:', 'Category:', 'Home Value ($):',\n", " '10-Yr Appreciation:', 'Wildfire Risk (%):', 'Investment Score:'],\n", " localize=True,\n", " ),\n", " name='ZIP details (hover)',\n", " overlay=True,\n", " control=True,\n", ").add_to(dyn_map)\n", "\n", "# --- Historical fires overlay: bubbles (size = acres, color = decade), not filled perimeters ---\n", "# Filled polygons looked messy \u2014 overlapping fire boundaries stack their opacity into muddy\n", "# blobs and clash with the category dot colors underneath. Bubbles (same treatment as the\n", "# editorial bubble map) show the same information without the visual noise.\n", "fire_layer = folium.FeatureGroup(name='Historical Major Fires (>=3,000 acres, CAL FIRE FRAP)', show=False, overlay=True)\n", "for _, r in fires.iterrows():\n", " radius = max(3, np.sqrt(r['GIS_ACRES']) * 0.045)\n", " popup_html = f\"{r['FIRE_NAME']} ({int(r['YEAR_'])})
{r['acres_fmt']:,} acres burned\"\n", " folium.CircleMarker(\n", " location=(r['fire_lat'], r['fire_lon']),\n", " radius=radius,\n", " color='#b3401f',\n", " weight=1,\n", " fill=True,\n", " fill_color='#f2d9a1',\n", " fill_opacity=0.85,\n", " popup=folium.Popup(popup_html, max_width=220),\n", " tooltip=f\"{r['FIRE_NAME']} ({int(r['YEAR_'])}) \u2014 {r['acres_fmt']:,} acres\",\n", " ).add_to(fire_layer)\n", "fire_layer.add_to(dyn_map)\n", "\n", "# --- City labels (text only, matching the static hex map's style) ---\n", "city_labels = {\n", " 'Malibu': (-118.7798, 34.0259), 'Santa Monica': (-118.4912, 34.0195),\n", " 'Downtown LA': (-118.2437, 34.0407), 'Pasadena': (-118.1445, 34.1478),\n", " 'Long Beach': (-118.1937, 33.7701), 'Santa Clarita': (-118.5426, 34.3917),\n", "}\n", "for name, (lon, lat) in city_labels.items():\n", " folium.Marker(\n", " location=(lat, lon),\n", " icon=folium.DivIcon(html=f'
{name}
'),\n", " ).add_to(dyn_map)\n", "\n", "# --- Top Investment Picks: numbered pins ranked by Investment Score ---\n", "top_picks = map_df.sort_values('investment_score', ascending=False).head(8).reset_index(drop=True)\n", "top_picks['rank'] = top_picks.index + 1\n", "for _, row in top_picks.iterrows():\n", " centroid = row.geometry.centroid\n", " pin_html = f\"\"\"\n", "
\n", "
\n", " \n", " {row['rank']}\n", " \n", "
\n", "
\n", " \"\"\"\n", " popup_html = f\"\"\"\n", " #{row['rank']} \u2014 {row['City']} ({row['zip']})
\n", " ■ {row['investment_category']}
\n", " Home Value: ${row['current_home_value']:,.0f}
\n", " 10-Yr Appreciation: {row['appreciation_10yr']*100:.1f}%
\n", " Wildfire Risk: {row['wildfire_pct']:.1f}%
\n", " Investment Score: {row['investment_score']:.2f}\n", " \"\"\"\n", " folium.Marker(\n", " location=[centroid.y, centroid.x],\n", " icon=folium.DivIcon(html=pin_html, icon_size=(34, 34), icon_anchor=(17, 30)),\n", " popup=folium.Popup(popup_html, max_width=250),\n", " tooltip=f\"#{row['rank']}: {row['City']} \u2014 Investment Score {row['investment_score']:.2f}\",\n", " ).add_to(dyn_map)\n", "\n", "# --- Masthead ---\n", "masthead_html = '''\n", "
\n", "
FOCUS  |  REAL ESTATE INVESTMENT
\n", "
\n", " LA Wildfire Risk & Investment Explorer\n", "
\n", "
\n", " Built for investors and homebuyers weighing whether a ZIP code's price growth justifies its wildfire exposure.\n", " Toggle layers to compare risk, income, home value, appreciation, and our Investment Opportunity Score\n", " (ZIP percentile rank on 10-yr appreciation, minus ZIP percentile rank on wildfire exposure, weighted equally).\n", " The Investment Opportunity Score is based on wildfire risk, historical appreciation, and housing market\n", " characteristics. It is intended as a screening tool for further due diligence, not a definitive investment\n", " recommendation. Numbered pins mark the 8 highest-scoring ZIPs; toggle on Historical Major Fires (cream circles,\n", " sized by acres burned) to see real burn history, or Wildfire Exposure for background context.\n", "
\n", "
\n", "'''\n", "dyn_map.get_root().html.add_child(folium.Element(masthead_html))\n", "dyn_map.get_root().html.add_child(folium.Element(\n", " \"\"\n", "))\n", "\n", "# --- Fixed legend for the investment category layer ---\n", "legend_html = '''\n", "
\n", "Investment Category
\n", "■ Opportunity Zone (high risk, high appreciation)
\n", "■ Red Flag (high risk, low appreciation)
\n", "■ Safe Bet (low risk, high appreciation)
\n", "■ Low Priority (low risk, low appreciation)
\n", "(only shown when that layer is active)\n", "
\n", "\n", "Historical major fire (size = acres burned)\n", "
\n", "'''\n", "dyn_map.get_root().html.add_child(folium.Element(legend_html))\n", "\n", "folium.LayerControl(collapsed=False).add_to(dyn_map)\n", "\n", "# Enforce \"only one metric/category visible at a time\" via JS instead of Folium's base-layer\n", "# mechanism (base layers are mutually exclusive with EACH OTHER *and* the tile layer, which was\n", "# silently hiding the street basemap every time a different metric was picked).\n", "exclusive_js = f\"\"\"\n", "\n", "\"\"\"\n", "dyn_map.get_root().html.add_child(folium.Element(exclusive_js))\n", "\n", "dyn_map.save(\"la_wildfire_dynamic_map.html\")\n", "print(\"Saved to la_wildfire_dynamic_map.html \u2014 open it directly in your browser\")\n", "dyn_map" ], "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": [ "What's informative about this view: Framing this around an actual real-estate decision changes what the map is for \u2014 instead of asking a user to compare five layers and draw their own conclusion, the Investment Score and numbered pins hand them a ranked, actionable answer. The 8 \"Top Picks\" are all ZIPs where 10-year appreciation outpaced the wildfire-exposure penalty, which is a very different (and more useful) framing than just \"here's the wildfire map\" and \"here's the price map\" side by side. Toggling on the historical fire bubbles over a Top Pick is a genuinely useful gut-check: several of the highest-scoring ZIPs sit right next to real Woolsey/Palisades fire locations, which the score alone doesn't show.\n", "\n", "What could be improved: We originally used appreciation \u00f7 (1 + wildfire%), but that divisor only ranges from 1 to ~1.94 across our data while appreciation itself spans a much wider range, so growth alone explained 96% of the variance in the score and wildfire risk barely mattered (confirmed when a reviewer noticed a ZIP's score matched its raw appreciation number exactly). We replaced it with an equal-weighted percentile-rank formula instead, which gives both components comparable influence by construction \u2014 but the 50/50 weighting is still a judgment call, not validated against how real investors or lenders actually price wildfire risk, so it should be presented as illustrative, not authoritative, in the live demo. It also still can't speak to insurance at all: a ZIP could rank as a \"Top Pick\" on this score while quietly losing FAIR Plan coverage, which would matter enormously to a real buyer and isn't visible anywhere in this notebook yet. Sourcing and joining that data, and testing whether it changes the ranking, is the clearest next step before treating this as a real product rather than a class project prototype." ], "metadata": {} }, { "cell_type": "markdown", "source": "Editorial Bubble Map: Pinpointing Fire Exposure\n\nEvery map so far treats wildfire risk as a ZIP-level attribute. This view shifts the unit of analysis to the fire *events* themselves, styled after data-journalism graphics like the LA Times' \"Pinpointing Pollution.\" It layers three things on a real street basemap: a pale dot texture showing modeled wildfire hazard exposure as background context (same hex-grid technique as the bivariate map earlier), a bubble for every major historical fire (\u22653,000 acres) sized by acres burned, and color grouped by the decade it occurred. This makes two things visible at once that no earlier chart showed together: which parts of the county the *real* historical fires actually hit, and whether large fires have been getting more frequent in recent decades.", "metadata": {} }, { "cell_type": "code", "source": [ "import contextily as cx\n", "from matplotlib.patches import Rectangle\n", "\n", "map_df_3857 = map_df.to_crs(epsg=3857)\n", "\n", "# Reuse the same fire perimeters already loaded above, just re-filter for \"major\" fires (>=3,000 acres)\n", "bubble_fires = gpd.read_file(FIRE_CACHE, bbox=bbox).to_crs(epsg=3857)\n", "bubble_fires = bubble_fires[bubble_fires['GIS_ACRES'] >= 3000].copy()\n", "bubble_fires = bubble_fires.dropna(subset=['DECADES'])\n", "bubble_fires['centroid'] = bubble_fires.geometry.centroid\n", "print(f\"Major fires (>=3,000 acres): {len(bubble_fires)}\")\n", "\n", "# --- Background texture: same hex-grid technique as the bivariate map, muted single hue ---\n", "minx0, miny0, maxx0, maxy0 = map_df_3857.total_bounds\n", "dxb = 1900\n", "dyb = dxb * np.sqrt(3) / 2\n", "xsb = np.arange(minx0 - dxb, maxx0 + dxb, dxb)\n", "centers_b = []\n", "row_b = 0\n", "yb = miny0 - dyb\n", "while yb < maxy0 + dyb:\n", " x_offset = (dxb / 2) if (row_b % 2) else 0\n", " for xb in xsb + x_offset:\n", " centers_b.append((xb, yb))\n", " yb += dyb\n", " row_b += 1\n", "pts_b = gpd.GeoDataFrame(geometry=[Point(c) for c in centers_b], crs='EPSG:3857')\n", "texture_pts = gpd.sjoin(pts_b, map_df_3857[['zip', 'wildfire_pct', 'geometry']], how='inner', predicate='within')\n", "print(f\"Texture points: {len(texture_pts)}\")\n", "\n", "# --- Tighter bounds based on where the content actually is, with extra room at top for the masthead ---\n", "allx = np.concatenate([texture_pts.geometry.x.values, bubble_fires['centroid'].x.values])\n", "ally = np.concatenate([texture_pts.geometry.y.values, bubble_fires['centroid'].y.values])\n", "bminx, bmaxx = allx.min(), allx.max()\n", "bminy, bmaxy = ally.min(), ally.max()\n", "padx = (bmaxx - bminx) * 0.04\n", "bminx, bmaxx = bminx - padx, bmaxx + padx\n", "bminy = bminy - (bmaxy - bminy) * 0.03\n", "bmaxy = bmaxy + (bmaxy - bminy) * 0.18\n", "\n", "fig, ax = plt.subplots(figsize=(9, 12.5), dpi=200)\n", "fig.patch.set_facecolor('white')\n", "\n", "texture_norm = Normalize(vmin=0, vmax=texture_pts['wildfire_pct'].quantile(0.95))\n", "tex_sizes = 2 + texture_norm(texture_pts['wildfire_pct'].clip(upper=texture_pts['wildfire_pct'].quantile(0.95))) * 10\n", "ax.scatter(texture_pts.geometry.x, texture_pts.geometry.y, s=tex_sizes, color='#8fa8c9', alpha=0.35, linewidths=0, zorder=2)\n", "\n", "ax.set_xlim(bminx, bmaxx)\n", "ax.set_ylim(bminy, bmaxy)\n", "ax.set_aspect('equal')\n", "ax.axis('off')\n", "\n", "cx.add_basemap(ax, source=cx.providers.CartoDB.Positron, zorder=1)\n", "\n", "# White masthead panel so the headline/subtitle never overlaps basemap labels underneath\n", "masthead_h = (bmaxy - bminy) * 0.16\n", "ax.add_patch(Rectangle((bminx, bmaxy - masthead_h), bmaxx - bminx, masthead_h,\n", " facecolor='white', edgecolor='none', zorder=3))\n", "\n", "# --- Foreground bubbles: one per major fire, size = acres burned, color = decade ---\n", "decade_order = sorted(bubble_fires['DECADES'].unique())\n", "decade_cmap = LinearSegmentedColormap.from_list(\n", " 'decade', ['#f7ecd1', '#f7c948', '#f2822a', '#e8412c', '#c31e5c', '#5b1a8c']\n", ")\n", "decade_norm = Normalize(vmin=0, vmax=len(decade_order) - 1)\n", "decade_to_color = {d: decade_cmap(decade_norm(i)) for i, d in enumerate(decade_order)}\n", "\n", "bubble_sizes = np.sqrt(bubble_fires['GIS_ACRES']) * 1.1\n", "ax.scatter(\n", " bubble_fires['centroid'].x, bubble_fires['centroid'].y, s=bubble_sizes,\n", " color=[decade_to_color[d] for d in bubble_fires['DECADES']],\n", " alpha=0.8, edgecolors='white', linewidths=0.6, zorder=4,\n", ")\n", "\n", "# --- Masthead text ---\n", "title_y = bmaxy - (bmaxy - bminy) * 0.015\n", "ax.text(bminx + padx * 0.3, title_y, 'FOCUS | WILDFIRE RISK', fontsize=10.5, color='#b3401f',\n", " fontweight='bold', va='top', family='sans-serif', zorder=5)\n", "ax.text(bminx + padx * 0.3, title_y - (bmaxy - bminy) * 0.028, 'PINPOINTING FIRE EXPOSURE',\n", " fontsize=23, color='#1a1a1a', fontweight='bold', va='top', family='serif', zorder=5)\n", "subtitle = (\"Every circle is a major wildfire (\u2265 3,000 acres) that has burned in or near\\n\"\n", " \"LA County since 1950. Size shows acres burned; color shows the decade it occurred.\\n\"\n", " \"The pale dot texture shows modeled wildfire hazard exposure by ZIP code.\")\n", "ax.text(bminx + padx * 0.3, title_y - (bmaxy - bminy) * 0.075, subtitle, fontsize=9.3,\n", " color='#4a4a4a', va='top', family='sans-serif', linespacing=1.5, zorder=5)\n", "\n", "# --- Legends ---\n", "leg_y = bminy + (bmaxy - bminy) * 0.045\n", "leg_x0 = bminx + padx * 0.3\n", "for i, d in enumerate(decade_order):\n", " lx = leg_x0 + i * (bmaxx - bminx) * 0.083\n", " ax.scatter([lx], [leg_y], s=90, color=decade_to_color[d], edgecolors='white', linewidths=0.6, zorder=5)\n", " ax.text(lx, leg_y - (bmaxy - bminy) * 0.02, d, fontsize=6.3, color='#4a4a4a', ha='center', va='top')\n", "\n", "size_leg_x = bminx + (bmaxx - bminx) * 0.80\n", "for acres, dy_off in [(5000, 0), (20000, 0.028), (80000, 0.062)]:\n", " r = np.sqrt(acres) * 1.1\n", " ax.scatter([size_leg_x], [leg_y + (bmaxy - bminy) * dy_off - (bmaxy - bminy) * 0.02],\n", " s=r, facecolor='none', edgecolor='#4a4a4a', linewidths=0.8, zorder=5)\n", "ax.text(size_leg_x + (bmaxx - bminx) * 0.03, leg_y + (bmaxy - bminy) * 0.075,\n", " 'Circle size =\\nacres burned', fontsize=6.8, color='#4a4a4a', va='top')\n", "\n", "plt.savefig(\"la_wildfire_bubble_map.png\", bbox_inches='tight', facecolor='white')\n", "plt.show()" ], "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": "What's informative about this view: Putting real fires on a real street basemap makes the risk tangible in a way a choropleth can't \u2014 you can see individual large fires sitting right up against named cities and neighborhoods (Santa Clarita, Simi Valley, Thousand Oaks) rather than abstract ZIP polygons. The decade coloring also surfaces a pattern the rest of the notebook doesn't show explicitly: large (\u22653,000 acre) fires cluster heavily in the most recent two decades (2010s\u20132020s, the purple/magenta bubbles) along the same western and northern edges where our wildfire exposure and home-value layers already showed the highest overlap.\n\nWhat could be improved: The 3,000-acre cutoff is somewhat arbitrary \u2014 chosen to keep the bubble count legible rather than derived from any threshold in the data. Bubble maps also suffer from overplotting in dense fire zones (e.g., the Santa Clarita/Angeles National Forest cluster), where overlapping circles make it hard to judge individual fire size. A version with slight jitter or a \"top 20 fires\" filter (labeled by name, like Woolsey and Palisades) would likely communicate the story faster in a live 15-minute presentation than the full 234-fire view.", "metadata": {} }, { "cell_type": "markdown", "source": "Interactive Version: The Same Bubble Map, But Live\n\nThe static version above is presentation-ready as a single image, but it can't be explored \u2014 you can't zoom into the Santa Clarita fire cluster, hover a bubble to check which fire it actually is, or turn off the texture layer to declutter. This is the same masthead/texture/bubble concept rebuilt in Folium: real map tiles instead of a static basemap image, independently toggleable texture and fire layers, and hover tooltips (fire name, year, acres) on every bubble.", "metadata": {} }, { "cell_type": "code", "source": [ "interactive_fires = gpd.read_file(FIRE_CACHE, bbox=bbox).to_crs(epsg=4326)\n", "interactive_fires = interactive_fires[interactive_fires['GIS_ACRES'] >= 3000].copy()\n", "interactive_fires = interactive_fires.dropna(subset=['DECADES'])\n", "interactive_fires['acres_fmt'] = interactive_fires['GIS_ACRES'].round(0).astype(int)\n", "interactive_fires['YEAR_'] = interactive_fires['YEAR_'].astype('Int64')\n", "interactive_fires['lat'] = interactive_fires.geometry.centroid.y\n", "interactive_fires['lon'] = interactive_fires.geometry.centroid.x\n", "print(f\"Major fires (interactive): {len(interactive_fires)}\")\n", "\n", "# --- Background texture (hex-grid points, muted) ---\n", "map_df_3310 = map_df.to_crs(epsg=3310)\n", "minx, miny, maxx, maxy = map_df_3310.total_bounds\n", "dxi = 2400\n", "dyi = dxi * np.sqrt(3) / 2\n", "xsi = np.arange(minx - dxi, maxx + dxi, dxi)\n", "centers_i = []\n", "row_i2 = 0\n", "yi = miny - dyi\n", "while yi < maxy + dyi:\n", " x_offset = (dxi / 2) if (row_i2 % 2) else 0\n", " for xi in xsi + x_offset:\n", " centers_i.append((xi, yi))\n", " yi += dyi\n", " row_i2 += 1\n", "pts_i = gpd.GeoDataFrame(geometry=[Point(c) for c in centers_i], crs='EPSG:3310')\n", "joined_i = gpd.sjoin(pts_i, map_df_3310[['zip', 'wildfire_pct', 'geometry']], how='inner', predicate='within')\n", "tex_pts = joined_i.set_geometry('geometry').set_crs('EPSG:3310').to_crs(epsg=4326)\n", "tex_pts['lat'] = tex_pts.geometry.y\n", "tex_pts['lon'] = tex_pts.geometry.x\n", "print(f\"Texture points (interactive): {len(tex_pts)}\")\n", "\n", "bubble_map = folium.Map(location=[34.05, -118.25], zoom_start=9, tiles=None, prefer_canvas=True)\n", "folium.TileLayer(\n", " tiles='cartodbpositron', name='Streets Basemap (CARTO)',\n", " attr='Basemap by CARTO, data by OpenStreetMap contributors', control=True,\n", ").add_to(bubble_map)\n", "\n", "texture_layer = folium.FeatureGroup(name='Wildfire Exposure (background texture)', show=True, overlay=True)\n", "tex_norm_i = Normalize(vmin=0, vmax=tex_pts['wildfire_pct'].quantile(0.95))\n", "for _, r in tex_pts.iterrows():\n", " v = min(r['wildfire_pct'], tex_pts['wildfire_pct'].quantile(0.95))\n", " radius = 1.2 + tex_norm_i(v) * 3.5\n", " folium.CircleMarker(\n", " location=(r['lat'], r['lon']), radius=radius, color=None, fill=True,\n", " fill_color='#5b7ca8', fill_opacity=0.35, weight=0,\n", " ).add_to(texture_layer)\n", "texture_layer.add_to(bubble_map)\n", "\n", "fires_layer = folium.FeatureGroup(name='Major Fires, 1950-2025 (>=3,000 acres)', show=True, overlay=True)\n", "for _, r in interactive_fires.iterrows():\n", " radius = max(3, np.sqrt(r['GIS_ACRES']) * 0.055)\n", " popup_html = f\"{r['FIRE_NAME']} ({int(r['YEAR_'])})
{r['acres_fmt']:,} acres burned\"\n", " folium.CircleMarker(\n", " location=(r['lat'], r['lon']), radius=radius,\n", " color='#b3401f', weight=1,\n", " fill=True, fill_color='#f2d9a1', fill_opacity=0.85,\n", " popup=folium.Popup(popup_html, max_width=220),\n", " tooltip=f\"{r['FIRE_NAME']} ({int(r['YEAR_'])}) \u2014 {r['acres_fmt']:,} acres\",\n", " ).add_to(fires_layer)\n", "fires_layer.add_to(bubble_map)\n", "\n", "# --- Masthead banner (fixed HTML overlay, mirrors the static version's title block) ---\n", "masthead_html = '''\n", "
\n", "
FOCUS  |  WILDFIRE RISK
\n", "
\n", " Pinpointing Fire Exposure\n", "
\n", "
\n", " Every circle is a major wildfire (≥ 3,000 acres) that has burned in or near LA County since 1950.\n", " Size shows acres burned. Toggle the wildfire-exposure texture and fire layers independently,\n", " and hover any fire for its name, year, and acreage.\n", "
\n", "
\n", "'''\n", "bubble_map.get_root().html.add_child(folium.Element(masthead_html))\n", "bubble_map.get_root().html.add_child(folium.Element(\n", " \"\"\n", "))\n", "\n", "# --- Legend (fixed corner) ---\n", "legend_html = '''\n", "
\n", "\n", "Historical major fire (size = acres burned)
\n", "\n", "Wildfire exposure (background texture)\n", "
\n", "'''\n", "bubble_map.get_root().html.add_child(folium.Element(legend_html))\n", "\n", "folium.LayerControl(collapsed=False).add_to(bubble_map)\n", "bubble_map.save(\"la_wildfire_bubble_map.html\")\n", "print(\"Saved to la_wildfire_bubble_map.html \u2014 open it directly in your browser\")\n", "bubble_map" ], "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "source": "What's informative about this view: This is the closest thing in the whole notebook to a finished \"product\" rather than an analysis chart \u2014 a masthead, a legend, and two independently toggleable layers a user can actually manipulate live during the presentation. Turning off the texture layer and zooming into the Woolsey/Palisades area makes the individual fire bubbles and their popups easy to read without the background competing for attention, which the static PNG can't do.\n\nWhat could be improved: Rendering ~1,974 texture points and 234 fire bubbles as individual Leaflet CircleMarkers makes this noticeably heavier than the earlier interactive maps (both in file size and browser responsiveness when panning). If this becomes the main demo piece, switching the texture layer to a single rasterized image overlay (rendered once with matplotlib, then placed with `folium.raster_layers.ImageOverlay`) would cut the marker count dramatically while keeping the same look.", "metadata": {} }, { "cell_type": "markdown", "metadata": {}, "source": [ "A More Professional Version: The Same Data, Dressed for a Real Estate Audience\n", "\n", "The dot-textured Investment Explorer above is visually distinctive, but it reads more like a data-journalism infographic than something a real estate firm would trust for underwriting-adjacent decisions. This version keeps the exact same underlying data and layers (wildfire risk, income, home value, appreciation, Investment Score, Investment Category, top picks) but restyles it for that audience:\n", "\n", "- **Smooth ZIP-boundary choropleth fills** instead of a hex-dot texture \u2014 the boundary-shading style used by Zillow, Redfin, and Realtor.com, rather than a grid of individual dots.\n", "- **A restrained, \"trustworthy\" palette** \u2014 muted blues for value/growth metrics, a desaturated red for risk, and navy/teal/burgundy/gray (instead of cream/orange/purple/red) for the Investment Category, closer to what a financial dashboard would use than an editorial chart.\n", "- **Cleaner typography and markers** \u2014 a sans-serif masthead instead of a serif headline, and simple navy-and-gold numbered circles for the Top Picks instead of teardrop map pins.\n", "- The same JS-based layer-exclusivity fix from the Investment Explorer, applied from the start this time, so the street basemap never disappears when switching layers.\n", "\n- **Usability-driven polish, based on our own usability study**: a single legend panel that now updates automatically to match whichever layer is active (previously only the first layer had a visible color scale); an \u24d8 info toggle next to \"Investment Opportunity Score\" that expands a plain-language explanation of the formula in place, since testers could see the number but not explain how it was derived; a more prominent, labeled layer control (\"MAP LAYERS \u2014 TOGGLE TO EXPLORE\") since some testers missed it at its original size; a ZIP/neighborhood search box (top right); and click-to-open popups on the ZIP polygons themselves, not just the numbered pins, since testers expected the polygons to be directly interactive.\n", "\n- **Usability-driven polish, based on our own usability study**: a single legend panel that now updates automatically to match whichever layer is active (previously only the first layer had a visible color scale); an \u24d8 info toggle next to \"Investment Opportunity Score\" that expands a plain-language explanation of the formula in place, since testers could see the number but not explain how it was derived; a more prominent, labeled layer control (\"MAP LAYERS \u2014 TOGGLE TO EXPLORE\") since some testers missed it at its original size; a ZIP/neighborhood search box (top right); and click-to-open popups on the ZIP polygons themselves, not just the numbered pins, since testers expected the polygons to be directly interactive." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import json as _json\n", "import numpy as np\n", "import matplotlib as mpl\n", "from folium.features import GeoJsonPopup\n", "from folium.plugins import Search\n", "\n", "# Soft/pastel palette, chosen to read as light and approachable rather than \"financial\n", "# terminal,\" while still reusing the same color logic as the continuous layers: blue tones\n", "# for the good outcomes (matches the Blues palette used for value/growth), coral/red for the\n", "# bad outcome (matches the Reds palette used for wildfire risk), and a lavender blend of the\n", "# two for the \"mixed\" quadrant (real risk, but real growth too) \u2014 so the mapping stays\n", "# intuitive even for someone who never opens the legend.\n", "category_colors_pro = {\n", " 'Opportunity Zone (high risk, high appreciation)': '#B9A8DC',\n", " 'Red Flag (high risk, low appreciation)': '#F0A8A0',\n", " 'Safe Bet (low risk, high appreciation)': '#A8CDEB',\n", " 'Low Priority (low risk, low appreciation)': '#DAD6C8',\n", "}\n", "\n", "# Plain-language labels shown to users (legend, popups) \u2014 the dict keys above stay as the\n", "# original technical strings since those are what's actually stored in map_df/GeoJson\n", "# properties and used for style lookups; only the *display* text changes.\n", "category_display = {\n", " 'Opportunity Zone (high risk, high appreciation)': 'Opportunity Zone \u2014 risky, but growing fast',\n", " 'Red Flag (high risk, low appreciation)': 'Red Flag \u2014 risky, without the growth',\n", " 'Safe Bet (low risk, high appreciation)': 'Safe Bet \u2014 low risk, growing fast',\n", " 'Low Priority (low risk, low appreciation)': 'Low Priority \u2014 low risk, slow growth',\n", "}\n", "\n", "pro_map = folium.Map(location=[34.05, -118.25], zoom_start=9, tiles=None, prefer_canvas=True)\n", "folium.TileLayer(\n", " tiles='cartodbpositron', name='Streets Basemap (CARTO)',\n", " attr='Basemap by CARTO, data by OpenStreetMap contributors', control=True,\n", ").add_to(pro_map)\n", "\n", "# --- Typography: Cormorant Garamond, a delicate high-contrast serif in the same family used\n", "# by fashion/editorial luxury brands, for headlines and numerals \u2014 set at 600/700 weight since\n", "# its thin default weight gets lost at small sizes \u2014 alongside a clean Inter for body copy and\n", "# Leaflet's own UI chrome, in place of the plain system 'Helvetica Neue' stack used before. ---\n", "pro_map.get_root().html.add_child(folium.Element(\n", " \"\"\n", " \"\"\n", " \"\"\n", "))\n", "pro_map.get_root().html.add_child(folium.Element(\n", " \"\"\n", "))\n", "\n", "# --- Metric definitions: (column, label, color palette, value formatter for the legend) ---\n", "def _fmt_currency(v): return f\"${v:,.0f}\"\n", "def _fmt_percent0(v): return f\"{v:.0f}%\"\n", "def _fmt_appreciation(v): return f\"{v * 100:.0f}%\"\n", "def _fmt_score(v): return f\"{v:.0f}\"\n", "\n", "metrics_pro = [\n", " ('wildfire_pct', 'Wildfire Risk', 'Reds', _fmt_percent0),\n", " ('median_household_income', 'Household Income', 'Blues', _fmt_currency),\n", " ('current_home_value', 'Home Value', 'Blues', _fmt_currency),\n", " ('appreciation_10yr', 'Price Growth (Last 10 Years)', 'Blues', _fmt_appreciation),\n", " ('investment_score', 'Investment Score', 'PuBu', _fmt_score),\n", "]\n", "\n", "exclusive_names = []\n", "for i, (col, label, palette, fmt) in enumerate(metrics_pro):\n", " choropleth = folium.Choropleth(\n", " geo_data=map_df,\n", " data=map_df,\n", " columns=['zip', col],\n", " key_on='feature.properties.zip',\n", " fill_color=palette,\n", " fill_opacity=0.82,\n", " line_color='#5b6672',\n", " line_weight=0.6,\n", " line_opacity=0.5,\n", " legend_name=label,\n", " name=label,\n", " show=(i == 0),\n", " overlay=True,\n", " nan_fill_color='#e5e2da',\n", " ).add_to(pro_map)\n", " exclusive_names.append(choropleth.get_name())\n", " # Folium's built-in colorbar legend isn't tied to the layer's own show/hide toggle \u2014 it's\n", " # always rendered regardless of which overlay is checked, so with 5 metrics all 5 default\n", " # legends would stack on top of each other. Strip all of them and drive a single custom\n", " # legend panel from JS instead (see legend_configs / #dashLegend below), which *does* update\n", " # with whichever layer is actually active.\n", " for key in list(choropleth._children):\n", " if key.startswith('color_map'):\n", " del choropleth._children[key]\n", "\n", "# Investment Type as a smooth polygon fill, not a dot texture. Pastel fills read lighter than\n", "# the continuous-metric choropleths, so they get a touch more opacity (0.88 vs 0.82) to stay\n", "# visible against the basemap rather than washing out.\n", "cat_layer = folium.GeoJson(\n", " map_df,\n", " style_function=lambda f: {\n", " 'fillColor': category_colors_pro[f['properties']['investment_category']],\n", " 'fillOpacity': 0.88,\n", " 'color': '#8b8f99',\n", " 'weight': 0.6,\n", " },\n", " name='Investment Type',\n", " show=False,\n", " overlay=True,\n", ").add_to(pro_map)\n", "exclusive_names.append(cat_layer.get_name())\n", "\n", "# --- Custom legend content, one entry per exclusive layer above (same order) ---\n", "def _gradient_css(palette, n=6):\n", " cmap = mpl.colormaps[palette]\n", " stops = [mpl.colors.to_hex(cmap(t)) for t in np.linspace(0.08, 0.92, n)]\n", " return 'linear-gradient(to right, ' + ', '.join(stops) + ')'\n", "\n", "legend_configs = []\n", "for col, label, palette, fmt in metrics_pro:\n", " legend_configs.append({\n", " 'type': 'gradient',\n", " 'title': label,\n", " 'gradient': _gradient_css(palette),\n", " 'min': fmt(map_df[col].min()),\n", " 'max': fmt(map_df[col].max()),\n", " })\n", "legend_configs.append({\n", " 'type': 'category',\n", " 'title': 'Investment Type',\n", " 'swatches': [{'color': c, 'label': category_display[k]} for k, c in category_colors_pro.items()],\n", "})\n", "\n", "# --- Search field: lets the search box below match on either ZIP or city/neighborhood name ---\n", "map_df['search_key'] = map_df['zip'].astype(str) + ' \u2014 ' + map_df['City'].astype(str)\n", "# Plain-language, pre-formatted versions of the raw numbers for the tooltip/popup \u2014 a whole\n", "# number (\"14\") reads easier than a decimal score, and a \"%\"-suffixed string reads easier\n", "# than a bare fraction like \"0.234\" (which doesn't mean anything at a glance without already\n", "# knowing the column is a fraction).\n", "map_df['investment_score_fmt'] = map_df['investment_score'].round(0).astype(int)\n", "map_df['appreciation_10yr_fmt'] = (map_df['appreciation_10yr'] * 100).round(1).astype(str) + '%'\n", "map_df['investment_category_display'] = map_df['investment_category'].map(category_display)\n", "\n", "# Hover tooltip + click popup: property-card style, same info both ways so the map works\n", "# the same whether a user hovers (desktop) or taps (mobile/touch).\n", "zip_info_layer = folium.GeoJson(\n", " map_df,\n", " style_function=lambda x: {'fillOpacity': 0, 'color': 'transparent'},\n", " tooltip=GeoJsonTooltip(\n", " fields=['zip', 'City', 'investment_category_display', 'current_home_value',\n", " 'appreciation_10yr_fmt', 'wildfire_pct', 'investment_score_fmt'],\n", " aliases=['ZIP:', 'Neighborhood:', 'Investment Type:', 'Home Value ($):',\n", " 'Price Growth (10 yrs):', 'Wildfire Risk (%):', 'Investment Score:'],\n", " localize=True,\n", " ),\n", " popup=GeoJsonPopup(\n", " fields=['zip', 'City', 'investment_category_display', 'current_home_value',\n", " 'appreciation_10yr_fmt', 'wildfire_pct', 'investment_score_fmt'],\n", " aliases=['ZIP:', 'Neighborhood:', 'Investment Type:', 'Home Value ($):',\n", " 'Price Growth (10 yrs):', 'Wildfire Risk (%):', 'Investment Score:'],\n", " localize=True,\n", " ),\n", " name='ZIP details (hover / click)',\n", " overlay=True,\n", " control=True,\n", ").add_to(pro_map)\n", "\n", "# ZIP / neighborhood search box, top-right \u2014 matches on ZIP digits or city name\n", "Search(\n", " layer=zip_info_layer,\n", " search_label='search_key',\n", " placeholder='Search ZIP or neighborhood\u2026',\n", " collapsed=False,\n", " position='topright',\n", ").add_to(pro_map)\n", "\n", "# City labels\n", "city_labels = {\n", " 'Malibu': (-118.7798, 34.0259), 'Santa Monica': (-118.4912, 34.0195),\n", " 'Downtown LA': (-118.2437, 34.0407), 'Pasadena': (-118.1445, 34.1478),\n", " 'Long Beach': (-118.1937, 33.7701), 'Santa Clarita': (-118.5426, 34.3917),\n", "}\n", "for name, (lon, lat) in city_labels.items():\n", " folium.Marker(\n", " location=(lat, lon),\n", " icon=folium.DivIcon(html=f'
{name}
'),\n", " ).add_to(pro_map)\n", "\n", "# Top Investment Picks: flat pastel-green numbered badges \u2014 same lightness/saturation family\n", "# as the 4 Investment Type swatches (lavender/coral/sky-blue/sand) rather than the earlier\n", "# saturated gold or jewel-toned emerald, so the pins read as part of the same soft palette\n", "# instead of a separate \"special\" material. A true, less blue-leaning pastel green doesn't collide with any hue used\n", "# elsewhere (Reds/Blues/PuBu metric palettes, or the lavender/coral/sky-blue/sand category\n", "# colors), and the ivory-then-navy double ring \u2014 the same contrast trick as before \u2014 keeps it\n", "# legible against every fill color underneath even at this lighter, flatter weight.\n", "#\n", "# The numeral itself uses Inter, not Cormorant Garamond: tested up close, Cormorant's \"1\" is\n", "# just a bare vertical stroke with no serif, easy to misread as a capital \"I\" in a small badge\n", "# with no other context (the \"#1\" in the popup below is fine since the \"#\" disambiguates it,\n", "# but a lone digit in a circle needs a face with unambiguous digit shapes).\n", "top_picks = map_df.sort_values('investment_score', ascending=False).head(8).reset_index(drop=True)\n", "top_picks['rank'] = top_picks.index + 1\n", "for _, row in top_picks.iterrows():\n", " centroid = row.geometry.centroid\n", " pin_html = f\"\"\"\n", "
\n", " {row['rank']}\n", "
\n", " \"\"\"\n", " popup_html = f\"\"\"\n", "
\n", "
\n", " #{row['rank']} \u2014 {row['City']} ({row['zip']})\n", "
\n", " ■ {category_display[row['investment_category']]}
\n", " Home Value: ${row['current_home_value']:,.0f}
\n", " Price Growth (10 yrs): {row['appreciation_10yr'] * 100:.1f}%
\n", " Wildfire Risk: {row['wildfire_pct']:.1f}%
\n", " Investment Score: {row['investment_score']:.0f}\n", "
\n", " \"\"\"\n", " folium.Marker(\n", " location=[centroid.y, centroid.x],\n", " icon=folium.DivIcon(html=pin_html, icon_size=(29, 29), icon_anchor=(14.5, 14.5)),\n", " popup=folium.Popup(popup_html, max_width=250),\n", " tooltip=f\"#{row['rank']}: {row['City']} \u2014 Investment Score {row['investment_score']:.0f}\",\n", " ).add_to(pro_map)\n", "\n", "# --- Masthead: clean sans-serif, corporate palette, with a plain-language score explainer ---\n", "# Usability testing found users could see the Investment Score number but not explain how it\n", "# was derived (multiple participants asked \"how exactly is this calculated?\"), and more broadly\n", "# that the whole dashboard read as too technical for someone who isn't already fluent in real\n", "# estate/finance terms (\"10-year price appreciation,\" \"percentile,\" etc.). This rewrite drops\n", "# jargon wherever the underlying idea can be said in plain English, and the \u24d8 toggle explains\n", "# the score in one click, right where it's first mentioned, instead of a dense paragraph nobody\n", "# reads to the end.\n", "#\n", "# The score itself is a simplified version of \"risk-adjusted return,\" the same idea real estate\n", "# investors use to compare a property's likely payoff against how risky it is (institutional\n", "# versions typically divide return by volatility/a Sharpe-ratio-style denominator, using data\n", "# like vacancy rates and loan coverage ratios we don't have here). We approximate it by ranking\n", "# each ZIP's price growth and wildfire risk against every other ZIP, then taking growth-rank\n", "# minus risk-rank \u2014 same spirit (reward relative to risk), simplified to data we can actually\n", "# verify, and described honestly below as a screening tool rather than a professional appraisal.\n", "masthead_html = '''\n", "
\n", "
LA COUNTY MARKET INTELLIGENCE
\n", "
\n", " Wildfire Risk & Investment Dashboard\n", "
\n", "
\n", " Which LA County ZIP codes are still worth the wildfire risk?\n", "
\n", "
\n", " Every ZIP code gets an Investment Score\n", " i\n", " that balances price growth against fire risk, so you can spot areas doing well despite the\n", " danger. The 8 pastel-green markers are our top picks. Search a ZIP or neighborhood above, or explore\n", " the layers below.\n", "
\n", " What is the Investment Score? Real estate investors often weigh a property's likely\n", " growth against how risky it is \u2014 this score uses that same idea, simplified. Every ZIP code\n", " is compared to every other ZIP code in two ways: how much home prices grew over the last 10\n", " years, and how much wildfire danger it has. A ZIP that grew a lot and has low fire\n", " risk scores highest; one that barely grew and has high fire risk scores lowest.\n", " It's a quick way to compare neighborhoods side by side \u2014 not a prediction, an appraisal, or\n", " financial advice. Always do your own research (and talk to a real estate professional)\n", " before making a decision.\n", "
\n", "
\n", "
\n", "'''\n", "pro_map.get_root().html.add_child(folium.Element(masthead_html))\n", "# Fallback margin (matches the masthead's typical desktop height) so the map isn't hidden\n", "# under the masthead for the instant before the JS below measures the real, rendered height \u2014\n", "# JS then overrides this to fit whatever height the masthead actually wraps to (mobile-width\n", "# screens wrap the description onto several more lines than desktop, so a fixed pixel value\n", "# either leaves a gap on desktop or overlaps the layer control on narrow phones).\n", "pro_map.get_root().html.add_child(folium.Element('''\n", "\n", "'''))\n", "\n", "# --- Legend: single panel, content swapped by JS to match whichever layer is active ---\n", "legend_html = '''\n", "
\n", "
\n", "'''\n", "pro_map.get_root().html.add_child(folium.Element(legend_html))\n", "\n", "# --- Layer control: same widget, made harder to miss (usability testing found some\n", "# participants overlooked it entirely at default size/contrast) ---\n", "layer_control_css = '''\n", "\n", "'''\n", "pro_map.get_root().html.add_child(folium.Element(layer_control_css))\n", "folium.LayerControl(collapsed=False).add_to(pro_map)\n", "\n", "# --- Wiring: layer exclusivity (only one metric/category visible at a time), legend swap,\n", "# and a \"MAP LAYERS\" header prepended to the layer control so it reads as an instruction,\n", "# not just an unlabeled checkbox list. ---\n", "main_js = f\"\"\"\n", "\n", "\"\"\"\n", "pro_map.get_root().html.add_child(folium.Element(main_js))\n", "\n", "pro_map.save(\"la_wildfire_professional_map.html\")\n", "print(\"Saved to la_wildfire_professional_map.html \u2014 open it directly in your browser\")\n", "pro_map" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "What's informative about this view: Side by side with the Investment Explorer, this makes the design tradeoff concrete rather than abstract \u2014 same data, same layers, same Investment Score and Top Picks logic, but a completely different read. The smooth choropleth immediately looks more like something from an MLS or a Zillow-style market report, and the muted navy/teal/burgundy category colors don't compete with each other the way the brighter palette did. For a presentation to an actual real estate audience, this is very likely the version to lead with; the hex-dot version is better suited to a design-process discussion of how we got there.\n", "\n", "What could be improved: Smooth choropleths hide the underlying spatial resolution \u2014 a whole ZIP code shares one fill color, which can visually overstate homogeneity within large or diverse ZIPs (exactly the \"West Hills labeled just Los Angeles\" problem from earlier, just less visible now that there's no dot texture to hint at internal variation). A hybrid \u2014 smooth fills as the default, with the hex-dot texture available as an optional \"detail\" overlay \u2014 would let a user zoom into a ZIP and see that finer structure on demand rather than losing it. The color choices here also reflect one team's sense of \"looks professional\"; the honest test would be showing both versions to an actual real estate audience and asking which they'd trust more." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Design Process & Change Log\n", "\n", "*Group 5 \u2014 Amin Sharifi, Kala Fejzo, Moreki Khobotlo*\n", "\n", "This section documents how this dashboard changed between our midterm submission and this final version, and why each change was made. The same write-up (plus a QA section on the bugs we found while testing) lives at [`design_process/DESIGN_CHANGELOG.md`](design_process/DESIGN_CHANGELOG.md); our usability study results are summarized in [`usability_study_summary.md`](usability_study_summary.md).\n", "\n", "### Midterm screenshots, for reference\n", "\n", "| Iteration 1 \u2014 static wildfire map (no investment framing yet) | Iteration 2 \u2014 hex-dot Investment Explorer (midterm) |\n", "|---|---|\n", "| ![Iteration 1](design_process/before_iteration1_static_wildfire_map.png) | ![Iteration 2](design_process/before_investment_explorer_hexdot.png) |\n", "\n", "We also prototyped a third, visually distinct direction in Observable (a recovery-timeline focused layout, \"Burning Value\") that was not carried forward \u2014 the risk/appreciation/investment-score framing of the Investment Explorer tested better with our target users and became the basis for everything downstream.\n", "\n", "### Current visualization, for reference\n", "\n", "| Desktop | Mobile |\n", "|---|---|\n", "| ![Final dashboard, desktop](design_process/after_dashboard_desktop.png) | ![Final dashboard, mobile](design_process/after_dashboard_mobile.png) |\n", "\n", "### Change log\n", "\n", "| # | Before | After | Why |\n", "|---|---|---|---|\n", "| 1 | Iteration 1 had no investment framing at all \u2014 just historical fire locations sized by acres burned, with no way to compare neighborhoods as opportunities | Added an **Investment Score** (price growth weighed against wildfire risk) and 8 ranked \"Top Picks\" markers | Stronger decision support \u2014 turns \"here's where fires happened\" into \"here's where you should actually look first\" |\n", "| 2 | Hex-dot texture map, bright editorial palette (cream/purple/orange/red) \u2014 reads like a data-journalism graphic | Smooth ZIP-boundary choropleth, closer to a Zillow/Redfin-style market map | More context and more trustworthy for the audience actually evaluating it \u2014 several usability testers described it in exactly those terms (\"something Zillow or Redfin could build\") |\n", "| 3 | Color legend was only ever correct for the default layer (Wildfire Risk); switching to any other metric left a stale or missing legend \u2014 confirmed as a real bug present since the midterm build, not something introduced later | A single legend panel that re-renders to match whichever layer is actually active | Easier to interpret \u2014 a color scale that doesn't match what's on screen is worse than no legend, since it actively misleads instead of just being unhelpful |\n", "| 4 | Dense, technical copy throughout: \"10-Year Price Appreciation (Fraction),\" \"Investment Opportunity Score,\" percentile-rank formula spelled out in the header | Plain-language labels (\"Price Growth,\" \"Investment Score\") and a one-click \u24d8 explainer using everyday language | Directly answers our usability study's #1 finding \u2014 testers could see the score but not explain how it was derived, and said the dashboard needed to work \"without me explaining it\" |\n", "| 5 | Dark, saturated \"financial terminal\" palette (navy/teal/burgundy/gray) for the Investment Category layer | Soft pastel palette (lavender/coral/sky-blue/sand) plus a matching pastel-green for the numbered pins | More approachable and friendlier on first impression, without losing legibility |\n", "| 6 | No way to search for a specific ZIP; only the 8 numbered pins were clickable, ZIP polygons responded to hover only | Added a ZIP/neighborhood search box, made ZIP polygons clickable (not just the pins), and enlarged/labeled the layer control | Better exploration \u2014 testers specifically asked for a search bar and expected to be able to click directly on ZIP polygons |\n", "| 7 | Default system sans-serif (Helvetica Neue) throughout, same as any generic web page | A serif/sans pairing (Cormorant Garamond for headlines and legend titles, Inter for body copy and data) | Gives the dashboard a more polished, boutique real-estate feel instead of reading as a generic corporate report |\n", "| 8 | Dashboard presented six togglable layers with no framing \u2014 left it to the user to figure out what question they were supposed to be answering | Masthead now opens with one explicit question (\"Which LA County ZIP codes are still worth the wildfire risk?\") that every layer exists to help answer | Gives the visualization a single clear narrative instead of open-ended, undirected exploration |" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Usability Study Summary\n", "\n", "*Group 5 \u2014 Amin Sharifi, Kala Fejzo, Moreki Khobotlo*\n", "\n", "We ran a usability study on this dashboard covering three themes: Wildfire Risk Assessment, Housing Market Impact and Recovery, and Investment Decision Support. The full write-up (per-participant observations, comments, and a MoSCoW-prioritized list of findings) is at [`usability_study_summary.md`](usability_study_summary.md); a condensed one-slide version is at [`design_process/Usability_Study_Summary.pptx`](design_process/Usability_Study_Summary.pptx).\n", "\n", "### Participants\n", "\n", "**9 participants** tested the dashboard. Each self-selected one persona to role-play for their session, from: prospective homebuyer, homeowner, real estate investor, real estate professional, or policymaker/community planner.\n", "\n", "### Tasks\n", "\n", "- Identify the highest wildfire-risk ZIP\n", "- Compare wildfire risk: Pasadena vs. Santa Monica\n", "- Identify the highest-appreciation ZIP\n", "- Identify the fastest-recovering neighborhood\n", "- Explain what the Investment Score means\n", "- Recommend one ZIP for long-term investment\n", "\n", "### Main findings\n", "\n", "- **Misunderstood** how the Investment Score was calculated (Participants 1, 6)\n", "- **Overlooked** the layer controls at first (Participant 1)\n", "- **Expected** ZIP polygons themselves to be clickable, not just the numbered pins (Participant 9)\n", "- **Wanted** a search bar (Participant 3), plus more financial context in the popups \u2014 insurance costs, rental yield, historical appreciation, market inventory (Participants 2, 4, 8)\n", "- **Strength**: participants found the dashboard visually professional and easy to interpret \u2014 one described it as \"something Zillow or Redfin could build\" (Participant 3)\n", "\n", "Four of these findings (the \u24d8 score explainer, the more prominent layer control, ZIP-polygon click support, and the search bar) are already addressed in the current dashboard \u2014 see the [Design Process & Change Log](#Design-Process-&-Change-Log) above." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Explain Design Changes\n", "\n", "*For each usability finding: what did we change?*\n", "\n", "**Finding:** Users didn't understand how the Investment Score was calculated (Participants 1, 6).\n", "**Change:** Added a \u24d8 info icon next to \"Investment Score\" that expands a plain-language explanation panel in place \u2014 no formula, just what the score means and what it's based on.\n", "\n", "**Finding:** Some users overlooked the layer controls, taking up to ~20 seconds to find a layer (Participant 1).\n", "**Change:** Enlarged the layer control, gave it a visible border, and added a labeled header (\"MAP LAYERS \u2014 TOGGLE TO EXPLORE\") so it reads as an instruction, not just an unlabeled checkbox list.\n", "\n", "**Finding:** Users expected ZIP code polygons to be directly clickable, not just the numbered pins (Participant 9).\n", "**Change:** Added click-to-open popups on the ZIP polygons themselves, showing the same detail card as the pins.\n", "\n", "**Finding:** Users wanted a faster way to locate specific neighborhoods (Participant 3).\n", "**Change:** Added a ZIP/neighborhood search box that zooms straight to a match.\n", "\n", "**Finding:** Users wanted additional financial/neighborhood context \u2014 insurance costs, rental yield, historical appreciation trends, market inventory, recent sales (Participants 2, 4, 8).\n", "**Change:** Not implemented this pass \u2014 requires sourcing and cleaning new datasets (e.g., FAIR Plan insurance data, MLS inventory feeds) that are out of scope for this iteration. Flagged as the clearest next step.\n", "\n", "**Finding:** Less experienced users wanted investment terminology explained more clearly (Participant 1).\n", "**Change:** Partially addressed \u2014 the Investment Score itself is now explained via the \u24d8 panel; a full glossary for other terms (e.g., \"appreciation,\" \"percentile\") isn't built yet.\n", "\n", "**Finding:** Predict future home values or insurance premiums.\n", "**Change:** Out of scope for this version \u2014 noted as a possible feature for a future iteration, not this one." ] } ], "metadata": { "colab": { "provenance": [] }, "kernelspec": { "display_name": "base", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.4" } }, "nbformat": 4, "nbformat_minor": 0 }