cassiebuhler commited on
Commit
38804a1
·
1 Parent(s): b834a5d

optimized functions. Still need to figure out why removing unknown status makes the stacked bar charts go wonky...

Files changed (2) hide show
  1. app/app.py +8 -8
  2. app/utils.py +321 -442
app/app.py CHANGED
@@ -195,7 +195,7 @@ def run_sql(query,color_choice):
195
 
196
  elif ("id" and "geom" in result.columns):
197
  style = get_pmtiles_style_llm(style_options[color_choice], result["id"].tolist())
198
- legend, position, bg_color, fontsize = getLegend(style_options,color_choice)
199
 
200
  m.add_legend(legend_dict = legend, position = position, bg_color = bg_color, fontsize = fontsize)
201
  m.add_pmtiles(ca_pmtiles, style=style, opacity=alpha, tooltip=True, fit_bounds=True)
@@ -229,7 +229,7 @@ with st.sidebar:
229
 
230
  st.divider()
231
  color_choice = st.radio("Group by:", style_options, key = "color", help = "Select a category to change map colors and chart groupings.")
232
- colorby_vals = getColorVals(style_options, color_choice) #get options for selected color_by column
233
  alpha = 0.8
234
  st.divider()
235
 
@@ -348,9 +348,9 @@ with st.sidebar:
348
  for label in style_options: # get selected filters (based on the buttons selected)
349
  with st.expander(label):
350
  if label in ["GAP Code","30x30 Status"]: # gap code 1 and 2 are on by default
351
- opts = getButtons(style_options, label, default_boxes)
352
  else: # other buttons are not on by default.
353
- opts = getButtons(style_options, label)
354
  filters.update(opts)
355
 
356
  selected = {k: v for k, v in filters.items() if v}
@@ -371,7 +371,7 @@ with st.sidebar:
371
  # Display CA 30x30 Data
372
  if 'out' not in locals():
373
  style = get_pmtiles_style(style_options[color_choice], alpha, filter_cols, filter_vals)
374
- legend, position, bg_color, fontsize = getLegend(style_options, color_choice)
375
  m.add_legend(legend_dict = legend, position = position, bg_color = bg_color, fontsize = fontsize)
376
  m.add_pmtiles(ca_pmtiles, style=style, name="CA", opacity=alpha, tooltip=True, fit_bounds=True)
377
 
