wack0 commited on
Commit
79fb6ab
·
verified ·
1 Parent(s): 7c29d0b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +191 -119
app.py CHANGED
@@ -13,8 +13,7 @@ async def _():
13
  import marimo as mo
14
  import pandas as pd
15
 
16
- # If running inside the browser (WebAssembly), explicitly download openpyxl first!
17
- if "pyodide" in sys.modules:
18
  import micropip
19
  await micropip.install("openpyxl")
20
 
@@ -23,75 +22,62 @@ async def _():
23
  return io, math, mo, pd
24
 
25
 
26
- @app.cell
27
- def _():
28
- # TO DO:
29
- # - on click: show a list of sites where that species type occurs
30
- # - add axis on the seperation between species groups that shows the amount of sites the species occur in
31
- # - maybe line up each species group
32
- # - put the visual on a html site instead of here in the notebook
33
- return
34
-
35
-
36
  @app.cell
37
  def _(mo):
38
- # Create file upload buttons for the WASM environment
39
- upload_yield = mo.ui.file(
40
- label="Upload: Coffee_yield.xlsx", kind="button", multiple=False)
41
- upload_species = mo.ui.file(
42
- label="Upload: Plant_species_and_average...xlsx", kind="button", multiple=False)
43
- upload_decomp = mo.ui.file(
44
- label="Upload: Total_species_composition.xlsx", kind="button", multiple=False)
45
-
46
- # Assign the layout to a variable
47
  upload_ui = mo.vstack([
48
- mo.md("### 📁 Please provide your data files to view the dashboard"),
49
  upload_yield,
50
  upload_species,
51
  upload_decomp
52
  ], align="center")
53
 
54
- # Place it as the last line so Marimo renders it to the screen!
55
  upload_ui
56
- return upload_decomp, upload_species, upload_yield, upload_ui
57
 
58
 
59
  @app.cell
60
  def _(io, mo, pd, upload_decomp, upload_species, upload_yield):
61
- # Pause execution of this cell (and everything below it) until files are uploaded
62
  mo.stop(
63
  not upload_yield.value or not upload_species.value or not upload_decomp.value,
64
  mo.md("*Waiting for all three files to be uploaded...*")
65
  )
66
 
67
- # Read the uploaded files directly from browser memory
 
68
  df_yield = pd.read_excel(io.BytesIO(upload_yield.value[0].contents))
69
  df_species = pd.read_excel(io.BytesIO(upload_species.value[0].contents))
70
- df_decomposition = pd.read_excel(
71
- io.BytesIO(upload_decomp.value[0].contents))
72
 
 
73
  COL_SPECIES_NAME = "Species name"
74
  COL_SPECIES_YIELD = "Average coffee yield (kg ha-1)"
75
  COL_SPECIES_GROUP = "Species group"
76
  COL_DECOMP_SPECIES = df_decomposition.columns[0]
77
 
78
- # Forward fill empty group cells
79
  df_species[COL_SPECIES_GROUP] = df_species[COL_SPECIES_GROUP].ffill()
80
  GROUPS = df_species[COL_SPECIES_GROUP].dropna().unique().tolist()
81
 
82
- # Set index to species for easy row lookups
83
  df_decomposition.set_index(COL_DECOMP_SPECIES, inplace=True)
84
  ALL_SITES = [str(col) for col in df_decomposition.columns]
85
 
86
- # Build the species_data dictionary
 
87
  species_data = []
88
-
89
  for idx, row in df_species.iterrows():
90
  sp_id = str(row[COL_SPECIES_NAME])
91
  avg_yield = float(row[COL_SPECIES_YIELD])
92
 
93
- group = str(row[COL_SPECIES_GROUP]
94
- ) if COL_SPECIES_GROUP in df_species.columns else GROUPS[idx % len(GROUPS)]
95
 
96
  present_in = []
97
 
@@ -104,29 +90,41 @@ def _(io, mo, pd, upload_decomp, upload_species, upload_yield):
104
  "id": sp_id,
105
  "group": group,
106
  "yield": avg_yield,
107
- "num_sites": len(present_in),
108
  "sites": present_in
109
  })
110
 
111
- # Sort species from most common (center) to least common (edges)
112
  species_data.sort(key=lambda x: x["num_sites"], reverse=True)
113
  return ALL_SITES, GROUPS, species_data
114
 
115
 
116
  @app.cell
117
  def _(ALL_SITES, mo, species_data):
118
- # Setup Marimo UI Controls
119
  total_species = len(species_data)
120
 
