Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -13,8 +13,7 @@ async def _():
|
|
| 13 |
import marimo as mo
|
| 14 |
import pandas as pd
|
| 15 |
|
| 16 |
-
#
|
| 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 |
-
#
|
| 39 |
-
upload_yield = mo.ui.file(
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 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("###
|
| 49 |
upload_yield,
|
| 50 |
upload_species,
|
| 51 |
upload_decomp
|
| 52 |
], align="center")
|
| 53 |
|
| 54 |
-
# Place
|
| 55 |
upload_ui
|
| 56 |
-
return upload_decomp, upload_species,
|
| 57 |
|
| 58 |
|
| 59 |
@app.cell
|
| 60 |
def _(io, mo, pd, upload_decomp, upload_species, upload_yield):
|
| 61 |
-
#
|
| 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 |
-
#
|
|
|
|
| 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 |
-
#
|
| 79 |
df_species[COL_SPECIES_GROUP] = df_species[COL_SPECIES_GROUP].ffill()
|
| 80 |
GROUPS = df_species[COL_SPECIES_GROUP].dropna().unique().tolist()
|
| 81 |
|
| 82 |
-
#
|
| 83 |
df_decomposition.set_index(COL_DECOMP_SPECIES, inplace=True)
|
| 84 |
ALL_SITES = [str(col) for col in df_decomposition.columns]
|
| 85 |
|
| 86 |
-
#
|
|
|
|
| 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 (
|
| 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 |
-
#
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
@app.cell
|
| 188 |
-
def _(
|
| 189 |
-
|
| 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 |
-
#
|
| 209 |
preferred_hues = [30, 120, 210]
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 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
|
| 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 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
grouped_data = {g: [] for g in GROUPS}
|
| 266 |
for s in active_data:
|
| 267 |
grouped_data[s["group"]].append(s)
|
|
|
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 309 |
-
elif
|
| 310 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 375 |
-
SWATCH_S = 14
|
| 376 |
|
| 377 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 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 |
-
|
| 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 |
|