# + # -*- coding: utf-8 -*- # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # + import streamlit as st import streamlit.components.v1 as components import base64 import leafmap.maplibregl as leafmap import altair as alt import ibis from ibis import _ import ibis.selectors as s from typing import Optional def to_streamlit( self, width: Optional[int] = None, height: Optional[int] = 600, scrolling: Optional[bool] = False, **kwargs, ): try: import streamlit.components.v1 as components import base64 raw_html = self.to_html().encode("utf-8") raw_html = base64.b64encode(raw_html).decode() return components.iframe( f"data:text/html;base64,{raw_html}", width=width, height=height, scrolling=scrolling, **kwargs, ) except Exception as e: raise Exception(e) # gap codes 3 and 4 are off by default. default_gap = { 3: False, 4: False, } # + #pad_pmtiles = "https://data.source.coop/cboettig/pad-us-3/pad-stats.pmtiles" #parquet = "https://data.source.coop/cboettig/pad-us-3/pad-stats.parquet" pad_pmtiles = "https://huggingface.co/datasets/boettiger-lab/pad-us-3/resolve/main/pad-stats.pmtiles" # parquet = "https://huggingface.co/datasets/boettiger-lab/pad-us-3/resolve/main/pad-stats.parquet" parquet = "pad-stats.parquet" # adding this to test out git # some default color variables, consider user palette via st.color_picker() private_color = "#DE881E" # orange #"#850101" # red tribal_color = "#BF40BF" # purple mixed_color = "#005a00" # green public_color = "#3388ff" # blue # default color breaks, consider tool via st.slider() low = 2 high = 3 alpha = .5 color_choice = "Manager Type" us_lower_48_area_m2 = 7.8e+12 # + ## Helper functions #@st.cache_resource # def ibis_connection(parquet): # return ibis.read_parquet(parquet) # pad_data = ibis_connection(parquet) con = ibis.duckdb.connect(extensions=["spatial"]) pad_data = con.read_parquet(parquet) #@st.cache_data() # def summary_table(column, colors): # df = (pad_data # .rename(area = "area_square_meters") # .group_by(_[column]) # .aggregate( # ) # .mutate(percent_protected = _.percent_protected.round(1)) # .inner_join(colors, column) # ) # df = df.to_pandas() # df[column] = df[column].astype(str) # return df from functools import reduce def get_summary(pad_data, combined_filter, column, colors=None): #summary stats, based on filtered data # ca = ca.filter(_.gap_code.isin([1,2])) #only gap 1 and 2 df = pad_data.filter(combined_filter) df = (df .rename(area = "area_square_meters") .group_by(*column) # unpack the list for grouping .aggregate(hectares_protected = (_.area.sum() / 10000).round(), percent_protected = 100 * _.area.sum() / us_lower_48_area_m2, mean_richness = (_.richness * _.area).sum() / _.area.sum(), mean_rsr = (_.rsr * _.area).sum() / _.area.sum(), mean_irrecoverable_carbon = (_.irrecoverable_carbon * _.area).sum() / _.area.sum(), mean_manageable_carbon = (_.manageable_carbon * _.area).sum() / _.area.sum(), mean_carbon_lost = (_.deforest_carbon * _.area).sum() / _.area.sum(), mean_crop_expansion = (_.crop_expansion * _.area).sum() / _.area.sum(), mean_human_impact = (_.human_impact * _.area).sum() / _.area.sum(), mean_forest_integrity_loss = (_.forest_integrity_loss*_.area).sum() / _.area.sum(), mean_bio_intact_loss = (_.biodiversity_intactness_loss * _.area).sum() / _.area.sum(), ) .mutate(percent_protected=_.percent_protected.round(1)) ) if colors is not None and not colors.empty: #only the df will have colors, df_tab doesn't since we are printing it. df = df.inner_join(colors, column) df = df.cast({col: "string" for col in column}) df = df.to_pandas() return df def summary_table(column, colors, filter_cols, filter_vals,colorby_vals): # get df for charts + df_tab for printed table + df_percent for percentage (only gap 1 and 2) filters = [] if filter_cols and filter_vals: #if a filter is selected, add to list of filters for filter_col, filter_val in zip(filter_cols, filter_vals): if len(filter_val) > 1: filters.append(getattr(_, filter_col).isin(filter_val)) else: filters.append(getattr(_, filter_col) == filter_val[0]) if column not in filter_cols: #show color_by column in table by adding it as a filter (if it's not already a filter) filter_cols.append(column) filters.append(getattr(_, column).isin(colorby_vals[column])) combined_filter = reduce(lambda x, y: x & y, filters) #combining all the filters into ibis filter expression df = get_summary(pad_data, combined_filter, [column], colors) # df used for charts df_tab = get_summary(pad_data, combined_filter, filter_cols, colors = None) #df used for printed table df_percent = get_summary(pad_data.filter(_.gap_code.isin([1,2])), combined_filter, [column], colors) # only gap 1 and 2 count towards percentage return df, df_tab, df_percent def bar_chart(df, x, y): chart = alt.Chart(df).mark_bar().encode( x=x, y=y, color=alt.Color('color').scale(None) ).properties(width="container", height=300) return chart def area_plot(df, column): base = alt.Chart(df).encode( alt.Theta("percent_protected:Q").stack(True), ) pie = ( base .mark_arc(innerRadius= 40, outerRadius=100) .encode(alt.Color("color:N").scale(None).legend(None), tooltip=['percent_protected', 'hectares_protected', column]) ) text = ( base .mark_text(radius=80, size=14, color="white") .encode(text = column + ":N") ) plot = pie # pie + text return plot.properties(width="container", height=300) def pad_style(paint, alpha): return { "version": 8, "sources": { "pad": { "type": "vector", "url": "pmtiles://" + pad_pmtiles, "attribution": "US PAD v3"}}, "layers": [{ "id": "public", "source": "pad", "source-layer": "pad-stats", "type": "fill", "paint": { "fill-color": paint, "fill-opacity": alpha } }]} def get_pmtiles_style(paint, alpha, cols, values): #style depends on the filters selected. filters = [] for col, val in zip(cols, values): filter_condition = ["match", ["get", col], val, True, False] filters.append(filter_condition) combined_filter = ["all"] + filters return { "version": 8, "sources": { "pad": { "type": "vector", "url": "pmtiles://" + pad_pmtiles, "attribution": "US PAD v3", } }, "layers": [{ "id": "public", "source": "pad", "source-layer": "pad-stats", "type": "fill", "filter": combined_filter, # Use the combined filter "paint": { "fill-color": paint, "fill-opacity": alpha } }] } # + def getButtons(style_options, color_choice, default_gap=None): #finding the buttons selected to use as filters column = style_options[color_choice]['property'] opts = [style[0] for style in style_options[color_choice]['stops']] default_gap = default_gap or {} buttons = { name: st.checkbox(f"{name}", value=default_gap.get(name, True), key=column + str(name)) for name in opts } filter_choice = [key for key, value in buttons.items() if value] # return only selected d = {} d[column] = filter_choice return d def getColorVals(style_options, color_choice): #df_tab only includes filters selected, we need to manually add "color_by" column (if it's not already a filter). column = style_options[color_choice]['property'] opts = [style[0] for style in style_options[color_choice]['stops']] d = {} d[column] = opts return d custom_style = ''' "blue" ''' sample_q = '''( ibis.read_parquet(parquet). mutate(area = _.area_square_meters). group_by(_.gap_code). aggregate(percent_protected = 100 * _.area.sum() / us_lower_48_area_m2, mean_richness = (_.richness * _.area).sum() / _.area.sum(), mean_rsr = (_.rsr * _.area).sum() / _.area.sum() ). mutate(percent_protected = _.percent_protected.round()) ) ''' ## Protected Area polygon color codes manager = { 'property': 'manager_type', 'type': 'categorical', 'stops': [ ['Federal', "darkblue"], ['State', public_color], ['Local Government', "lightblue"], ['Regional Agency Special District', "darkgreen"], ['Unknown', "grey"], ['Joint', "green"], ['American Indian Lands', tribal_color], ['Private', "darkred"], ['Non-Governmental Organization', "orange"] ] } easement = { 'property': 'category', 'type': 'categorical', 'stops': [ ['Fee', public_color], ['Easement', private_color], ['Proclamation', tribal_color] ] } access = { 'property': 'public_access', 'type': 'categorical', 'stops': [ ['Open Access', public_color], ['Closed', private_color], ['Unknown', "grey"], ['Restricted Access', tribal_color] ] } gap = { 'property': 'gap_code', 'type': 'categorical', 'stops': [ ["1", "#26633d"], ["2", "#879647"], ["3", "#BBBBBB"], ["4", "#F8F8F8"] ] } iucn = { 'property': 'iucn_category', 'type': 'categorical', 'stops': [ ["Ia: Strict nature reserves", "#4B0082"], ["Ib: Wilderness areas", "#663399"], ["II: National park", "#7B68EE"], ["III: Natural monument or feature", "#9370DB"], ["IV: Habitat / species management", "#8A2BE2"], ["V: Protected landscape / seascape", "#9932CC"], ["VI: Protected area with sustainable use of natural resources", "#9400D3"], ["Other Conservation Area", "#DDA0DD"], ["Unassigned", "#F8F8F8"], ] } style_options = { "GAP Status Code": gap, "IUCN Status Code": iucn, "Manager Type": manager, "Fee/Easement": easement, "Public Access": access, # "Mean Richness": richness, # "Mean RSR": rsr, # "custom": eval(custom) } code_ex=''' m.add_cog_layer("https://data.source.coop/vizzuality/lg-land-carbon-data/natcrop_expansion_100m_cog.tif", palette="oranges", name="Cropland Expansion", transparent_bg=True, opacity = 0.7, fit_bounds=False) ''' justice40 = "https://data.source.coop/cboettig/justice40/disadvantaged-communities.pmtiles" justice40_fill = { 'property': 'Disadvan', 'type': 'categorical', 'stops': [ [0, "rgba(255, 255, 255, 0)"], [1, "rgba(0, 0, 139, 1)"]]} justice40_style = { "version": 8, "sources": { "source1": { "type": "vector", "url": "pmtiles://" + justice40, "attribution": "Justice40"} }, "layers": [{ "id": "layer1", "source": "source1", "source-layer": "DisadvantagedCommunitiesCEJST", "type": "fill", "paint": {"fill-color": justice40_fill, "fill-opacity": 0.6}}] } bil_url = "https://huggingface.co/datasets/boettiger-lab/pad-us-3/resolve/main/bil.geojson" bil_fill = { "fill-extrusion-color": { "property": "AtlasCateg", "type": "categorical", "stops": [ ["America the Beautiful Challenge Grants", "orange"], ["Clean Energy and Power", "gray"], ["Environmental Remediation", "green"], ["Resilience and Ecosystem Restoration", "purple"], ["Water Infrastructure", "blue"] ], }, #"fill-extrusion-height": ["*", .01, ["get", "FundingAmo"]], "fill-extrusion-height": ["*", 50, ["sqrt", ["get", "FundingAmo"]]], "fill-extrusion-opacity": 0.9, } ########################################################################################################### # + st.set_page_config(layout="wide", page_title="Protected Areas Explorer", page_icon=":globe:") ''' # US Conservation Atlas Prototype An interactive cloud-native geospatial tool for exploring and visualizing the United States' protected lands with open data. - ❌ Safari/iOS not yet supported - ⬅️ Use the left sidebar to color-code the map by different attributes, toggle on data layers and view summary charts, or filter data. ''' st.divider() filters = {} m = leafmap.Map(style="positron") # + with st.sidebar: with st.expander("🗺 Basemaps"): # radio selector would make more sense if st.toggle("Topography"): m.add_basemap("Esri.WorldShadedRelief") if st.toggle("Satellite"): m.add_basemap("Esri.WorldImagery") # if st.toggle("Protected Areas", True): color_choice = st.radio("Color by:", style_options) colorby_vals = getColorVals(style_options, color_choice) #get options for selected color_by column alpha = st.slider("transparency", 0.0, 1.0, 0.5) "Data layers:" with st.expander("🦜 Biodiversity"): a_bio = st.slider("transparency", 0.0, 1.0, 0.4, key = "biodiversity") show_richness = st.toggle("Species Richness", False) if show_richness: m.add_tile_layer(url="https://data.source.coop/cboettig/mobi/tiles/red/species-richness-all/{z}/{x}/{y}.png", name="MOBI Species Richness", attribution="NatureServe", opacity=a_bio ) show_rsr = st.toggle("Range-Size Rarity") if show_rsr: m.add_tile_layer(url="https://data.source.coop/cboettig/mobi/tiles/green/range-size-rarity-all/{z}/{x}/{y}.png", name="MOBI Range-Size Rarity", attribution="NatureServe", opacity=a_bio ) #m.add_cog_layer("https://data.source.coop/cboettig/mobi/range-size-rarity-all/RSR_All.tif", # palette="greens", name="Range-Size Rarity", transparent_bg=True, opacity = 0.9, fit_bounds=False) with st.expander("⛅ Carbon & Climate"): a_climate = st.slider("transparency", 0.0, 1.0, 0.3, key = "climate") show_carbon_lost = st.toggle("Carbon Lost (2002-2022)") if show_carbon_lost: m.add_cog_layer("https://data.source.coop/vizzuality/lg-land-carbon-data/deforest_carbon_100m_cog.tif", palette="reds", name="Carbon Lost (2002-2022)", transparent_bg=True, opacity = a_climate, fit_bounds=False) show_irr_carbon = st.toggle("Irrecoverable Carbon") if show_irr_carbon: m.add_cog_layer("https://data.source.coop/cboettig/carbon/cogs/irrecoverable_c_total_2018.tif", palette="purples", name="Irrecoverable Carbon", transparent_bg=True, opacity = a_climate, fit_bounds=False) show_man_carbon = st.toggle("Manageable Carbon") if show_man_carbon: m.add_cog_layer("https://data.source.coop/cboettig/carbon/cogs/manageable_c_total_2018.tif", palette="greens", name="Manageable Carbon", transparent_bg=True, opacity = a_climate, fit_bounds=False) with st.expander("🚜 Human Impacts"): a_hi = st.slider("transparency", 0.0, 1.0, 0.5, key = "hi") show_human_impact = st.toggle("Human Impact") if show_human_impact: hi="https://data.source.coop/vizzuality/hfp-100/hfp_2021_100m_v1-2_cog.tif" m.add_cog_layer(hi, palette="purples", name="Human Impact", transparent_bg=True, opacity = a_hi, fit_bounds=False) show_crop_expansion = st.toggle("Cropland Expansion") if show_crop_expansion: m.add_cog_layer("https://data.source.coop/vizzuality/lg-land-carbon-data/natcrop_expansion_100m_cog.tif",opacity = a_hi, name = "Cropland Expansion") # palette="greens", name="cropland expansion", transparent_bg=True, opacity = 0.8, fit_bounds=False) show_bio_loss = st.toggle("Biodiversity Intactness Loss") if show_bio_loss: m.add_cog_layer("https://data.source.coop/vizzuality/lg-land-carbon-data/natcrop_bii_100m_cog.tif", palette="reds", name="Biodiversity Intactness Loss", transparent_bg=True, opacity = a_hi, fit_bounds=False) show_forest_loss = st.toggle("Forest Integrity Loss") if show_forest_loss: m.add_cog_layer("https://data.source.coop/vizzuality/lg-land-carbon-data/natcrop_fii_100m_cog.tif", palette="reds", name="Forest Integrity Loss", transparent_bg=True, opacity = a_hi, fit_bounds=False) with st.expander("💰 Conservation Investment"): if st.toggle("Bipartisan Infrastructure Law"): m.add_geojson(bil_url, layer_type="fill-extrusion", paint=bil_fill, name="BIL", fit_bounds=False) with st.expander("💻 Custom Code"): if st.toggle("Custom Map Layers"): code = st.text_area(label = "leafmap code:", value = code_ex, height = 100) eval(compile(code, "", "exec")) st.divider() "Filters:" for label in style_options: # get selected filters (based on the buttons selected) with st.expander(label): if label == "GAP Status Code": # gap code 1 and 2 are on by default opts = getButtons(style_options, label, default_gap) else: # other buttons are not on by default. opts = getButtons(style_options, label) filters.update(opts) selected = {k: v for k, v in filters.items() if v} if selected: filter_cols = list(selected.keys()) filter_vals = list(selected.values()) else: filter_cols = [] filter_vals = [] style = get_pmtiles_style(style_options[color_choice], alpha, filter_cols, filter_vals) legend_d = {cat: color for cat, color in style_options[color_choice]['stops']} m.add_legend(legend_dict = legend_d, position = 'bottom-left') m.add_pmtiles(pad_pmtiles, style=style, name="PAD", opacity=alpha, tooltip=True) # style = get_pmtiles_style(style_options[color_choice], alpha) # m.add_pmtiles(pad_pmtiles, style=style, visible=True, opacity=alpha, tooltip=True) # "## Boundaries" # boundaries = st.radio("Boundaries:", # ["None", # "State Boundaries", # "County Boundaries", # "Congressional District", # "custom"] # ) # + # Map radio buttons to corresponding column: select_column = { "GAP Status Code": "gap_code", "IUCN Status Code": "iucn_category", "Manager Type": "manager_type", "Fee/Easement": "category", "Public Access": "public_access", "Mean Richness": "gap_code", "Mean RSR": "gap_code", "custom": "gap_code"} column = select_column[color_choice] # Map radio buttons to corresponding color-scheme: select_colors = { "GAP Status Code": gap["stops"], "IUCN Status Code": iucn["stops"], "Manager Type": manager["stops"], "Fee/Easement": easement["stops"], "Public Access": access["stops"], "Mean Richness": gap["stops"], "Mean RSR": gap["stops"], "custom": gap["stops"]} colors = (ibis .memtable(select_colors[color_choice], columns = [column, "color"]) .to_pandas() ) # + # get summary tables used for charts + printed table + percentage # df - charts; df_tab - printed table (omits colors) + df_percent - only gap codes 1 & 2 count towards percentage df,df_tab,df_percent = summary_table(column, colors, filter_cols, filter_vals, colorby_vals) # compute area covered (only gap 1 and 2) # df_onlygap = df[df.gap_code.isin([1,2])] total_percent = df_percent.percent_protected.sum().round(1) # charts displayed based on color_by variable richness_chart = bar_chart(df, column, 'mean_richness') rsr_chart = bar_chart(df, column, 'mean_rsr') irr_carbon_chart = bar_chart(df, column, 'mean_irrecoverable_carbon') man_carbon_chart = bar_chart(df, column, 'mean_manageable_carbon') carbon_loss_chart = bar_chart(df, column, 'mean_carbon_lost') hi_chart = bar_chart(df, column, 'mean_human_impact') crop_expansion_chart = bar_chart(df, column, 'mean_crop_expansion') bio_intact_loss_chart = bar_chart(df, column, 'mean_bio_intact_loss') forest_integrity_loss_chart = bar_chart(df, column, 'mean_forest_integrity_loss') main = st.container() with main: map_col, stats_col = st.columns([2,1]) with map_col: to_streamlit(m, height=700) # df = summary_table(column, colors) # total_percent = df.percent_protected.sum().round(1) # richness_chart = bar_chart(df, column, 'mean_richness') # rsr_chart = bar_chart(df, column, 'mean_rsr') # carbon_lost = bar_chart(df, column, 'carbon_lost') # crop_expansion = bar_chart(df, column, 'crop_expansion') # human_impact = bar_chart(df, column, 'human_impact') with stats_col: with st.container(): f"{total_percent}% Continental US Covered" st.altair_chart(area_plot(df, column), use_container_width=True) with st.container(): if show_richness: "Species Richness" st.altair_chart(richness_chart, use_container_width=True) if show_rsr: "Range-Size Rarity" st.altair_chart(rsr_chart, use_container_width=True) if show_carbon_lost: "Carbon Lost ('02-'22)" st.altair_chart(carbon_loss_chart, use_container_width=True) if show_crop_expansion: "Crop Expansion" st.altair_chart(crop_expansion_chart, use_container_width=True) if show_human_impact: "Human Impact" st.altair_chart(hi_chart, use_container_width=True) if show_irr_carbon: "Irrecoverable Carbon" st.altair_chart(irr_carbon_chart, use_container_width=True) if show_man_carbon: "Manageable Carbon" st.altair_chart(man_carbon_chart, use_container_width=True) if show_bio_loss: "Biodiversity Intactness Loss" st.altair_chart(bio_intact_loss_chart, use_container_width=True) if show_forest_loss: "Forest Integrity Loss" st.altair_chart(forest_integrity_loss_chart, use_container_width=True) # charts displayed based on color_by variable # + st.divider() footer = st.container() with footer: ''' ## Custom queries Input custom python code below to interactively explore the data. ''' col2_1, col2_2 = st.columns(2) with col2_1: query = st.text_area( label = "Python code:", value = sample_q, height = 300) with col2_2: "Output table:" df = eval(query) st.write(df.to_pandas()) st.divider() ''' ## Credits Author: Cassie Buhler & Carl Boettiger, UC Berkeley License: BSD-2-clause ### Data sources - US Protected Areas Database v3 by USGS. Data: https://beta.source.coop/cboettig/us-pad-3. Citation: https://doi.org/10.5066/P9Q9LQ4B, License: Public Domain - Imperiled Species Richness and Range-Size-Rarity from NatureServe (2022). Data: https://beta.source.coop/repositories/cboettig/mobi. License CC-BY-NC-ND - Carbon-loss and farming impact by Vizzuality, on https://beta.source.coop/repositories/vizzuality/lg-land-carbon-data. Citation: https://doi.org/10.1101/2023.11.01.565036, License: CC-BY - Human Footprint by Vizzuality, on https://beta.source.coop/repositories/vizzuality/hfp-100. Citation: https://doi.org/10.3389/frsen.2023.1130896, License: Public Domain - Fire polygons by USGS, reprocessed to PMTiles on https://beta.source.coop/cboettig/fire/. License: Public Domain - Irrecoverable Carbon from Conservation International, reprocessed to COG on https://beta.source.coop/cboettig/carbon, citation: https://doi.org/10.1038/s41893-021-00803-6, License: CC-BY-NC - Climate and Economic Justice Screening Tool, US Council on Environmental Quality, Justice40, data: https://beta.source.coop/repositories/cboettig/justice40/description/, License: Public Domain ### Software Proudly built with a free and Open Source software stack: Streamlit (reactive application), HuggingFace (application hosting), Source.Coop (data hosting), using cloud-native data serializations in COG, PMTiles, and GeoParquet. Coded in pure python using leafmap and duckdb. Map styling with [MapLibre](https://maplibre.org/). '''