121
  slider_count = mo.ui.slider(
122
- start=5, stop=total_species, step=1, value=total_species, label="Species shown:")
 
 
123
  drop_single = mo.ui.dropdown(
124
- options=ALL_SITES, value=ALL_SITES[0] if ALL_SITES else "", label="Highlight Site:")
 
 
125
  drop_comp1 = mo.ui.dropdown(
126
- options=ALL_SITES, value=ALL_SITES[0] if ALL_SITES else "", label="Compare Site 1:")
127
- drop_comp2 = mo.ui.dropdown(options=ALL_SITES, value=ALL_SITES[1] if len(
128
- ALL_SITES) > 1 else ALL_SITES[0], label="Compare Site 2:")
129
 
 
 
 
 
 
 
 
 
 
130
  dropdown_styles = mo.Html("""
131
  <style>
132
  .marimo-dropdown select, select {
@@ -170,57 +168,42 @@ def _(ALL_SITES, mo, species_data):
170
  </style>
171
  """)
172
 
 
173
  tabs = mo.ui.tabs({
174
  "General Overview": mo.md("*Viewing all species colored by their primary group.*"),
175
  "Individual Site": drop_single,
176
  "Compare Sites": mo.hstack([drop_comp1, drop_comp2], gap=4)
177
  })
178
 
 
179
  controls = mo.vstack([
180
  dropdown_styles,
181
  tabs,
182
  mo.hstack([slider_count], justify="center")
183
  ], align="center", gap=4)
184
- return controls, drop_comp1, drop_comp2, drop_single, slider_count, tabs
 
185
 
186
 
187
  @app.cell
188
- def _(
189
- GROUPS,
190
- controls,
191
- drop_comp1,
192
- drop_comp2,
193
- drop_single,
194
- math,
195
- mo,
196
- slider_count,
197
- species_data,
198
- tabs,
199
- ):
200
- active_data = species_data[:slider_count.value]
201
  CX, CY = 500, 380
202
  MAX_RADIUS = 370
203
  MIN_RADIUS = 80
204
-
205
- # ── Define Tooltip Dimensions Globally ──
206
  TW, TH = 245, 105
207
 
208
- # ── Plant Group Color Update ──
209
  preferred_hues = [30, 120, 210]
210
- group_hues = {
211
- group_name: preferred_hues[i % len(preferred_hues)]
212
- for i, group_name in enumerate(GROUPS)
213
- }
214
-
215
- # ── Site Comparison Color Updates ──
216
- SITE_LEGEND_BKG = "#1e1e2e" # Legend card background
217
- SITE_LEGEND_BORDER = "#45475a" # Legend card border
218
- SITE_LEGEND_TEXT = "#cdd6f4" # Legend text color
219
- SITE_COLOR_S1_ONLY = "#f5b0c6" # Softer Pink (Site 1 only)
220
- SITE_COLOR_S2_ONLY = "#d8b4fe" # Lighter Purple (Site 2 only)
221
- SITE_COLOR_BOTH = "#f9e2af" # Light Yellow (In both)
222
- SITE_COLOR_NEITHER = "#e0e0e0" # Light Grey (In neither)
223
-
224
  def polar_to_cartesian(cx, cy, r, angle_deg):
225
  rad = math.radians(angle_deg)
226
  return cx + r * math.cos(rad), cy + r * math.sin(rad)