@@ -397,11 +397,11 @@ colors = (
397
  # get summary tables used for charts + printed table
398
  # df - charts; df_tab - printed table (omits colors)
399
  if 'out' not in locals():
400
- df, df_tab, df_percent, df_bar_30x30 = summary_table(ca, column, select_colors, color_choice, filter_cols, filter_vals,colorby_vals)
401
  total_percent = 100*df_percent.percent_CA.sum()
402
 
403
  else:
404
- df = summary_table_sql(ca, column, colors, ids)
405
  total_percent = 100*df.percent_CA.sum()
406
 
407
 
@@ -431,7 +431,7 @@ with main:
431
  with st.container():
432
 
433
  st.markdown(f"{total_percent}% CA Protected", help = "Total percentage of 30x30 conserved lands, updates based on displayed data")
434
- st.altair_chart(area_plot(df, column), use_container_width=True)
435
 
436
  if 'df_bar_30x30' in locals(): #if we use chatbot, we won't have these graphs.
437
  if column not in ["status", "gap_code"]:
 
195
 
196
  elif ("id" and "geom" in result.columns):
197
  style = get_pmtiles_style_llm(style_options[color_choice], result["id"].tolist())
198
+ legend, position, bg_color, fontsize = get_legend(style_options,color_choice)
199
 
200
  m.add_legend(legend_dict = legend, position = position, bg_color = bg_color, fontsize = fontsize)
201
  m.add_pmtiles(ca_pmtiles, style=style, opacity=alpha, tooltip=True, fit_bounds=True)
 
229
 
230
  st.divider()
231
  color_choice = st.radio("Group by:", style_options, key = "color", help = "Select a category to change map colors and chart groupings.")
232
+ colorby_vals = get_color_vals(style_options, color_choice) #get options for selected color_by column
233
  alpha = 0.8
234
  st.divider()
235
 
 
348
  for label in style_options: # get selected filters (based on the buttons selected)
349
  with st.expander(label):
350
  if label in ["GAP Code","30x30 Status"]: # gap code 1 and 2 are on by default
351
+ opts = get_buttons(style_options, label, default_boxes)
352
  else: # other buttons are not on by default.
353
+ opts = get_buttons(style_options, label)
354
  filters.update(opts)
355
 
356
  selected = {k: v for k, v in filters.items() if v}
 
371
  # Display CA 30x30 Data
372
  if 'out' not in locals():
373
  style = get_pmtiles_style(style_options[color_choice], alpha, filter_cols, filter_vals)
374
+ legend, position, bg_color, fontsize = get_legend(style_options, color_choice)
375
  m.add_legend(legend_dict = legend, position = position, bg_color = bg_color, fontsize = fontsize)
376
  m.add_pmtiles(ca_pmtiles, style=style, name="CA", opacity=alpha, tooltip=True, fit_bounds=True)
377
 
 
397
  # get summary tables used for charts + printed table
398
  # df - charts; df_tab - printed table (omits colors)
399
  if 'out' not in locals():
400
+ df, df_tab, df_percent, df_bar_30x30 = get_summary_table(ca, column, select_colors, color_choice, filter_cols, filter_vals,colorby_vals)
401
  total_percent = 100*df_percent.percent_CA.sum()
402
 
403
  else:
404
+ df = get_summary_table_sql(ca, column, colors, ids)
405
  total_percent = 100*df.percent_CA.sum()
406
 
407
 
 
431
  with st.container():
432
 
433
  st.markdown(f"{total_percent}% CA Protected", help = "Total percentage of 30x30 conserved lands, updates based on displayed data")
434
+ st.altair_chart(area_chart(df, column), use_container_width=True)
435
 
436
  if 'df_bar_30x30' in locals(): #if we use chatbot, we won't have these graphs.
437
  if column not in ["status", "gap_code"]:
app/utils.py CHANGED
@@ -1,400 +1,45 @@
1
  import streamlit as st
2
  import streamlit.components.v1 as components
3
- import base64
4
  import leafmap.maplibregl as leafmap
5
  import altair as alt
6
  import ibis
7
  from ibis import _
8
  import ibis.selectors as s
9
  import os
10
- import pandas as pd
11
- import geopandas as gpd
12
  from shapely import wkb
13
- import sqlalchemy
14
- import pathlib
15
  from typing import Optional
16
  from functools import reduce
17
  from itertools import chain
18
 
19
  from variables import *
20
 
21
- def colorTable(select_colors,color_choice,column):
22
- colors = (ibis
23
- .memtable(select_colors[color_choice], columns=[column, "color"])
24
- .to_pandas()
25
- )
26
- return colors
27
 
 
 
 
 
 
 
 
 
28
 
29
-
30
-
31
- def get_summary(ca, combined_filter, column, main_group, colors=None):
32
- df = ca.filter(combined_filter)
33
- #total acres for each group
34
- # if colors is not None and not colors.empty:
35
- group_totals = df.group_by(main_group).aggregate(total_acres=_.acres.sum())
36
- df = ca.filter(combined_filter)
37
- df = (df
38
- .group_by(*column) # unpack the list for grouping
39
- .aggregate(percent_CA= _.acres.sum() / ca_area_acres,
40
- acres = _.acres.sum(),
41
- mean_richness = (_.richness * _.acres).sum() / _.acres.sum(),
42
- mean_rsr = (_.rsr * _.acres).sum() / _.acres.sum(),
43
- mean_irrecoverable_carbon = (_.irrecoverable_carbon * _.acres).sum() / _.acres.sum(),
44
- mean_manageable_carbon = (_.manageable_carbon * _.acres).sum() / _.acres.sum(),
45
- mean_fire = (_.fire *_.acres).sum()/_.acres.sum(),
46
- mean_rxburn = (_.rxburn *_.acres).sum()/_.acres.sum(),
47
- mean_disadvantaged = (_.disadvantaged_communities * _.acres).sum() / _.acres.sum(),
48
- mean_svi = (_.svi * _.acres).sum() / _.acres.sum(),
49
- )
50
- .mutate(percent_CA=_.percent_CA.round(3),
51
- acres=_.acres.round(0))
52
- )
53
- # if colors is not None and not colors.empty:
54
- df = df.inner_join(group_totals, main_group)
55
- df = df.mutate(percent_group=( _.acres / _.total_acres).round(3))
56
- if colors is not None and not colors.empty: #only the df will have colors, df_tab doesn't since we are printing it.
57
- df = df.inner_join(colors, column[-1])
58
- df = df.cast({col: "string" for col in column})
59
- df = df.to_pandas()
60
- return df
61
-
62
-
63
- def summary_table(ca, column, select_colors, color_choice, filter_cols, filter_vals,colorby_vals): # get df for charts + df_tab for printed table
64
- colors = colorTable(select_colors,color_choice,column)
65
- filters = []
66
- if filter_cols and filter_vals: #if a filter is selected, add to list of filters
67
- for filter_col, filter_val in zip(filter_cols, filter_vals):
68
- if len(filter_val) > 1:
69
- filters.append(getattr(_, filter_col).isin(filter_val))
70
- else:
71
- filters.append(getattr(_, filter_col) == filter_val[0])
72
- 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)
73
- filter_cols.append(column)
74
- filters.append(getattr(_, column).isin(colorby_vals[column]))
75
- combined_filter = reduce(lambda x, y: x & y, filters) #combining all the filters into ibis filter expression
76
 
77
- only_conserved = (combined_filter) & (_.status.isin(['30x30-conserved']))
78
- df_percent = get_summary(ca, only_conserved, [column],column, colors) # df used for percentage, excludes non-conserved.
79
-
80
- df_tab = get_summary(ca, combined_filter, filter_cols, column, colors = None) #df used for printed table
81
-
82
- if "non-conserved" in list(chain.from_iterable(filter_vals)):
83
- combined_filter = (combined_filter) | (_.status.isin(['non-conserved']))
84
-
85
- df = get_summary(ca, combined_filter, [column], column, colors) # df used for charts
86
-
87
- df_bar_30x30 = None # no stacked charts if we have status/gap_code
88
- if column not in ["status","gap_code"]: # df for stacked 30x30 status bar chart
89
- colors = colorTable(select_colors,"30x30 Status",'status')
90
- df_bar_30x30 = get_summary(ca, combined_filter, [column, 'status'], column, colors) # df used for charts
91
 
