import marimo __generated_with = "0.21.1" app = marimo.App(width="medium") @app.cell async def _(): import sys import io import math import marimo as mo import pandas as pd import openpyxl return io, math, mo, pd @app.cell def _(mo): # Buttons to upload the data files required for the visualisation upload_yield = mo.ui.file(label="Upload: Coffee_yield.xlsx", kind="button", multiple=False) upload_species = mo.ui.file(label="Upload: Plant_species_and_average...xlsx", kind="button", multiple=False) upload_decomp = mo.ui.file(label="Upload: Total_species_composition.xlsx", kind="button", multiple=False) # Assign to variable and display the UI upload_ui = mo.vstack([ mo.md("### Please provide the correct data files to view the visual"), upload_yield, upload_species, upload_decomp ], align="center") # Place as the last statement to ensure Marimo renders it! upload_ui return upload_decomp, upload_species, upload_yield, upload_ui @app.cell def _(io, mo, pd, upload_decomp, upload_species, upload_yield): # Exectution of this cell and everything below is paused until all files are uploaded mo.stop( not upload_yield.value or not upload_species.value or not upload_decomp.value, mo.md("*Waiting for all three files to be uploaded...*") ) ###### PREPARATION ###### # Read the uploaded files from browser memory df_yield = pd.read_excel(io.BytesIO(upload_yield.value[0].contents)) df_species = pd.read_excel(io.BytesIO(upload_species.value[0].contents)) df_decomposition = pd.read_excel(io.BytesIO(upload_decomp.value[0].contents)) # Hardcoded column names used in the data files COL_SPECIES_NAME = "Species name" COL_SPECIES_GROUP = "Species group" COL_DECOMP_SPECIES = df_decomposition.columns[0] # Build a site -> yield lookup from Coffee_yield.xlsx site_yield_map = dict(zip(df_yield["Site ID"].astype(str), df_yield["Mean_CC_Yield"])) # In `Plant_species_and_average_yield.xlsx`, empty group cells are filled df_species[COL_SPECIES_GROUP] = df_species[COL_SPECIES_GROUP].ffill() GROUPS = df_species[COL_SPECIES_GROUP].dropna().unique().tolist() # In `Total_species_decomposition.xlsx`, set index to species for easy row lookups df_decomposition.set_index(COL_DECOMP_SPECIES, inplace=True) ALL_SITES = [str(col) for col in df_decomposition.columns] # Build the species_data dictionary >> dictionary used to build the visual later species_data = [] for idx, row in df_species.iterrows(): sp_id = str(row[COL_SPECIES_NAME]) group = str(row[COL_SPECIES_GROUP]) if COL_SPECIES_GROUP in df_species.columns else GROUPS[idx % len(GROUPS)] # First: find which sites this species occurs in present_in = [] if sp_id in df_decomposition.index: species_row = df_decomposition.loc[sp_id] sites_with_species = species_row[species_row == 1] present_in = sites_with_species.index.astype(str).tolist() # Then: compute mean yield across those sites using Coffee_yield.xlsx site_yields = [site_yield_map[site] for site in present_in if site in site_yield_map] avg_yield = sum(site_yields) / len(site_yields) if site_yields else 0.0 species_data.append({ "id": sp_id, "group": group, "yield": avg_yield, "num_sites": len(present_in), "sites": present_in }) # Sort species from most common (center) to least common (edge) species_data.sort(key=lambda x: x["num_sites"], reverse=True) return ALL_SITES, GROUPS, species_data @app.cell def _(ALL_SITES, mo, species_data): # Create UI controls total_species = len(species_data) slider_count = mo.ui.slider( start=5, stop=total_species, step=1, value=int(total_species/2), label="Species shown:" ) drop_single = mo.ui.dropdown( options=ALL_SITES, value=ALL_SITES[0] if ALL_SITES else "", label="Highlight Site:" ) drop_comp1 = mo.ui.dropdown( options=ALL_SITES, value=ALL_SITES[0] if ALL_SITES else "", label="Compare Site 1:" ) drop_comp2 = mo.ui.dropdown( options=ALL_SITES, value=ALL_SITES[1] if len(ALL_SITES) > 1 else ALL_SITES[0], label="Compare Site 2:" ) return drop_comp1, drop_comp2, drop_single, slider_count @app.cell def _(mo): dropdown_styles = mo.Html(""" """) tabs = mo.ui.tabs({ "General Overview": mo.md("*Viewing all species colored by their primary group.*"), "Individual Site": mo.md("*Select a site using the dropdown below.*"), "Compare Sites": mo.md("*Select two sites to compare using the dropdowns below.*"), }) return dropdown_styles, tabs @app.cell def _( drop_comp1, drop_comp2, drop_single, dropdown_styles, mo, slider_count, tabs, ): _active = tabs.value if _active == "Individual Site": _site_selector = mo.hstack([drop_single], justify="center") elif _active == "Compare Sites": _site_selector = mo.hstack([drop_comp1, drop_comp2], gap=4) else: _site_selector = mo.Html("") controls = mo.vstack([ dropdown_styles, tabs, _site_selector, mo.hstack([slider_count], justify="center") ], align="center", gap=4) return (controls,) @app.cell def _(math, tabs): # Sunburst chart: dimensions + position CX, CY = 500, 380 MAX_RADIUS = 350 MIN_RADIUS = 80 TW, TH = 245, 105 # Colors preferred_hues = [30, 120, 210] SITE_LEGEND_BKG = "#1e1e2e" SITE_LEGEND_BORDER = "#45475a" SITE_LEGEND_TEXT = "#cdd6f4" SITE_COLOR_S1_ONLY = "#f5b0c6" SITE_COLOR_S2_ONLY = "#d8b4fe" SITE_COLOR_BOTH = "#f9e2af" SITE_COLOR_NEITHER = "#e0e0e0" # Helper functions def polar_to_cartesian(cx, cy, r, angle_deg): rad = math.radians(angle_deg) return cx + r * math.cos(rad), cy + r * math.sin(rad) def build_arc(cx, cy, r_inner, r_outer, start_angle, end_angle): if end_angle - start_angle <= 0.05: end_angle = start_angle + 0.05 p1 = polar_to_cartesian(cx, cy, r_outer, start_angle) p2 = polar_to_cartesian(cx, cy, r_outer, end_angle) p3 = polar_to_cartesian(cx, cy, r_inner, end_angle) p4 = polar_to_cartesian(cx, cy, r_inner, start_angle) large_arc = "0" if end_angle - start_angle <= 180 else "1" 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" def make_tooltip(unique_id, tx, ty, group_name, species_name, avg_yield, num_sites): # Bound the tooltip coordinates so it doesn't clip off the 1000x1000 SVG canvas tx = max(10, min(tx, 1000 - TW - 10)) ty = max(10, min(ty, 1000 - TH - 10)) safe_group = group_name.strip().upper()[:40] safe_name = species_name.strip()[:38] yield_str = f"Average yield: {avg_yield:.1f} kg ha\u207b\u00b9" sites_str = f"Occurs in: {num_sites} site(s)" tip_id = unique_id.replace("seg", "tip") return f""" {safe_group} {safe_name} {yield_str} {sites_str} """ _ = tabs return ( CX, CY, MAX_RADIUS, MIN_RADIUS, SITE_COLOR_BOTH, SITE_COLOR_NEITHER, SITE_COLOR_S1_ONLY, SITE_COLOR_S2_ONLY, SITE_LEGEND_BKG, SITE_LEGEND_BORDER, SITE_LEGEND_TEXT, TH, TW, build_arc, make_tooltip, polar_to_cartesian, preferred_hues, ) @app.cell def _(GROUPS, preferred_hues, slider_count, species_data, tabs): # Filtering data + active tab active_data = species_data[:slider_count.value] active_tab = tabs.value group_hues = { group_name: preferred_hues[i % len(preferred_hues)] for i, group_name in enumerate(GROUPS) } grouped_data = {g: [] for g in GROUPS} for s in active_data: grouped_data[s["group"]].append(s) return active_tab, group_hues, grouped_data @app.cell def _( CX, CY, GROUPS, MAX_RADIUS, MIN_RADIUS, SITE_COLOR_BOTH, SITE_COLOR_NEITHER, SITE_COLOR_S1_ONLY, SITE_COLOR_S2_ONLY, TH, TW, active_tab, build_arc, drop_comp1, drop_comp2, drop_single, group_hues, grouped_data, make_tooltip, polar_to_cartesian, ): # Build the core sunburst paths core_paths = [] tooltip_elements = [] css_hover_rules = [] tab_id_prefix = active_tab.replace(" ", "_").lower() # unique tab prefix seg_index = 0 # unique id counter shared across all segments for group_idx, group_name in enumerate(GROUPS): group_items = grouped_data[group_name] if not group_items: continue base_angle = group_idx * 120 # hardcoded 120 degree angle bcs only 3 groups tiers = {} for item in group_items: sites = item["num_sites"] if sites not in tiers: tiers[sites] = [] tiers[sites].append(item) sorted_tier_keys = sorted(tiers.keys(), reverse=True) num_tiers = len(sorted_tier_keys) ring_thickness = (MAX_RADIUS - MIN_RADIUS) / max(1, num_tiers) for tier_idx, sites_key in enumerate(sorted_tier_keys): tier_items = tiers[sites_key] r_inner = MIN_RADIUS + (tier_idx * ring_thickness) # calculate segment properties (width / radius / ...) r_outer = r_inner + ring_thickness r_center = (r_inner + r_outer) / 2 total_yield = sum(item["yield"] for item in tier_items) current_start_angle = base_angle for item in tier_items: unique_seg_id = f"{tab_id_prefix}-seg-{seg_index}" unique_tip_id = f"{tab_id_prefix}-tip-{seg_index}" if total_yield > 0: sweep_angle = (item["yield"] / total_yield) * 120 else: sweep_angle = 120 / len(tier_items) end_angle = current_start_angle + sweep_angle opacity = 1.0 fill_color = "" if active_tab == "Compare Sites": # coloring depends on which tab the user is viewing in_s1 = drop_comp1.value in item["sites"] in_s2 = drop_comp2.value in item["sites"] if in_s1 and in_s2: fill_color = SITE_COLOR_BOTH elif in_s1: fill_color = SITE_COLOR_S1_ONLY elif in_s2: fill_color = SITE_COLOR_S2_ONLY else: fill_color = SITE_COLOR_NEITHER elif active_tab == "Individual Site": hue = group_hues[group_name] lightness = 85 - min(50, (item["yield"] / max(1, total_yield)) * 45) fill_color = f"hsl({hue}, 70%, {lightness}%)" if drop_single.value not in item["sites"]: opacity = 0.15 else: hue = group_hues[group_name] lightness = 85 - min(50, (item["yield"] / max(1, total_yield)) * 45) fill_color = f"hsl({hue}, 70%, {lightness}%)" path_d = build_arc(CX, CY, r_inner, r_outer, current_start_angle, end_angle) angle_center = current_start_angle + sweep_angle / 2 core_paths.append( f'' ) tip_r = r_outer + 20 tip_cx, tip_cy = polar_to_cartesian(CX, CY, tip_r, angle_center) tx = tip_cx if tip_cx < CX else tip_cx - TW ty = tip_cy - TH / 2 tooltip_elements.append( make_tooltip(unique_seg_id, tx, ty, group_name, item["id"], item["yield"], item["num_sites"]) ) # add hovering effect css_hover_rules.append( f"svg:has(#seg-{unique_seg_id}:hover) #seg-{unique_seg_id} " f"{{ filter: brightness(1.35) drop-shadow(0 0 7px rgba(255,255,255,0.6)); }}\n" f"svg:has(#seg-{unique_seg_id}:hover) #{unique_tip_id} " f"{{ visibility: visible; }}" ) current_start_angle = end_angle seg_index += 1 return core_paths, css_hover_rules, tooltip_elements @app.cell def _( CX, CY, MAX_RADIUS, MIN_RADIUS, SITE_COLOR_BOTH, SITE_COLOR_NEITHER, SITE_COLOR_S1_ONLY, SITE_COLOR_S2_ONLY, SITE_LEGEND_BKG, SITE_LEGEND_BORDER, SITE_LEGEND_TEXT, active_tab, drop_comp1, drop_comp2, polar_to_cartesian, ): # Build annotations and legends annotation_elements = [] if active_tab == "Compare Sites": # legend for Compare Sites tab site1_label = drop_comp1.value site2_label = drop_comp2.value legend_items = [ (SITE_COLOR_S1_ONLY, f"Only in {site1_label}"), (SITE_COLOR_S2_ONLY, f"Only in {site2_label}"), (SITE_COLOR_BOTH, f"In both sites"), (SITE_COLOR_NEITHER, "In neither site"), ] LX, LY = 0, 0 # top-left corner of the legend box LW, LH = 210, 150 # box dimensions ROW_H = 26 # vertical spacing between rows SWATCH_S = 14 # swatch square size annotation_elements.append( f'' f'' f'' f'SITE COMPARISON' f'' ) for i, (color, label) in enumerate(legend_items): row_y = LY + 42 + i * ROW_H annotation_elements.append( f'' f'{label}' ) annotation_elements.append('') # dividers for i in range(3): angle = i * 120 p1 = polar_to_cartesian(CX, CY, MIN_RADIUS, angle) p2 = polar_to_cartesian(CX, CY, MAX_RADIUS, angle) annotation_elements.append( f'' ) # group labels lbl_1 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 60) lbl_2 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 180) lbl_3 = polar_to_cartesian(CX, CY, MAX_RADIUS + 25, 300) annotation_elements.extend([ f'Woody vascular plants', f'Non-woody vascular plants', f'Bryophytes', ]) return (annotation_elements,) @app.cell def _(active_tab, drop_single, mo, species_data): # Build the recommendations panel for the Individual Site tab recommendations_panel = mo.Html("") if active_tab == "Individual Site" and drop_single.value: selected_site = drop_single.value present = [s for s in species_data if selected_site in s["sites"]] not_present = [s for s in species_data if selected_site not in s["sites"]] # Top 5 to REMOVE: species present at site with the lowest avg yield to_remove = sorted(present, key=lambda x: x["yield"])[:5] # Top 5 to ADD: species not present at site with the highest avg yield to_add = sorted(not_present, key=lambda x: x["yield"], reverse=True)[:5] def make_row(rank, species, color): return f"""
#{rank}
{species['id'][:35]}
{species['group']}  ·  occurs in {species['num_sites']} site(s)
{species['yield']:.1f} kg ha⁻¹
""" remove_rows = "".join(make_row(i+1, s, "#f38ba8") for i, s in enumerate(to_remove)) add_rows = "".join(make_row(i+1, s, "#a6e3a1") for i, s in enumerate(to_add)) recommendations_panel = mo.Html(f"""
CONSIDER REMOVING
Lowest-yield species currently at {selected_site}
{remove_rows}
CONSIDER ADDING
Highest-yield species absent from {selected_site}
{add_rows}
""") return (recommendations_panel,) @app.cell def _( CX, CY, MAX_RADIUS, MIN_RADIUS, active_tab, annotation_elements, controls, core_paths, css_hover_rules, mo, recommendations_panel, tooltip_elements, ): # Combine everything + visualize render_key = active_tab.replace(" ", "-").lower() css = f""" """ svg_markup = f"""
{css} {"".join(core_paths)} {"".join(annotation_elements)} {"".join(tooltip_elements)}
""" final_dashboard = mo.vstack( [controls, mo.Html(svg_markup), recommendations_panel], align="center", gap=0 ) final_dashboard return if __name__ == "__main__": app.run()