@@ -236,10 +219,11 @@ def _(
236
  return f"M {p1[0]} {p1[1]} A {r_outer} {r_outer} 0 {large_arc} 1 {p2[0]} {p2[1]} L {p3[0]} {p3[1]} A {r_inner} {r_inner} 0 {large_arc} 0 {p4[0]} {p4[1]} Z"
237
 
238
  def make_tooltip(index, tx, ty, group_name, species_name, avg_yield, num_sites):
 
239
  tx = max(10, min(tx, 1000 - TW - 10))
240
  ty = max(10, min(ty, 1000 - TH - 10))
241
  safe_group = group_name.strip().upper()[:40]
242
- safe_name = species_name.strip()[:38]
243
  yield_str = f"Average yield: {avg_yield:.1f} kg ha\u207b\u00b9"
244
  sites_str = f"Occurs in: {num_sites} site(s)"
245
  return f"""
@@ -258,35 +242,94 @@ def _(
258
  fill="#f9e2af">{sites_str}</text>
259
  </g>"""
260
 
261
- path_elements = []
262
- tooltip_elements = []
263
- css_hover_rules = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  grouped_data = {g: [] for g in GROUPS}
266
  for s in active_data:
267
  grouped_data[s["group"]].append(s)
 
268
 
269
- active_tab = tabs.value
270
- seg_index = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
  for group_idx, group_name in enumerate(GROUPS):
273
  group_items = grouped_data[group_name]
274
  if not group_items:
275
  continue
276
- base_angle = group_idx * 120
 
277
  tiers = {}
278
  for item in group_items:
279
  sites = item["num_sites"]
280
  if sites not in tiers:
281
  tiers[sites] = []
282
  tiers[sites].append(item)
 
283
  sorted_tier_keys = sorted(tiers.keys(), reverse=True)
284
  num_tiers = len(sorted_tier_keys)
285
  ring_thickness = (MAX_RADIUS - MIN_RADIUS) / max(1, num_tiers)
286
 
287
  for tier_idx, sites_key in enumerate(sorted_tier_keys):
288
  tier_items = tiers[sites_key]
289
- r_inner = MIN_RADIUS + (tier_idx * ring_thickness)
290
  r_outer = r_inner + ring_thickness
291
  r_center = (r_inner + r_outer) / 2
292
  total_yield = sum(item["yield"] for item in tier_items)
@@ -297,49 +340,41 @@ def _(
297
  sweep_angle = (item["yield"] / total_yield) * 120
298
  else:
299
  sweep_angle = 120 / len(tier_items)
 
300
  end_angle = current_start_angle + sweep_angle
301
  opacity = 1.0
302
  fill_color = ""
303
 
304
- if active_tab == "Compare Sites":
305
  in_s1 = drop_comp1.value in item["sites"]
306
  in_s2 = drop_comp2.value in item["sites"]
307
- if in_s1 and in_s2:
308
- fill_color = SITE_COLOR_BOTH
309
- elif in_s1:
310
- fill_color = SITE_COLOR_S1_ONLY
311
- elif in_s2:
312
- fill_color = SITE_COLOR_S2_ONLY
313
- else:
314
- fill_color = SITE_COLOR_NEITHER
315
  elif active_tab == "Individual Site":
316
  hue = group_hues[group_name]
317
- lightness = 85 - \
318
- min(50, (item["yield"] / max(1, total_yield)) * 45)
319
  fill_color = f"hsl({hue}, 70%, {lightness}%)"
320
  if drop_single.value not in item["sites"]:
321
  opacity = 0.15
322
  else:
323
  hue = group_hues[group_name]
324
- lightness = 85 - \
325
- min(50, (item["yield"] / max(1, total_yield)) * 45)
326
  fill_color = f"hsl({hue}, 70%, {lightness}%)"
327
 
328
- path_d = build_arc(CX, CY, r_inner, r_outer,
329
- current_start_angle, end_angle)
330
  angle_center = current_start_angle + sweep_angle / 2
331
 
332
- path_elements.append(
333
  f'<path id="seg-{seg_index}" d="{path_d}" '
334
  f'fill="{fill_color}" opacity="{opacity}" '
335
  f'stroke="white" stroke-width="2" cursor="pointer"/>'
336
  )
337
 
338
  tip_r = r_outer + 20
339
- tip_cx, tip_cy = polar_to_cartesian(
340
- CX, CY, tip_r, angle_center)
341
-
342
- tx = tip_cx if tip_cx < CX else tip_cx - TW
343
  ty = tip_cy - TH / 2
344
 
345
  tooltip_elements.append(
@@ -347,6 +382,7 @@ def _(
347
  item["id"], item["yield"], item["num_sites"])
348
  )
349
 
 
350
  css_hover_rules.append(
351
  f"svg:has(#seg-{seg_index}:hover) #seg-{seg_index} "
352
  f"{{ filter: brightness(1.35) drop-shadow(0 0 7px rgba(255,255,255,0.6)); }}\n"
@@ -356,9 +392,31 @@ def _(
356
 
357
  current_start_angle = end_angle
358
  seg_index += 1
 
 
359
 
360
- # ── legend for Compare Sites tab ───────────────────────────
361
- if active_tab == "Compare Sites":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  site1_label = drop_comp1.value
363
  site2_label = drop_comp2.value
364
 
@@ -371,10 +429,10 @@ def _(
371
 
372
  LX, LY = 0, 0 # top-left corner of the legend box
373
  LW, LH = 210, 150 # box dimensions
374
- ROW_H = 26 # vertical spacing between rows
375
- SWATCH_S = 14 # swatch square size
376
 
377
- path_elements.append(
378
  f'<g id="compare-legend" style="pointer-events:none;">'
379
  f'<rect x="{LX}" y="{LY}" width="{LW}" height="{LH}" rx="10" ry="10" '
380
  f'fill="{SITE_LEGEND_BKG}" stroke="{SITE_LEGEND_BORDER}" stroke-width="1.5" '
@@ -388,35 +446,49 @@ def _(
388
 
389
  for i, (color, label) in enumerate(legend_items):
390
  row_y = LY + 42 + i * ROW_H
391
- path_elements.append(
392
  f'<rect x="{LX+12}" y="{row_y}" width="{SWATCH_S}" height="{SWATCH_S}" '
393
  f'rx="3" fill="{color}" stroke="white" stroke-width="1"/>'
394
  f'<text x="{LX+12+SWATCH_S+10}" y="{row_y+11}" '
395
  f'font-family="Inter,sans-serif" font-size="12" fill="{SITE_LEGEND_TEXT}">{label}</text>'
396
  )
 
397
 
398
- path_elements.append('</g>')
399
-
400
- # Dividers
401
  for i in range(3):
402
  angle = i * 120
403
  p1 = polar_to_cartesian(CX, CY, MIN_RADIUS, angle)
404
  p2 = polar_to_cartesian(CX, CY, MAX_RADIUS, angle)
405
- path_elements.append(
406
  f'<line x1="{p1[0]}" y1="{p1[1]}" x2="{p2[0]}" y2="{p2[1]}" '
407
  f'stroke="#fff" stroke-width="4" style="pointer-events:none;"/>'
408
  )
409
 
410
- # Group labels
411
  lbl_1 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 60)
412
  lbl_2 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 180)
413
  lbl_3 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 300)
414
- path_elements.extend([
415
  f'<text x="{lbl_1[0]}" y="{lbl_1[1]}" font-family="sans-serif" font-weight="bold" fill="black" text-anchor="middle" style="pointer-events:none;">Woody vascular plants</text>',
416
  f'<text x="{lbl_2[0]}" y="{lbl_2[1]}" font-family="sans-serif" font-weight="bold" fill="black" text-anchor="middle" style="pointer-events:none;">Non-woody vascular plants</text>',
417
  f'<text x="{lbl_3[0]}" y="{lbl_3[1]}" font-family="sans-serif" font-weight="bold" fill="black" text-anchor="middle" style="pointer-events:none;">Bryophytes</text>',
418
  ])
 
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  css = f"""
421
  <style>
422
  .tip {{ visibility: hidden; pointer-events: none; }}
@@ -425,6 +497,7 @@ def _(
425
  </style>
426
  """
427
 
 
428
  svg_markup = f"""
429
  {css}
430
  <svg width="1000" height="1000" viewBox="0 0 1000 1000"
@@ -437,17 +510,16 @@ def _(
437
  </defs>
438
 
439
  <circle cx="{CX}" cy="{CY}" r="{MIN_RADIUS}" fill="#f4f4f4" stroke="none"/>
440
- {"".join(path_elements)}
441
-
 
 
 
442
  {"".join(tooltip_elements)}
443
-
444
  </svg>
445
  """
446
 
447
- # Assign layout to variable
448
  final_dashboard = mo.vstack([controls, mo.Html(svg_markup)], align="center", gap=8)
449
-
450
- # Place as the last line so Marimo renders it to the screen!
451
  final_dashboard
452
  return final_dashboard,
453
 
 
13
  import marimo as mo
14
  import pandas as pd
15
 
16
+ if "pyodide" in sys.modules: # this is for running the resulting html visual in a browser (WebAssembly)
 
17
  import micropip
18
  await micropip.install("openpyxl")
19
 
 
22
  return io, math, mo, pd
23
 
24
 
 
 
 
 
 
 
 
 
 
 
25
  @app.cell
26
  def _(mo):
27
+ # Buttons to upload the data files required for the visualisation
28
+ upload_yield = mo.ui.file(label="Upload: Coffee_yield.xlsx", kind="button", multiple=False)
29
+ upload_species = mo.ui.file(label="Upload: Plant_species_and_average...xlsx", kind="button", multiple=False)
30
+ upload_decomp = mo.ui.file(label="Upload: Total_species_composition.xlsx", kind="button", multiple=False)
31
+
32
+ # Assign to variable and display the UI
 
 
 
33
  upload_ui = mo.vstack([
34
+ mo.md("### Please provide the correct data files to view the visual"),
35
  upload_yield,
36
  upload_species,
37
  upload_decomp
38
  ], align="center")
39
 
40
+ # Place as the last statement to ensure Marimo renders it!
41
  upload_ui
42
+ return upload_decomp, upload_species, upload_ui, upload_yield
43
 
44
 
45
  @app.cell
46
  def _(io, mo, pd, upload_decomp, upload_species, upload_yield):
47
+ # Exectution of this cell and everything below is paused until all files are uploaded
48
  mo.stop(
49
  not upload_yield.value or not upload_species.value or not upload_decomp.value,
50
  mo.md("*Waiting for all three files to be uploaded...*")
51
  )
52
 
53
+ ###### PREPARATION ######
54
+ # Read the uploaded files from browser memory
55
  df_yield = pd.read_excel(io.BytesIO(upload_yield.value[0].contents))
56
  df_species = pd.read_excel(io.BytesIO(upload_species.value[0].contents))
57
+ df_decomposition = pd.read_excel(io.BytesIO(upload_decomp.value[0].contents))
 
58
 
59
+ # Hardcoded column names used in the data files
60
  COL_SPECIES_NAME = "Species name"
61
  COL_SPECIES_YIELD = "Average coffee yield (kg ha-1)"
62
  COL_SPECIES_GROUP = "Species group"
63
  COL_DECOMP_SPECIES = df_decomposition.columns[0]
64
 
65
+ # In `Plant_species_and_average_yield.xlsx`, empty group cells are filled
66
  df_species[COL_SPECIES_GROUP] = df_species[COL_SPECIES_GROUP].ffill()
67
  GROUPS = df_species[COL_SPECIES_GROUP].dropna().unique().tolist()
68
 
69
+ # In `Total_species_decomposition.xlsx`, set index to species for easy row lookups
70
  df_decomposition.set_index(COL_DECOMP_SPECIES, inplace=True)
71
  ALL_SITES = [str(col) for col in df_decomposition.columns]
72
 
73
+ ###### DATA EXTRACTION/PARSING ######
74
+ # Build the species_data dictionary >> dictionary used to build the visual later
75
  species_data = []
 
76
  for idx, row in df_species.iterrows():
77
  sp_id = str(row[COL_SPECIES_NAME])
78
  avg_yield = float(row[COL_SPECIES_YIELD])
79
 
80
+ group = str(row[COL_SPECIES_GROUP]) if COL_SPECIES_GROUP in df_species.columns else GROUPS[idx % len(GROUPS)]
 
81
 
82
  present_in = []
83
 
 
90
  "id": sp_id,
91
  "group": group,
92
  "yield": avg_yield,
93
+ "num_sites": len(present_in),
94
  "sites": present_in
95
  })
96
 
97
+ # Sort species from most common (center) to least common (edge)
98
  species_data.sort(key=lambda x: x["num_sites"], reverse=True)
99
  return ALL_SITES, GROUPS, species_data
100
 
101
 
102
  @app.cell
103
  def _(ALL_SITES, mo, species_data):
104
+ # Create UI controls
105
  total_species = len(species_data)
106
 
107
  slider_count = mo.ui.slider(
108
+ start=5, stop=total_species, step=1, value=int(total_species/2), label="Species shown:"
109
+ )
110
+
111
  drop_single = mo.ui.dropdown(
112
+ options=ALL_SITES, value=ALL_SITES[0] if ALL_SITES else "", label="Highlight Site:"
113
+ )
114
+
115
  drop_comp1 = mo.ui.dropdown(
116
+ options=ALL_SITES, value=ALL_SITES[0] if ALL_SITES else "", label="Compare Site 1:"
117
+ )
 
118
 
119
+ drop_comp2 = mo.ui.dropdown(
120
+ options=ALL_SITES, value=ALL_SITES[1] if len(ALL_SITES) > 1 else ALL_SITES[0], label="Compare Site 2:"
121
+ )
122
+ return drop_comp1, drop_comp2, drop_single, slider_count
123
+
124
+
125
+ @app.cell
126
+ def _(drop_comp1, drop_comp2, drop_single, mo, slider_count):
127
+ # CSS styling
128
  dropdown_styles = mo.Html("""
129
  <style>
130
  .marimo-dropdown select, select {
 
168
  </style>
169
  """)
170
 
171
+ # Group the UI elements into interactive tabs
172
  tabs = mo.ui.tabs({
173
  "General Overview": mo.md("*Viewing all species colored by their primary group.*"),
174
  "Individual Site": drop_single,
175
  "Compare Sites": mo.hstack([drop_comp1, drop_comp2], gap=4)
176
  })
177
 
178
+ # Build the final control panel
179
  controls = mo.vstack([
180
  dropdown_styles,
181
  tabs,
182
  mo.hstack([slider_count], justify="center")
183
  ], align="center", gap=4)
184
+
185
+ return controls, tabs
186
 
187
 
188
  @app.cell
189
+ def _(math):
190
+ # Sunburst chart: dimensions + position
 
 
 
 
 
 
 
 
 
 
 
191
  CX, CY = 500, 380
192
  MAX_RADIUS = 370
193
  MIN_RADIUS = 80
 
 
194
  TW, TH = 245, 105
195
 
196
+ # Colors
197
  preferred_hues = [30, 120, 210]
198
+ SITE_LEGEND_BKG = "#1e1e2e"
199
+ SITE_LEGEND_BORDER = "#45475a"
200
+ SITE_LEGEND_TEXT = "#cdd6f4"
201
+ SITE_COLOR_S1_ONLY = "#f5b0c6"
202
+ SITE_COLOR_S2_ONLY = "#d8b4fe"
203
+ SITE_COLOR_BOTH = "#f9e2af"
204
+ SITE_COLOR_NEITHER = "#e0e0e0"
205
+
206
+ # Helper functions
 
 
 
 
 
207
  def polar_to_cartesian(cx, cy, r, angle_deg):
208
  rad = math.radians(angle_deg)
209
  return cx + r * math.cos(rad), cy + r * math.sin(rad)
 
219
  return f"M {p1[0]} {p1[1]} A {r_outer} {r_outer} 0 {large_arc} 1 {p2[0]} {p2[1]} L {p3[0]} {p3[1]} A {r_inner} {r_inner} 0 {large_arc} 0 {p4[0]} {p4[1]} Z"
220
 
221
  def make_tooltip(index, tx, ty, group_name, species_name, avg_yield, num_sites):
222
+ # Bound the tooltip coordinates so it doesn't clip off the 1000x1000 SVG canvas
223
  tx = max(10, min(tx, 1000 - TW - 10))
224
  ty = max(10, min(ty, 1000 - TH - 10))
225
  safe_group = group_name.strip().upper()[:40]
226
+ safe_name = species_name.strip()[:38]
227
  yield_str = f"Average yield: {avg_yield:.1f} kg ha\u207b\u00b9"
228
  sites_str = f"Occurs in: {num_sites} site(s)"
229
  return f"""
 
242
  fill="#f9e2af">{sites_str}</text>
243
  </g>"""
244
 
245
+ return (
246
+ CX,
247
+ CY,
248
+ MAX_RADIUS,
249
+ MIN_RADIUS,
250
+ SITE_COLOR_BOTH,
251
+ SITE_COLOR_NEITHER,
252
+ SITE_COLOR_S1_ONLY,
253
+ SITE_COLOR_S2_ONLY,
254
+ SITE_LEGEND_BKG,
255
+ SITE_LEGEND_BORDER,
256
+ SITE_LEGEND_TEXT,
257
+ TH,
258
+ TW,
259
+ build_arc,
260
+ make_tooltip,
261
+ polar_to_cartesian,
262
+ preferred_hues,
263
+ )
264
+
265
+
266
+ @app.cell
267
+ def _(GROUPS, preferred_hues, slider_count, species_data, tabs):
268
+ # Filtering data + active tab
269
+ active_data = species_data[:slider_count.value]
270
+ active_tab = tabs.value
271
+
272
+ group_hues = {
273
+ group_name: preferred_hues[i % len(preferred_hues)]
274
+ for i, group_name in enumerate(GROUPS)
275
+ }
276
 
277
  grouped_data = {g: [] for g in GROUPS}
278
  for s in active_data:
279
  grouped_data[s["group"]].append(s)
280
+ return active_tab, group_hues, grouped_data
281
 
282
+
283
+ @app.cell
284
+ def _(
285
+ CX,
286
+ CY,
287
+ GROUPS,
288
+ MAX_RADIUS,
289
+ MIN_RADIUS,
290
+ SITE_COLOR_BOTH,
291
+ SITE_COLOR_NEITHER,
292
+ SITE_COLOR_S1_ONLY,
293
+ SITE_COLOR_S2_ONLY,
294
+ TH,
295
+ TW,
296
+ active_tab,
297
+ build_arc,
298
+ drop_comp1,
299
+ drop_comp2,
300
+ drop_single,
301
+ group_hues,
302
+ grouped_data,
303
+ make_tooltip,
304
+ polar_to_cartesian,
305
+ ):
306
+ # Build the core sunburst paths
307
+ core_paths = []
308
+ tooltip_elements = []
309
+ css_hover_rules = []
310
+
311
+ seg_index = 0 # unique id counter shared across all segments
312
 
313
  for group_idx, group_name in enumerate(GROUPS):
314
  group_items = grouped_data[group_name]
315
  if not group_items:
316
  continue
317
+
318
+ base_angle = group_idx * 120 # hardcoded 120 degree angle bcs only 3 groups
319
  tiers = {}
320
  for item in group_items:
321
  sites = item["num_sites"]
322
  if sites not in tiers:
323
  tiers[sites] = []
324
  tiers[sites].append(item)
325
+
326
  sorted_tier_keys = sorted(tiers.keys(), reverse=True)
327
  num_tiers = len(sorted_tier_keys)
328
  ring_thickness = (MAX_RADIUS - MIN_RADIUS) / max(1, num_tiers)
329
 
330
  for tier_idx, sites_key in enumerate(sorted_tier_keys):
331
  tier_items = tiers[sites_key]
332
+ r_inner = MIN_RADIUS + (tier_idx * ring_thickness) # calculate segment properties (width / radius / ...)
333
  r_outer = r_inner + ring_thickness
334
  r_center = (r_inner + r_outer) / 2
335
  total_yield = sum(item["yield"] for item in tier_items)
 
340
  sweep_angle = (item["yield"] / total_yield) * 120
341
  else:
342
  sweep_angle = 120 / len(tier_items)
343
+
344
  end_angle = current_start_angle + sweep_angle
345
  opacity = 1.0
346
  fill_color = ""
347
 
348
+ if active_tab == "Compare Sites": # coloring depends on which tab the user is viewing
349
  in_s1 = drop_comp1.value in item["sites"]
350
  in_s2 = drop_comp2.value in item["sites"]
351
+ if in_s1 and in_s2: fill_color = SITE_COLOR_BOTH
352
+ elif in_s1: fill_color = SITE_COLOR_S1_ONLY
353
+ elif in_s2: fill_color = SITE_COLOR_S2_ONLY
354
+ else: fill_color = SITE_COLOR_NEITHER
 
 
 
 
355
  elif active_tab == "Individual Site":
356
  hue = group_hues[group_name]
357
+ lightness = 85 - min(50, (item["yield"] / max(1, total_yield)) * 45)
 
358
  fill_color = f"hsl({hue}, 70%, {lightness}%)"
359
  if drop_single.value not in item["sites"]:
360
  opacity = 0.15
361
  else:
362
  hue = group_hues[group_name]
363
+ lightness = 85 - min(50, (item["yield"] / max(1, total_yield)) * 45)
 
364
  fill_color = f"hsl({hue}, 70%, {lightness}%)"
365
 
366
+ path_d = build_arc(CX, CY, r_inner, r_outer, current_start_angle, end_angle)
 
367
  angle_center = current_start_angle + sweep_angle / 2
368
 
369
+ core_paths.append(
370
  f'<path id="seg-{seg_index}" d="{path_d}" '
371
  f'fill="{fill_color}" opacity="{opacity}" '
372
  f'stroke="white" stroke-width="2" cursor="pointer"/>'
373
  )
374
 
375
  tip_r = r_outer + 20
376
+ tip_cx, tip_cy = polar_to_cartesian(CX, CY, tip_r, angle_center)
377
+ tx = tip_cx if tip_cx < CX else tip_cx - TW
 
 
378
  ty = tip_cy - TH / 2
379
 
380
  tooltip_elements.append(
 
382
  item["id"], item["yield"], item["num_sites"])
383
  )
384
 
385
+ # add hovering effect
386
  css_hover_rules.append(
387
  f"svg:has(#seg-{seg_index}:hover) #seg-{seg_index} "
388
  f"{{ filter: brightness(1.35) drop-shadow(0 0 7px rgba(255,255,255,0.6)); }}\n"
 
392
 
393
  current_start_angle = end_angle
394
  seg_index += 1
395
+ return core_paths, css_hover_rules, tooltip_elements
396
+
397
 
398
+ @app.cell
399
+ def _(
400
+ CX,
401
+ CY,
402
+ MAX_RADIUS,
403
+ MIN_RADIUS,
404
+ SITE_COLOR_BOTH,
405
+ SITE_COLOR_NEITHER,
406
+ SITE_COLOR_S1_ONLY,
407
+ SITE_COLOR_S2_ONLY,
408
+ SITE_LEGEND_BKG,
409
+ SITE_LEGEND_BORDER,
410
+ SITE_LEGEND_TEXT,
411
+ active_tab,
412
+ drop_comp1,
413
+ drop_comp2,
414
+ polar_to_cartesian,
415
+ ):
416
+ # Build annotations and legends
417
+ annotation_elements = []
418
+
419
+ if active_tab == "Compare Sites": # legend for Compare Sites tab
420
  site1_label = drop_comp1.value
421
  site2_label = drop_comp2.value
422
 
 
429
 
430
  LX, LY = 0, 0 # top-left corner of the legend box
431
  LW, LH = 210, 150 # box dimensions
432
+ ROW_H = 26 # vertical spacing between rows
433
+ SWATCH_S = 14 # swatch square size
434
 
435
+ annotation_elements.append(
436
  f'<g id="compare-legend" style="pointer-events:none;">'
437
  f'<rect x="{LX}" y="{LY}" width="{LW}" height="{LH}" rx="10" ry="10" '
438
  f'fill="{SITE_LEGEND_BKG}" stroke="{SITE_LEGEND_BORDER}" stroke-width="1.5" '
 
446
 
447
  for i, (color, label) in enumerate(legend_items):
448
  row_y = LY + 42 + i * ROW_H
449
+ annotation_elements.append(
450
  f'<rect x="{LX+12}" y="{row_y}" width="{SWATCH_S}" height="{SWATCH_S}" '
451
  f'rx="3" fill="{color}" stroke="white" stroke-width="1"/>'
452
  f'<text x="{LX+12+SWATCH_S+10}" y="{row_y+11}" '
453
  f'font-family="Inter,sans-serif" font-size="12" fill="{SITE_LEGEND_TEXT}">{label}</text>'
454
  )
455
+ annotation_elements.append('</g>')
456
 
457
+ # dividers
 
 
458
  for i in range(3):
459
  angle = i * 120
460
  p1 = polar_to_cartesian(CX, CY, MIN_RADIUS, angle)
461
  p2 = polar_to_cartesian(CX, CY, MAX_RADIUS, angle)
462
+ annotation_elements.append(
463
  f'<line x1="{p1[0]}" y1="{p1[1]}" x2="{p2[0]}" y2="{p2[1]}" '
464
  f'stroke="#fff" stroke-width="4" style="pointer-events:none;"/>'
465
  )
466
 
467
+ # group labels
468
  lbl_1 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 60)
469
  lbl_2 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 180)
470
  lbl_3 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 300)