92
-
93
- return df, df_tab, df_percent, df_bar_30x30
94
-
95
-
96
-
97
-
98
- def summary_table_sql(ca, column, colors, ids): # get df for charts + df_tab for printed table
99
- filters = [_.id.isin(ids)]
100
- combined_filter = reduce(lambda x, y: x & y, filters) #combining all the filters into ibis filter expression
101
- df = get_summary(ca, combined_filter, [column], column, colors) # df used for charts
102
- return df
103
-
104
-
105
- def get_hex(df, color,sort_order):
106
- return list(df.drop_duplicates(subset=color, keep="first")
107
- .set_index(color)
108
- .reindex(sort_order)
109
- .dropna()["color"])
110
-
111
- def transform_label(label, x_field):
112
- # converting labels for that gnarly stacked bar chart
113
- if x_field == "access_type":
114
- return label.replace(" Access", "")
115
- elif x_field == "ecoregion":
116
- label = label.replace("Northern California", "NorCal")
117
- label = label.replace("Southern California", "SoCal")
118
- label = label.replace("Southeastern", "SE.")
119
- label = label.replace("Northwestern", "NW.")
120
- label = label.replace("and", "&")
121
- label = label.replace("California", "CA")
122
- return label
123
- else:
124
- return label
125
-
126
-
127
- def stacked_bar(df, x, y, color, title, colors):
128
- label_colors = colors['color'].to_list()
129
- # bar order
130
- if x == "established": # order labels in chronological order, not alphabetic.
131
- sort = '-x'
132
- elif x == "access_type": # order based on levels of openness
133
- sort = ['Open', 'Restricted', 'No Public', "Unknown"]
134
- elif x == "easement":
135
- sort = ['True', 'False']
136
- elif x == "manager_type":
137
- sort = ["Federal", "Tribal", "State", "Special District", "County", "City", "HOA",
138
- "Joint", "Non Profit", "Private", "Unknown"]
139
- elif x == "status":
140
- sort = ["30x30-conserved", "other-conserved", "unknown", "non-conserved"]
141
- elif x == "ecoregion":
142
- sort = ['SE. Great Basin', 'Mojave Desert', 'Sonoran Desert', 'Sierra Nevada',
143
- 'SoCal Mountains & Valleys', 'Mono', 'Central CA Coast', 'Klamath Mountains',
144
- 'NorCal Coast', 'NorCal Coast Ranges', 'NW. Basin & Range', 'Colorado Desert',
145
- 'Central Valley Coast Ranges', 'SoCal Coast', 'Sierra Nevada Foothills',
146
- 'Southern Cascades', 'Modoc Plateau', 'Great Valley (North)',
147
- 'NorCal Interior Coast Ranges', 'Great Valley (South)']
148
- else:
149
- sort = 'x'
150
-
151
- if x == "manager_type":
152
- angle = 270
153
- height = 350
154
-
155
- elif x == 'ecoregion':
156
- angle = 270
157
- height = 430
158
- else:
159
- angle = 0
160
- height = 310
161
-
162
- # stacked bar order
163
- sort_order = ['30x30-conserved', 'other-conserved', 'unknown', 'non-conserved']
164
- y_titles = {
165
- 'ecoregion': 'Ecoregion (%)',
166
- 'established': 'Year (%)',
167
- 'manager_type': 'Manager Type (%)',
168
- 'easement': 'Easement (%)',
169
- 'access_type': 'Access (%)'
170
- }
171
- ytitle = y_titles.get(x, y)
172
- color_hex = get_hex(df[[color, 'color']], color, sort_order)
173
- sort_order = sort_order[0:len(color_hex)]
174
- df["stack_order"] = df[color].apply(lambda val: sort_order.index(val) if val in sort_order else len(sort_order))
175
-
176
- # shorten labels to fit on chart
177
- label_transform = f"datum.{x}"
178
- if x == "access_type":
179
- label_transform = f"replace(datum.{x}, ' Access', '')"
180
- elif x == "ecoregion":
181
- label_transform = (
182
- "replace("
183
- "replace("
184
- "replace("
185
- "replace("
186
- "replace("
187
- "replace(datum.ecoregion, 'Northern California', 'NorCal'),"
188
- "'Southern California', 'SoCal'),"
189
- "'Southeastern', 'SE.'),"
190
- "'Northwestern', 'NW.'),"
191
- "'and', '&'),"
192
- "'California', 'CA')"
193
- )
194
-
195
- # to match the colors in the map to each label, need to write some ugly code..
196
- # bar chart w/ xlabels hidden
197
- chart = alt.Chart(df).mark_bar(height = 500).transform_calculate(
198
- xlabel=label_transform
199
- ).encode(
200
- x=alt.X("xlabel:N", sort=sort, title=None,
201
- axis=alt.Axis(labelLimit=150, labelAngle=angle, labelColor="transparent")),
202
- y=alt.Y(y, title=ytitle, axis=alt.Axis(labelPadding=5)).scale(domain=(0, 1)),
203
- color=alt.Color(
204
- color,
205
- sort=sort_order,
206
- scale=alt.Scale(domain=sort_order, range=color_hex)
207
- ),
208
- order=alt.Order("stack_order:Q", sort="ascending"),
209
- tooltip=[
210
- alt.Tooltip(x, type="nominal"),
211
- alt.Tooltip(color, type="nominal"),
212
- alt.Tooltip("percent_group", type="quantitative", format=",.1%"),
213
- alt.Tooltip("acres", type="quantitative", format=",.0f"),
214
- ]
215
- )
216
- transformed_labels = [transform_label(str(lab), x) for lab in colors[x]]
217
- labels_df = colors
218
- labels_df['xlabel'] = transformed_labels
219
- # 2 layers, 1 for the symbol and 1 for the text
220
- if angle == 0:
221
- symbol_layer = alt.Chart(labels_df).mark_point(
222
- filled=True,
223
- shape="circle",
224
- size=100,
225
- xOffset = 0,
226
- yOffset=130,
227
- align = 'left',
228
- tooltip = False
229
- ).encode(
230
- x=alt.X("xlabel:N", sort=sort),
231
- color=alt.Color("color:N", scale=None)
232
- )
233
- text_layer = alt.Chart(labels_df).mark_text(
234
- dy=115, # shifts the text to the right of the symbol
235
- dx = 0,
236
- yOffset=0,
237
- xOffset = 0,
238
- align='center',
239
- color="black",
240
- tooltip = False
241
- ).encode(
242
- x=alt.X("xlabel:N", sort=sort),
243
- text=alt.Text("xlabel:N")
244
- )
245
- # vertical labels
246
- elif angle == 270:
247
- symbol_layer = alt.Chart(labels_df).mark_point(
248
- xOffset = 0,
249
- yOffset= 100,
250
- filled=True,
251
- shape="circle",
252
- size=100,
253
- tooltip = False
254
- ).encode(
255
- x=alt.X("xlabel:N", sort=sort),
256
- color=alt.Color("color:N", scale=None)
257
- )
258
- text_layer = alt.Chart(labels_df).mark_text(
259
- dy=0,
260
- dx = -110,
261
- angle=270,
262
- align='right',
263
- color="black",
264
- tooltip = False
265
- ).encode(
266
- x=alt.X("xlabel:N", sort=sort),
267
- text=alt.Text("xlabel:N")
268
- )
269
-
270
- custom_labels = alt.layer(symbol_layer, text_layer)
271
- final_chart = alt.layer(chart, custom_labels)
272
-
273
- # put it all together
274
- final_chart = final_chart.properties(
275
- width="container",
276
- height=height,
277
- title=title
278
- ).configure_legend(
279
- direction='horizontal',
280
- orient='top',
281
- columns=2,
282
- title=None,
283
- labelOffset=2,
284
- offset=10,
285
- symbolType = 'square'
286
- ).configure_title(
287
- fontSize=18, align="center", anchor='middle', offset=10
288
- )
289
- return final_chart
290
-
291
- def area_plot(df, column): # Percent protected pie chart
292
- base = alt.Chart(df).encode(
293
- alt.Theta("percent_CA:Q").stack(True),
294
- )
295
- pie = (
296
- base
297
- .mark_arc(innerRadius=40, outerRadius=100, stroke="black", strokeWidth=0.5)
298
- .encode(
299
- alt.Color("color:N").scale(None).legend(None),
300
- tooltip=[
301
- alt.Tooltip(column, type="nominal"),
302
- alt.Tooltip("percent_CA", type="quantitative", format=",.1%"),
303
- alt.Tooltip("acres", type="quantitative", format=",.0f"),
304
- ]
305
- )
306
- )
307
- text = (
308
- base
309
- .mark_text(radius=80, size=14, color="white")
310
- .encode(text=column + ":N")
311
- )
312
- plot = pie # pie + text
313
- return plot.properties(width="container", height=290)
314
-
315
-
316
-
317
- def bar_chart(df, x, y, title): #display summary stats for color_by column
318
- #axis label angles / chart size
319
- if x == "manager_type": #labels are too long, making vertical
320
- angle = 270
321
- height = 373
322
- elif x == 'ecoregion': # make labels vertical and figure taller
323
- angle = 270
324
- height = 430
325
- else: #other labels are horizontal
326
- angle = 0
327
- height = 310
328
-
329
- # order of bars
330
- sort = 'x'
331
- lineBreak = ''
332
- if x == "established": # order labels in chronological order, not alphabetic.
333
- sort = '-x'
334
- elif x == "access_type": #order based on levels of openness
335
- sort=['Open', 'Restricted', 'No Public', "Unknown"]
336
- elif x == "easement":
337
- sort=['True','False']
338
- elif x == "manager_type":
339
- sort = ["Federal","Tribal","State","Special District", "County", "City", "HOA","Joint","Non Profit","Private","Unknown"]
340
- elif x == "ecoregion":
341
- sort = ['SE. Great Basin','Mojave Desert','Sonoran Desert','Sierra Nevada','SoCal Mountains & Valleys','Mono',
342
- 'Central CA Coast','Klamath Mountains','NorCal Coast','NorCal Coast Ranges',
343
- 'NW. Basin & Range','Colorado Desert','Central Valley Coast Ranges','SoCal Coast',
344
- 'Sierra Nevada Foothills','Southern Cascades','Modoc Plateau','Great Valley (North)','NorCal Interior Coast Ranges',
345
- 'Great Valley (South)']
346
- elif x == "status":
347
- sort = ["30x30-conserved","other-conserved","unknown","non-conserved"]
348
- lineBreak = '-'
349
-
350
- # modify label names in bar chart to fit in frame
351
- label_transform = f"datum.{x}" # default; no change
352
- if x == "access_type":
353
- label_transform = f"replace(datum.{x}, ' Access', '')" #omit 'access' from access_type
354
- elif x == "ecoregion":
355
- label_transform = (
356
- "replace("
357
- "replace("
358
- "replace("
359
- "replace("
360
- "replace("
361
- "replace(datum.ecoregion, 'Northern California', 'NorCal'),"
362
- "'Southern California', 'SoCal'),"
363
- "'Southeastern', 'SE.'),"
364
- "'Northwestern', 'NW.'),"
365
- "'and', '&'),"
366
- "'California', 'CA')"
367
- )
368
- y_titles = {
369
- 'mean_richness': 'Richness (Mean)',
370
- 'mean_rsr': 'Range-Size Rarity (Mean)',
371
- 'mean_irrecoverable_carbon': 'Irrecoverable Carbon (Mean)',
372
- 'mean_manageable_carbon': 'Manageable Carbon (Mean)',
373
- 'mean_disadvantaged': 'Disadvantaged (Mean)',
374
- 'mean_svi': 'SVI (Mean)',
375
- 'mean_fire': 'Fire (Mean)',
376
- 'mean_rxburn': 'Rx Fire (Mean)'
377
- }
378
- ytitle = y_titles.get(y, y) # Default to `y` if not in the dictionary
379
-
380
- x_title = next(key for key, value in select_column.items() if value == x)
381
- chart = alt.Chart(df).mark_bar(stroke = 'black', strokeWidth = .5).transform_calculate(
382
- label=label_transform
383
- ).encode(
384
- x=alt.X("label:N",
385
- axis=alt.Axis(labelAngle=angle, title=x_title, labelLimit = 200),
386
- sort=sort),
387
- y=alt.Y(y, axis=alt.Axis(title = ytitle)),
388
- color=alt.Color('color').scale(None),
389
- ).configure(lineBreak = lineBreak)
390
-
391
- chart = chart.properties(width="container", height=height, title = title
392
- ).configure_title(fontSize=18, align = "center",anchor='middle')
393
- return chart
394
-
395
 