471
+ annotation_elements.extend([
472
  f'<text x="{lbl_1[0]}" y="{lbl_1[1]}" font-family="sans-serif" font-weight="bold" fill="black" text-anchor="middle" style="pointer-events:none;">Woody vascular plants</text>',
473
  f'<text x="{lbl_2[0]}" y="{lbl_2[1]}" font-family="sans-serif" font-weight="bold" fill="black" text-anchor="middle" style="pointer-events:none;">Non-woody vascular plants</text>',
474
  f'<text x="{lbl_3[0]}" y="{lbl_3[1]}" font-family="sans-serif" font-weight="bold" fill="black" text-anchor="middle" style="pointer-events:none;">Bryophytes</text>',
475
  ])
476
+ return (annotation_elements,)
477
 
478
+
479
+ @app.cell
480
+ def _(
481
+ CX,
482
+ CY,
483
+ MIN_RADIUS,
484
+ annotation_elements,
485
+ controls,
486
+ core_paths,
487
+ css_hover_rules,
488
+ mo,
489
+ tooltip_elements,
490
+ ):
491
+ # Combine everything + visualize
492
  css = f"""
493
  <style>
494
  .tip {{ visibility: hidden; pointer-events: none; }}
 
497
  </style>
498
  """
499
 
500
+ # core + annotions are merged here
501
  svg_markup = f"""
502
  {css}
503
  <svg width="1000" height="1000" viewBox="0 0 1000 1000"
 
510
  </defs>
511
 
512
  <circle cx="{CX}" cy="{CY}" r="{MIN_RADIUS}" fill="#f4f4f4" stroke="none"/>
513
+
514
+ {"".join(core_paths)}
515
+
516
+ {"".join(annotation_elements)}
517
+
518
  {"".join(tooltip_elements)}
 
519
  </svg>
520
  """
521
 
 
522
  final_dashboard = mo.vstack([controls, mo.Html(svg_markup)], align="center", gap=8)
 
 
523
  final_dashboard
524
  return final_dashboard,
525