396
 
397
  def sync_checkboxes(source):
 
 
 
398
  # gap 1 and gap 2 on -> 30x30-conserved on
399
  if source in ["gap_code1", "gap_code2"]:
400
  st.session_state['status30x30-conserved'] = st.session_state.gap_code1 and st.session_state.gap_code2
@@ -428,62 +73,103 @@ def sync_checkboxes(source):
428
  st.session_state.gap_code0 = st.session_state['statusnon-conserved']
429
 
430
 
431
- def getButtons(style_options, style_choice, default_boxes=None):
 
 
 
 
 
 
 
 
 
432
  column = style_options[style_choice]['property']
433
- opts = [style[0] for style in style_options[style_choice]['stops']]
434
- default_boxes = default_boxes or {}
435
- buttons = {}
436
- for name in opts:
437
- key = column + str(name)
438
- buttons[name] = st.checkbox(f"{name}", value=st.session_state[key], key=key, on_change = sync_checkboxes, args = (key,))
439
- filter_choice = [key for key, value in buttons.items() if value]
440
- return {column: filter_choice}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
 
 
 
 
442
 
 
 
 
443
 
444
- def getColorVals(style_options, style_choice):
445
- #df_tab only includes filters selected, we need to manually add "color_by" column (if it's not already a filter).
446
- column = style_options[style_choice]['property']
447
- opts = [style[0] for style in style_options[style_choice]['stops']]
448
- d = {}
449
- d[column] = opts
450
- return d
451
 
 
 
 
 
452
 
453
- def getLegend(style_options, color_choice):
454
- legend = {cat: color for cat, color in style_options[color_choice]['stops']}
455
- position = 'bottom-left'
456
- fontsize = 15
457
- bg_color = 'white'
458
- # shorten legend for ecoregions
459
- if color_choice == "Ecoregion":
460
- legend = {key.replace("Northern California", "NorCal"): value for key, value in legend.items()}
461
- legend = {key.replace("Southern California", "SoCal"): value for key, value in legend.items()}
462
- legend = {key.replace("Southeastern", "SE."): value for key, value in legend.items()}
463
- legend = {key.replace("and", "&"): value for key, value in legend.items()}
464
- legend = {key.replace("California", "CA"): value for key, value in legend.items()}
465
- legend = {key.replace("Northwestern", "NW."): value for key, value in legend.items()}
466
- bg_color = 'rgba(255, 255, 255, 0.6)'
467
- fontsize = 12
468
- return legend, position, bg_color, fontsize
469
 
 
 
 
470
 
471
 
 
 
 
 
 
 
 
 
 
472
  def get_pmtiles_style(paint, alpha, filter_cols, filter_vals):
473
- filters = []
474
- for col, val in zip(filter_cols, filter_vals):
475
- filters.append(["match", ["get", col], val, True, False])
476
- combined_filters = ["all"] + filters
477
- if "non-conserved" in list(chain.from_iterable(filter_vals)):
478
- combined_filters = ["any", combined_filters, ["match", ["get", "status"], ["non-conserved"],True, False]]
479
- style = {
 
 
 
480
  "version": 8,
481
- "sources": {
482
- "ca": {
483
- "type": "vector",
484
- "url": "pmtiles://" + ca_pmtiles,
485
- }
486
- },
487
  "layers": [
488
  {
489
  "id": "ca30x30",
@@ -491,39 +177,232 @@ def get_pmtiles_style(paint, alpha, filter_cols, filter_vals):
491
  "source-layer": "ca30x30",
492
  "type": "fill",
493
  "filter": combined_filters,
494
- "paint": {
495
- "fill-color": paint,
496
- "fill-opacity": alpha
497
- }
498
  }
499
- ]
500
  }
501
- return style
502
-
503
 
504
  def get_pmtiles_style_llm(paint, ids):
505
- combined_filters = ["all", ["match", ["get", "id"], ids, True, False]]
506
- style = {
 
 
507
  "version": 8,
508
- "sources": {
509
- "ca": {
510
- "type": "vector",
511
- "url": "pmtiles://" + ca_pmtiles,
512
- }
513
- },
514
  "layers": [
515
  {
516
  "id": "ca30x30",
517
  "source": "ca",
518
  "source-layer": "ca30x30",
519
  "type": "fill",
520
- "filter": combined_filters,
521
- "paint": {
522
- "fill-color": paint,
523
- "fill-opacity": 1,
524
- }
525
  }
526
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  }
528
- return style
529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
  import streamlit.components.v1 as components
3
+ import pandas as pd
4
  import leafmap.maplibregl as leafmap
5
  import altair as alt
6
  import ibis
7
  from ibis import _
8
  import ibis.selectors as s
9
  import os
 
 
10
  from shapely import wkb
 
 
11
  from typing import Optional
12
  from functools import reduce
13
  from itertools import chain
14
 
15
  from variables import *
16
 
 
 
 
 
 
 
17
 
18
+ ######################## UI FUNCTIONS
19
+ def get_buttons(style_options, style_choice, default_boxes=None):
20
+ """
21
+ Creates Streamlit checkboxes based on style options and returns the selected filters.
22
+ """
23
+ column = style_options[style_choice]['property']
24
+ opts = [style[0] for style in style_options[style_choice]['stops']]
25
+ default_boxes = default_boxes or {}
26
 
27
+ buttons = {}
28
+ for name in opts:
29
+ key = column + str(name)
30
+ buttons[name] = st.checkbox(f"{name}", value=st.session_state[key], key=key, on_change = sync_checkboxes, args = (key,))
31
+ filter_choice = [key for key, value in buttons.items() if value]
32
+ return {column: filter_choice}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ # buttons = {name: st.checkbox(name, value=st.session_state.get(column + str(name), False), key=column + str(name), on_change=sync_checkboxes, args=(column + str(name),)) for name in opts}
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ # return {column: [key for key, value in buttons.items() if value]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
 
39
  def sync_checkboxes(source):
40
+ """
41
+ Synchronizes checkbox selections in Streamlit based on 30x30 status and GAP codes.
42
+ """
43
  # gap 1 and gap 2 on -> 30x30-conserved on
44
  if source in ["gap_code1", "gap_code2"]:
45
  st.session_state['status30x30-conserved'] = st.session_state.gap_code1 and st.session_state.gap_code2
 
73
  st.session_state.gap_code0 = st.session_state['statusnon-conserved']
74
 
75
 
76
+ def color_table(select_colors, color_choice, column):
77
+ """
78
+ Converts selected color mapping into a DataFrame.
79
+ """
80
+ return ibis.memtable(select_colors[color_choice], columns=[column, "color"]).to_pandas()
81
+
82
+ def get_color_vals(style_options, style_choice):
83
+ """
84
+ Extracts available color values for a selected style option.
85
+ """
86
  column = style_options[style_choice]['property']
87
+ return {column: [style[0] for style in style_options[style_choice]['stops']]}
88
+
89
+
90
+
91
+ ######################## SUMMARY & DATA FUNCTIONS
92
+ def get_summary(ca, combined_filter, column, main_group, colors = None):
93
+ """
94
+ Computes summary statistics for the filtered dataset.
95
+ """
96
+ df = ca.filter(combined_filter)
97
+
98
+ #total acres for each group
99
+ group_totals = df.group_by(main_group).aggregate(total_acres=_.acres.sum())
100
+ df = (df.group_by(*column)
101
+ .aggregate(percent_CA=(_.acres.sum() / ca_area_acres),
102
+ acres=_.acres.sum(),
103
+ mean_richness=(_.richness * _.acres).sum() / _.acres.sum(),
104
+ mean_rsr=(_.rsr * _.acres).sum() / _.acres.sum(),
105
+ mean_irrecoverable_carbon=(_.irrecoverable_carbon * _.acres).sum() / _.acres.sum(),
106
+ mean_manageable_carbon=(_.manageable_carbon * _.acres).sum() / _.acres.sum(),
107
+ mean_fire=(_.fire * _.acres).sum()/_.acres.sum(),
108
+ mean_rxburn=(_.rxburn * _.acres).sum()/_.acres.sum(),
109
+ mean_disadvantaged=(_.disadvantaged_communities * _.acres).sum() / _.acres.sum(),
110
+ mean_svi=(_.svi * _.acres).sum() / _.acres.sum())
111
+ .mutate(percent_CA=_.percent_CA.round(3), acres=_.acres.round(0)))
112
+ df = df.inner_join(group_totals, main_group).mutate(percent_group=( _.acres / _.total_acres).round(3))
113
+ if colors is not None and not colors.empty:
114
+ df = df.inner_join(colors, column[-1])
115
+ return df.cast({col: "string" for col in column}).execute()
116
+
117
+ def get_summary_table(ca, column, select_colors, color_choice, filter_cols, filter_vals, colorby_vals):
118
+ """
119
+ Generates summary tables for visualization and reporting.
120
+ """
121
+ colors = color_table(select_colors, color_choice, column)
122
+
123
+ #if a filter is selected, add to list of filters
124
+ filters = [getattr(_, col).isin(vals) for col, vals in zip(filter_cols, filter_vals) if vals]
125
 
126
+ #show color_by column in table by adding it as a filter (if it's not already a filter)
127
+ if column not in filter_cols:
128
+ filter_cols.append(column)
129
+ filters.append(getattr(_, column).isin(colorby_vals[column]))
130
 
131
+ #combining all the filters into ibis filter expression
132
+ combined_filter = reduce(lambda x, y: x & y, filters)
133
+ only_conserved = combined_filter & (_.status.isin(['30x30-conserved']))
134
 
135
+ # df used for percentage, excludes non-conserved.
136
+ df_percent = get_summary(ca, only_conserved, [column], column, colors)
 
 
 
 
 
137
 
138
+ #df used for printed table
139
+ df_tab = get_summary(ca, combined_filter, filter_cols, column, colors=None)
140
+ if "non-conserved" in chain.from_iterable(filter_vals):
141
+ combined_filter = combined_filter | (_.status.isin(['non-conserved']))
142
 
143
+ # df used for charts
144
+ df = get_summary(ca, combined_filter, [column], column, colors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
+ # df for stacked 30x30 status bar chart
147
+ df_bar_30x30 = None if column in ["status", "gap_code"] else get_summary(ca, combined_filter, [column, 'status'], column, color_table(select_colors, "30x30 Status", 'status'))
148
+ return df, df_tab, df_percent, df_bar_30x30
149
 
150
 
151
+ def get_summary_table_sql(ca, column, colors, ids):
152
+ """
153
+ Generates a summary table using specific IDs as filters.
154
+ """
155
+ combined_filter = _.id.isin(ids)
156
+ return get_summary(ca, combined_filter, [column], column, colors)
157
+
158
+
159
+ ######################## MAP STYLING FUNCTIONS
160
  def get_pmtiles_style(paint, alpha, filter_cols, filter_vals):
161
+ """
162
+ Generates a MapLibre GL style for PMTiles with specified filters.
163
+ """
164
+ filters = [["match", ["get", col], val, True, False] for col, val in zip(filter_cols, filter_vals)]
165
+ combined_filters = ["all", *filters]
166
+
167
+ if "non-conserved" in chain.from_iterable(filter_vals):
168
+ combined_filters = ["any", combined_filters, ["match", ["get", "status"], ["non-conserved"], True, False]]
169
+
170
+ return {
171
  "version": 8,
172
+ "sources": {"ca": {"type": "vector", "url": f"pmtiles://{ca_pmtiles}"}},
 
 
 
 
 
173
  "layers": [
174
  {
175
  "id": "ca30x30",
 
177
  "source-layer": "ca30x30",
178
  "type": "fill",
179
  "filter": combined_filters,
180
+ "paint": {"fill-color": paint, "fill-opacity": alpha},
 
 
 
181
  }
182
+ ],
183
  }
 
 
184
 
185
  def get_pmtiles_style_llm(paint, ids):
186
+ """
187
+ Generates a MapLibre GL style for PMTiles using specific IDs as filters.
188
+ """
189
+ return {
190
  "version": 8,
191
+ "sources": {"ca": {"type": "vector", "url": f"pmtiles://{ca_pmtiles}"}},
 
 
 
 
 
192
  "layers": [
193
  {
194
  "id": "ca30x30",
195
  "source": "ca",
196
  "source-layer": "ca30x30",
197
  "type": "fill",
198
+ "filter": ["in", ["get", "id"], ["literal", ids]],
199
+ # "filter": ["all", ["match", ["get", "id"], ids, True, False]],
200
+ "paint": {"fill-color": paint, "fill-opacity": 1},
 
 
201
  }
202
+ ],
203
+ }
204
+
205
+ def get_legend(style_options, color_choice):
206
+ """
207
+ Generates a legend dictionary with color mapping and formatting adjustments.
208
+ """
209
+ legend = {cat: color for cat, color in style_options[color_choice]['stops']}
210
+ position, fontsize, bg_color = 'bottom-left', 15, 'white'
211
+
212
+ # shorten legend for ecoregions
213
+ if color_choice == "Ecoregion":
214
+ legend = {key.replace("Northern California", "NorCal"): value for key, value in legend.items()}
215
+ legend = {key.replace("Southern California", "SoCal"): value for key, value in legend.items()}
216
+ legend = {key.replace("Southeastern", "SE."): value for key, value in legend.items()}
217
+ legend = {key.replace("and", "&"): value for key, value in legend.items()}
218
+ legend = {key.replace("California", "CA"): value for key, value in legend.items()}
219
+ legend = {key.replace("Northwestern", "NW."): value for key, value in legend.items()}
220
+ bg_color = 'rgba(255, 255, 255, 0.6)'
221
+ fontsize = 12
222
+ return legend, position, bg_color, fontsize
223
+
224
+
225
+
226
+
227
+ ######################## CHART FUNCTIONS
228
+ def area_chart(df, column):
229
+ """
230
+ Generates an Altair pie chart representing the percentage of protected areas.
231
+ """
232
+ base = alt.Chart(df).encode(alt.Theta("percent_CA:Q").stack(True))
233
+ pie = (
234
+ base.mark_arc(innerRadius=40, outerRadius=100, stroke="black", strokeWidth=0.1)
235
+ .encode(
236
+ alt.Color("color:N").scale(None).legend(None),
237
+ tooltip=[
238
+ alt.Tooltip(column, type="nominal"),
239
+ alt.Tooltip("percent_CA", type="quantitative", format=",.1%"),
240
+ alt.Tooltip("acres", type="quantitative", format=",.0f"),
241
+ ]
242
+ )
243
+ )
244
+ return pie.properties(width="container", height=290)
245
+
246
+
247
+ def bar_chart(df, x, y, title):
248
+ """Creates a simple bar chart."""
249
+ return create_bar_chart(df, x, y, title)
250
+
251
+ def stacked_bar(df, x, y, color, title, colors):
252
+ """Creates a stacked bar chart."""
253
+ return create_bar_chart(df, x, y, title, color=color, stacked=True, colors=colors)
254
+
255
+
256
+ def get_chart_settings(x, stacked):
257
+ """
258
+ Returns sorting, axis settings, and y-axis title mappings.
259
+ """
260
+ sort_options = {
261
+ "established": "-x",
262
+ "access_type": ["Open", "Restricted", "No Public", "Unknown"],
263
+ "easement": ["True", "False"],
264
+ "manager_type": ["Federal", "Tribal", "State", "Special District", "County", "City",
265
+ "HOA", "Joint", "Non Profit", "Private", "Unknown"],
266
+ "status": ["30x30-conserved", "other-conserved", "unknown", "non-conserved"],
267
+ "ecoregion": ['SE. Great Basin', 'Mojave Desert', 'Sonoran Desert', 'Sierra Nevada',
268
+ 'SoCal Mountains & Valleys', 'Mono', 'Central CA Coast', 'Klamath Mountains',
269
+ 'NorCal Coast', 'NorCal Coast Ranges', 'NW. Basin & Range', 'Colorado Desert',
270
+ 'Central Valley Coast Ranges', 'SoCal Coast', 'Sierra Nevada Foothills',
271
+ 'Southern Cascades', 'Modoc Plateau', 'Great Valley (North)',
272
+ 'NorCal Interior Coast Ranges', 'Great Valley (South)']
273
  }
 
274
 
275
+ y_titles = {
276
+ "ecoregion": "Ecoregion (%)", "established": "Year (%)",
277
+ "manager_type": "Manager Type (%)", "easement": "Easement (%)",
278
+ "access_type": "Access (%)", "mean_richness": "Richness (Mean)",
279
+ "mean_rsr": "Range-Size Rarity (Mean)", "mean_irrecoverable_carbon": "Irrecoverable Carbon (Mean)",
280
+ "mean_manageable_carbon": "Manageable Carbon (Mean)", "mean_disadvantaged": "Disadvantaged (Mean)",
281
+ "mean_svi": "SVI (Mean)", "mean_fire": "Fire (Mean)", "mean_rxburn": "Rx Fire (Mean)"
282
+ }
283
+
284
+ angle = 270 if x in ["manager_type", "ecoregion"] else 0
285
+ height = 250 if stacked else 400 if x == "ecoregion" else 350 if x == "manager_type" else 300
286
+
287
+ return sort_options.get(x, "x"), angle, height, y_titles.get(x, x)
288
+
289
+
290
+ def get_label_transform(x, label=None):
291
+ """
292
+ Returns label transformation logic for Altair expressions and manual label conversion.
293
+ """
294
+ transformations = {
295
+ "access_type": ("replace(datum.access_type, ' Access', '')", lambda lbl: lbl.replace(" Access", "")),
296
+ "ecoregion": (
297
+ "replace(replace(replace(replace(replace("
298
+ "replace(datum.ecoregion, 'Northern California', 'NorCal'),"
299
+ "'Southern California', 'SoCal'),"
300
+ "'Southeastern', 'SE.'),"
301
+ "'Northwestern', 'NW.'),"
302
+ "'and', '&'),"
303
+ "'California', 'CA')",
304
+ lambda lbl: (lbl.replace("Northern California", "NorCal")
305
+ .replace("Southern California", "SoCal")
306
+ .replace("Southeastern", "SE.")
307
+ .replace("Northwestern", "NW.")
308
+ .replace("and", "&")
309
+ .replace("California", "CA"))
310
+ )
311
+ }
312
+ if label is not None:
313
+ return transformations.get(x, (None, lambda lbl: lbl))[1](label)
314
+
315
+ return transformations.get(x, (f"datum.{x}", None))[0]
316
+
317
+ def get_hex(df, color, sort_order):
318
+ """
319
+ Returns a list of hex color codes sorted based on `sort_order`.
320
+ """
321
+ return list(df.drop_duplicates(subset=color, keep="first")
322
+ .set_index(color)
323
+ .reindex(sort_order)
324
+ .dropna()["color"])
325
+
326
+
327
+ def create_bar_chart(df, x, y, title, color=None, stacked=False, colors=None):
328
+ """
329
+ Generalized function to create a bar chart, supporting both standard and stacked bars.
330
+ """
331
+ # helper functions
332
+ sort, angle, height, y_title = get_chart_settings(x,stacked)
333
+ label_transform = get_label_transform(x)
334
+
335
+ # create base chart
336
+ chart = (
337
+ alt.Chart(df)
338
+ .mark_bar(stroke="black", strokeWidth=0.1)
339
+ .transform_calculate(xlabel=label_transform)
340
+ .encode(
341
+ x=alt.X("xlabel:N", sort=sort,
342
+ axis=alt.Axis(labelAngle=angle, title=None, labelLimit=200)),
343
+ y=alt.Y(y, axis=alt.Axis(title=y_title, offset = -5)),
344
+ tooltip=[alt.Tooltip(x, type="nominal"), alt.Tooltip(y, type="quantitative")]
345
+ )
346
+ .properties(width="container", height=height)
347
+
348
+ )
349
+
350
+ if stacked:
351
+ # order stacks
352
+ sort_order = ["30x30-conserved", "other-conserved", "unknown", "non-conserved"]
353
+ color_hex = get_hex(df[[color, "color"]], color, sort_order)
354
+ sort_order = sort_order[:len(color_hex)]
355
+ df["stack_order"] = df[color].apply(lambda val: sort_order.index(val) if val in sort_order else len(sort_order))
356
+
357
+ # build chart
358
+ chart = chart.encode(
359
+ x=alt.X("xlabel:N", sort=sort, title=None, axis=alt.Axis(labels=False)),
360
+ y=alt.Y(y, axis=alt.Axis(title=y_title, offset = -5),scale = alt.Scale(domain = [0,1])),
361
+
362
+ color=alt.Color(color, sort=sort_order, scale=alt.Scale(domain=sort_order, range=color_hex)) ,
363
+ order=alt.Order("stack_order:Q", sort="ascending"),
364
+ tooltip=[
365
+ alt.Tooltip(x, type="nominal"),
366
+ alt.Tooltip(color, type="nominal"),
367
+ alt.Tooltip("percent_group", type="quantitative", format=",.1%"),
368
+ alt.Tooltip("acres", type="quantitative", format=",.0f"),
369
+ ],
370
+ )
371
+
372
+ # use shorter label names (to save space)
373
+ labels_df = colors.copy()
374
+ labels_df["xlabel"] = [get_label_transform(x, str(lab)) for lab in colors[x]]
375
+
376
+ # create symbols/label below chart; dots match map colors.
377
+ symbol_layer = (
378
+ alt.Chart(labels_df)
379
+ .mark_point(filled=True, shape="circle", size=100, tooltip=False, yOffset=5)
380
+ .encode(
381
+ x=alt.X("xlabel:N", sort=sort,
382
+ axis=alt.Axis(labelAngle=angle, title=None, labelLimit=200)),
383
+ color=alt.Color("color:N", scale=None),
384
+ )
385
+ .properties(height=1, width="container")
386
+ )
387
+
388
+ # append symbols below base chart
389
+ final_chart = alt.vconcat(chart, symbol_layer, spacing=8).resolve_scale(x="shared")
390
+
391
+
392
+ else: #if not stacked, do single chart
393
+ final_chart = chart.encode(
394
+ color=alt.Color("color").scale(None)
395
+ )
396
+
397
+ # customize chart
398
+ final_chart = final_chart.properties(
399
+ title=title
400
+ ).configure_legend(
401
+ symbolStrokeWidth=0.1, direction="horizontal", orient="top",
402
+ columns=2, title=None, labelOffset=2, offset=5,
403
+ symbolType="square", labelFontSize=13,
404
+ ).configure_title(
405
+ fontSize=18, align="center", anchor="middle", offset = 10
406
+ )
407
+
408
+ return final_chart