Spaces:
Build error
Build error
Ashkan Taghipour (The University of Western Australia) commited on
Commit ·
14ba315
1
Parent(s): 4b345ed
UI overhaul: immersive chapter-based experience
Browse files- Rename tabs: Origins, Genetic Landscape, Gene Universe, Genome Explorer, Protein World, Your Report
- Load 3D UMAP embedding for true 3D scatter visualization
- Add country filter dropdown on Origins tab (replaces global filters accordion)
- Remove global filters accordion
- Update progress stepper labels
- Add new precomputed artifacts: 3D embedding, sunburst hierarchy, polar layout, radar axes
- Update UMAP callback to use 3D Scatter3d
- Upgrade Gradio SDK version to 6.5.1
- README.md +1 -1
- app.py +33 -8
- precomputed/line_embedding_3d.parquet +3 -0
- precomputed/polar_contig_layout.json +0 -0
- precomputed/radar_axes.json +26 -0
- precomputed/sunburst_hierarchy.json +26 -0
- scripts/run_precompute.py +23 -0
- src/callbacks.py +8 -35
- src/plot_config.py +198 -0
- src/precompute.py +181 -0
- src/utils.py +146 -0
- ui/final.py +337 -10
- ui/gene_card_ui.py +171 -7
- ui/layout.py +6 -27
- ui/quest0.py +159 -19
- ui/quest1.py +208 -15
- ui/quest2.py +144 -10
- ui/quest3.py +155 -10
- ui/quest4.py +204 -20
- ui/theme.py +446 -30
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: "\U0001F331"
|
|
| 4 |
colorFrom: green
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 5.
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
short_description: Interactive pangenome exploration of 89 pigeon pea lines
|
|
|
|
| 4 |
colorFrom: green
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.5.1
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
short_description: Interactive pangenome exploration of 89 pigeon pea lines
|
app.py
CHANGED
|
@@ -23,6 +23,7 @@ from src.callbacks import (
|
|
| 23 |
on_generate_report, build_data_health_html,
|
| 24 |
)
|
| 25 |
from ui.layout import build_app
|
|
|
|
| 26 |
|
| 27 |
# ===========================================================
|
| 28 |
# Load precomputed data
|
|
@@ -38,7 +39,14 @@ def load_data():
|
|
| 38 |
|
| 39 |
DATA["gene_freq"] = pd.read_parquet(p / "pav_gene_frequency.parquet")
|
| 40 |
DATA["line_stats"] = pd.read_parquet(p / "line_stats.parquet")
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
DATA["similarity"] = pd.read_parquet(p / "line_similarity_topk.parquet")
|
| 43 |
DATA["gff_index"] = pd.read_parquet(p / "gff_gene_index.parquet")
|
| 44 |
DATA["protein"] = pd.read_parquet(p / "protein_index.parquet")
|
|
@@ -74,9 +82,6 @@ contig_choices = contig_gene_counts.head(30).index.tolist()
|
|
| 74 |
# Gene choices (all genes with protein data)
|
| 75 |
gene_choices = sorted(DATA["protein"]["gene_id"].tolist())
|
| 76 |
|
| 77 |
-
# Country list for filters
|
| 78 |
-
country_list = sorted(DATA["line_stats"]["country"].unique().tolist())
|
| 79 |
-
|
| 80 |
# ===========================================================
|
| 81 |
# Build UI
|
| 82 |
# ===========================================================
|
|
@@ -87,9 +92,6 @@ demo, C = build_app(line_choices, contig_choices, gene_choices)
|
|
| 87 |
# ===========================================================
|
| 88 |
with demo:
|
| 89 |
|
| 90 |
-
# Update country filter choices
|
| 91 |
-
C["country_filter"].choices = country_list
|
| 92 |
-
|
| 93 |
# -- Data Health on load --
|
| 94 |
try:
|
| 95 |
report = {
|
|
@@ -103,7 +105,30 @@ with demo:
|
|
| 103 |
except Exception as e:
|
| 104 |
C["data_health_html"].value = f"<p>Error: {e}</p>"
|
| 105 |
|
| 106 |
-
# -- Quest 0 --
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
C["q0_line_dropdown"].change(
|
| 108 |
fn=lambda line_id, state: on_line_selected(line_id, state, DATA),
|
| 109 |
inputs=[C["q0_line_dropdown"], C["state"]],
|
|
|
|
| 23 |
on_generate_report, build_data_health_html,
|
| 24 |
)
|
| 25 |
from ui.layout import build_app
|
| 26 |
+
from ui.quest0 import build_globe_figure
|
| 27 |
|
| 28 |
# ===========================================================
|
| 29 |
# Load precomputed data
|
|
|
|
| 39 |
|
| 40 |
DATA["gene_freq"] = pd.read_parquet(p / "pav_gene_frequency.parquet")
|
| 41 |
DATA["line_stats"] = pd.read_parquet(p / "line_stats.parquet")
|
| 42 |
+
# Prefer 3D embedding for the Genetic Landscape chapter
|
| 43 |
+
emb_3d = p / "line_embedding_3d.parquet"
|
| 44 |
+
if emb_3d.exists():
|
| 45 |
+
DATA["embedding"] = pd.read_parquet(emb_3d)
|
| 46 |
+
logger.info("Loaded 3D UMAP embedding")
|
| 47 |
+
else:
|
| 48 |
+
DATA["embedding"] = pd.read_parquet(p / "line_embedding.parquet")
|
| 49 |
+
logger.info("Loaded 2D UMAP embedding (3D not available)")
|
| 50 |
DATA["similarity"] = pd.read_parquet(p / "line_similarity_topk.parquet")
|
| 51 |
DATA["gff_index"] = pd.read_parquet(p / "gff_gene_index.parquet")
|
| 52 |
DATA["protein"] = pd.read_parquet(p / "protein_index.parquet")
|
|
|
|
| 82 |
# Gene choices (all genes with protein data)
|
| 83 |
gene_choices = sorted(DATA["protein"]["gene_id"].tolist())
|
| 84 |
|
|
|
|
|
|
|
|
|
|
| 85 |
# ===========================================================
|
| 86 |
# Build UI
|
| 87 |
# ===========================================================
|
|
|
|
| 92 |
# ===========================================================
|
| 93 |
with demo:
|
| 94 |
|
|
|
|
|
|
|
|
|
|
| 95 |
# -- Data Health on load --
|
| 96 |
try:
|
| 97 |
report = {
|
|
|
|
| 105 |
except Exception as e:
|
| 106 |
C["data_health_html"].value = f"<p>Error: {e}</p>"
|
| 107 |
|
| 108 |
+
# -- Quest 0: Origins --
|
| 109 |
+
|
| 110 |
+
# Render globe on tab load
|
| 111 |
+
C["q0_tab"].select(
|
| 112 |
+
fn=lambda: build_globe_figure(DATA["line_stats"]),
|
| 113 |
+
inputs=[],
|
| 114 |
+
outputs=[C["q0_globe_plot"]],
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Country filter → update line dropdown choices
|
| 118 |
+
def on_country_filter(country):
|
| 119 |
+
ls = DATA["line_stats"]
|
| 120 |
+
if country and country != "All countries":
|
| 121 |
+
filtered = ls[ls["country"] == country]["line_id"].tolist()
|
| 122 |
+
else:
|
| 123 |
+
filtered = ls["line_id"].tolist()
|
| 124 |
+
return gr.Dropdown(choices=sorted(filtered), value=None)
|
| 125 |
+
|
| 126 |
+
C["q0_country_dropdown"].change(
|
| 127 |
+
fn=on_country_filter,
|
| 128 |
+
inputs=[C["q0_country_dropdown"]],
|
| 129 |
+
outputs=[C["q0_line_dropdown"]],
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
C["q0_line_dropdown"].change(
|
| 133 |
fn=lambda line_id, state: on_line_selected(line_id, state, DATA),
|
| 134 |
inputs=[C["q0_line_dropdown"], C["state"]],
|
precomputed/line_embedding_3d.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:60b2c1a7744b55b613fb2f36d60668999d15d0b54355038b6a1a226a8528002f
|
| 3 |
+
size 5680
|
precomputed/polar_contig_layout.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
precomputed/radar_axes.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axes": [
|
| 3 |
+
"L",
|
| 4 |
+
"S",
|
| 5 |
+
"E",
|
| 6 |
+
"A",
|
| 7 |
+
"K",
|
| 8 |
+
"V",
|
| 9 |
+
"G",
|
| 10 |
+
"R",
|
| 11 |
+
"P",
|
| 12 |
+
"I"
|
| 13 |
+
],
|
| 14 |
+
"global_mean": {
|
| 15 |
+
"L": 8.622,
|
| 16 |
+
"S": 6.65,
|
| 17 |
+
"E": 3.946,
|
| 18 |
+
"A": 3.843,
|
| 19 |
+
"K": 3.504,
|
| 20 |
+
"V": 3.448,
|
| 21 |
+
"G": 3.185,
|
| 22 |
+
"R": 2.93,
|
| 23 |
+
"P": 1.944,
|
| 24 |
+
"I": 1.828
|
| 25 |
+
}
|
| 26 |
+
}
|
precomputed/sunburst_hierarchy.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ids": [
|
| 3 |
+
"total",
|
| 4 |
+
"core",
|
| 5 |
+
"shell",
|
| 6 |
+
"cloud"
|
| 7 |
+
],
|
| 8 |
+
"labels": [
|
| 9 |
+
"All Genes",
|
| 10 |
+
"Core",
|
| 11 |
+
"Shell",
|
| 12 |
+
"Cloud"
|
| 13 |
+
],
|
| 14 |
+
"parents": [
|
| 15 |
+
"",
|
| 16 |
+
"total",
|
| 17 |
+
"total",
|
| 18 |
+
"total"
|
| 19 |
+
],
|
| 20 |
+
"values": [
|
| 21 |
+
55512,
|
| 22 |
+
51990,
|
| 23 |
+
2526,
|
| 24 |
+
996
|
| 25 |
+
]
|
| 26 |
+
}
|
scripts/run_precompute.py
CHANGED
|
@@ -20,6 +20,8 @@ from src.precompute import (
|
|
| 20 |
compute_gene_frequency, compute_line_stats, compute_line_embedding,
|
| 21 |
compute_similarity_topk, build_gff_gene_parquet, build_protein_parquet,
|
| 22 |
save_contig_index, compute_hotspot_bins, compute_cluster_markers,
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
from src.utils import logger, find_file
|
| 25 |
|
|
@@ -98,6 +100,27 @@ def main():
|
|
| 98 |
# Also save the PAV matrix as parquet for efficient loading
|
| 99 |
pav.to_parquet(os.path.join(output_dir, "pav_matrix.parquet"))
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
dt = time.time() - t_total
|
| 102 |
logger.info(f"=== All precomputation done in {dt:.1f}s ===")
|
| 103 |
|
|
|
|
| 20 |
compute_gene_frequency, compute_line_stats, compute_line_embedding,
|
| 21 |
compute_similarity_topk, build_gff_gene_parquet, build_protein_parquet,
|
| 22 |
save_contig_index, compute_hotspot_bins, compute_cluster_markers,
|
| 23 |
+
compute_line_embedding_3d, build_sunburst_data, build_polar_contig_layout,
|
| 24 |
+
compute_radar_axes,
|
| 25 |
)
|
| 26 |
from src.utils import logger, find_file
|
| 27 |
|
|
|
|
| 100 |
# Also save the PAV matrix as parquet for efficient loading
|
| 101 |
pav.to_parquet(os.path.join(output_dir, "pav_matrix.parquet"))
|
| 102 |
|
| 103 |
+
# 3. New derived data for UI overhaul
|
| 104 |
+
logger.info("=== Phase 3: New UI overhaul artifacts ===")
|
| 105 |
+
|
| 106 |
+
t_step = time.time()
|
| 107 |
+
embedding_3d = compute_line_embedding_3d(pav, embedding)
|
| 108 |
+
embedding_3d.to_parquet(os.path.join(output_dir, "line_embedding_3d.parquet"), index=False)
|
| 109 |
+
logger.info(f" -> line_embedding_3d.parquet ({time.time() - t_step:.1f}s)")
|
| 110 |
+
|
| 111 |
+
t_step = time.time()
|
| 112 |
+
build_sunburst_data(gene_freq, os.path.join(output_dir, "sunburst_hierarchy.json"))
|
| 113 |
+
logger.info(f" -> sunburst_hierarchy.json ({time.time() - t_step:.1f}s)")
|
| 114 |
+
|
| 115 |
+
t_step = time.time()
|
| 116 |
+
build_polar_contig_layout(hotspots, contig_index,
|
| 117 |
+
os.path.join(output_dir, "polar_contig_layout.json"))
|
| 118 |
+
logger.info(f" -> polar_contig_layout.json ({time.time() - t_step:.1f}s)")
|
| 119 |
+
|
| 120 |
+
t_step = time.time()
|
| 121 |
+
compute_radar_axes(protein_index, os.path.join(output_dir, "radar_axes.json"))
|
| 122 |
+
logger.info(f" -> radar_axes.json ({time.time() - t_step:.1f}s)")
|
| 123 |
+
|
| 124 |
dt = time.time() - t_total
|
| 125 |
logger.info(f"=== All precomputation done in {dt:.1f}s ===")
|
| 126 |
|
src/callbacks.py
CHANGED
|
@@ -61,43 +61,16 @@ def on_start_journey(state: AppState) -> tuple:
|
|
| 61 |
# ============================================================
|
| 62 |
|
| 63 |
def build_umap_plot(color_by: str, state: AppState, data: dict) -> go.Figure:
|
| 64 |
-
"""Build
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
if color_col == "cluster_id":
|
| 73 |
-
df["cluster_id"] = df["cluster_id"].astype(str)
|
| 74 |
-
|
| 75 |
-
fig = px.scatter(
|
| 76 |
-
df, x="umap_x", y="umap_y", color=color_col,
|
| 77 |
-
hover_data=["line_id", "country"],
|
| 78 |
-
title="UMAP of 89 Pigeon Pea Lines",
|
| 79 |
-
labels={"umap_x": "UMAP 1", "umap_y": "UMAP 2"},
|
| 80 |
-
color_discrete_sequence=COUNTRY_COLORS if color_by == "Country" else px.colors.qualitative.Bold,
|
| 81 |
)
|
| 82 |
|
| 83 |
-
# Highlight selected line
|
| 84 |
-
if state and state.selected_line:
|
| 85 |
-
sel = df[df["line_id"] == state.selected_line]
|
| 86 |
-
if len(sel) > 0:
|
| 87 |
-
fig.add_trace(go.Scatter(
|
| 88 |
-
x=sel["umap_x"], y=sel["umap_y"],
|
| 89 |
-
mode="markers",
|
| 90 |
-
marker=dict(symbol="star", size=18, color="red", line=dict(width=2, color="black")),
|
| 91 |
-
name=f"Your line: {state.selected_line}",
|
| 92 |
-
hovertext=state.selected_line,
|
| 93 |
-
))
|
| 94 |
-
|
| 95 |
-
fig.update_layout(
|
| 96 |
-
plot_bgcolor="white",
|
| 97 |
-
legend=dict(orientation="h", yanchor="bottom", y=-0.3),
|
| 98 |
-
)
|
| 99 |
-
return fig
|
| 100 |
-
|
| 101 |
|
| 102 |
def on_umap_select(selected_data, state: AppState) -> tuple:
|
| 103 |
"""Handle UMAP point selection."""
|
|
|
|
| 61 |
# ============================================================
|
| 62 |
|
| 63 |
def build_umap_plot(color_by: str, state: AppState, data: dict) -> go.Figure:
|
| 64 |
+
"""Build 3D UMAP scatter (delegates to quest1.build_umap_3d)."""
|
| 65 |
+
from ui.quest1 import build_umap_3d
|
| 66 |
+
|
| 67 |
+
selected_line = state.selected_line if state else None
|
| 68 |
+
color_key = "country" if color_by == "Country" else "cluster"
|
| 69 |
+
return build_umap_3d(
|
| 70 |
+
data["embedding"], data["line_stats"],
|
| 71 |
+
color_by=color_key, selected_line=selected_line,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
)
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
def on_umap_select(selected_data, state: AppState) -> tuple:
|
| 76 |
"""Handle UMAP point selection."""
|
src/plot_config.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Plotly chart configuration and color constants for the Pangenome Atlas.
|
| 2 |
+
|
| 3 |
+
Every chart-building callback should import from this module to ensure
|
| 4 |
+
visual consistency across all plots.
|
| 5 |
+
|
| 6 |
+
Exports
|
| 7 |
+
-------
|
| 8 |
+
COLORS – dict of semantic color tokens.
|
| 9 |
+
COUNTRY_COLORS – per-country color mapping (17 countries + Unknown).
|
| 10 |
+
CLUSTER_COLORS – ordered list for the 3 optimal clusters.
|
| 11 |
+
PLOTLY_TEMPLATE – light-background Plotly layout dict.
|
| 12 |
+
PLOTLY_TEMPLATE_DARK – dark-background variant for globe / 3-D views.
|
| 13 |
+
apply_template() – apply the atlas template to any ``plotly.graph_objects.Figure``.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import TYPE_CHECKING
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING:
|
| 21 |
+
import plotly.graph_objects as go # pragma: no cover
|
| 22 |
+
|
| 23 |
+
# =====================================================================
|
| 24 |
+
# Color Palette
|
| 25 |
+
# =====================================================================
|
| 26 |
+
|
| 27 |
+
COLORS: dict[str, str] = {
|
| 28 |
+
# Gene classification
|
| 29 |
+
"core": "#2E7D32", # forest green
|
| 30 |
+
"shell": "#F9A825", # warm amber
|
| 31 |
+
"cloud": "#C62828", # warm red
|
| 32 |
+
# UI accents
|
| 33 |
+
"selected": "#D4A017", # gold — user's selected line
|
| 34 |
+
"accent": "#1565C0", # deep blue — links / actions
|
| 35 |
+
# Backgrounds
|
| 36 |
+
"bg_dark": "#1a2332", # dark navy — globe / hero header
|
| 37 |
+
"bg_light": "#FAFAF5", # warm white — page background
|
| 38 |
+
"bg_card": "#FFFFFF", # card surface
|
| 39 |
+
# Text
|
| 40 |
+
"text_primary": "#1A1A1A",
|
| 41 |
+
"text_secondary": "#757575",
|
| 42 |
+
"text_muted": "#94a3b8",
|
| 43 |
+
# Structural
|
| 44 |
+
"border": "#E0E0E0",
|
| 45 |
+
"grid_faint": "#F0F0E8",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Per-country colors used by the globe, UMAP, and any country legend.
|
| 49 |
+
# 17 countries + Unknown.
|
| 50 |
+
COUNTRY_COLORS: dict[str, str] = {
|
| 51 |
+
"India": "#2E7D32",
|
| 52 |
+
"Philippines": "#1565C0",
|
| 53 |
+
"Kenya": "#C62828",
|
| 54 |
+
"Nepal": "#D4A017",
|
| 55 |
+
"Myanmar": "#6A1B9A",
|
| 56 |
+
"Uganda": "#00838F",
|
| 57 |
+
"Zaire": "#E65100",
|
| 58 |
+
"Indonesia": "#455A64",
|
| 59 |
+
"Jamaica": "#AD1457",
|
| 60 |
+
"South_Africa": "#1B5E20",
|
| 61 |
+
"Puerto_Rico": "#0277BD",
|
| 62 |
+
"Sierra_Leone": "#BF360C",
|
| 63 |
+
"Nigeria": "#827717",
|
| 64 |
+
"Malawi": "#4E342E",
|
| 65 |
+
"Italy": "#283593",
|
| 66 |
+
"Sri_Lanka": "#00695C",
|
| 67 |
+
"Thailand": "#FF6F00",
|
| 68 |
+
"Unknown": "#9E9E9E",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Cluster colors for the 3 optimal clusters (silhouette = 0.649).
|
| 72 |
+
CLUSTER_COLORS: list[str] = ["#2E7D32", "#1565C0", "#C62828"]
|
| 73 |
+
|
| 74 |
+
# =====================================================================
|
| 75 |
+
# Shared font stack (matches the Gradio theme)
|
| 76 |
+
# =====================================================================
|
| 77 |
+
|
| 78 |
+
_FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
| 79 |
+
|
| 80 |
+
# =====================================================================
|
| 81 |
+
# Plotly Layout Templates
|
| 82 |
+
# =====================================================================
|
| 83 |
+
|
| 84 |
+
PLOTLY_TEMPLATE: dict = dict(
|
| 85 |
+
layout=dict(
|
| 86 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 87 |
+
plot_bgcolor="rgba(0,0,0,0)",
|
| 88 |
+
font=dict(
|
| 89 |
+
family=_FONT_STACK,
|
| 90 |
+
color=COLORS["text_primary"],
|
| 91 |
+
size=13,
|
| 92 |
+
),
|
| 93 |
+
title=dict(
|
| 94 |
+
font=dict(size=18, color=COLORS["text_primary"]),
|
| 95 |
+
x=0.02,
|
| 96 |
+
xanchor="left",
|
| 97 |
+
),
|
| 98 |
+
xaxis=dict(
|
| 99 |
+
showgrid=False,
|
| 100 |
+
zeroline=False,
|
| 101 |
+
showline=False,
|
| 102 |
+
),
|
| 103 |
+
yaxis=dict(
|
| 104 |
+
showgrid=False,
|
| 105 |
+
zeroline=False,
|
| 106 |
+
showline=False,
|
| 107 |
+
),
|
| 108 |
+
margin=dict(l=30, r=20, t=50, b=30),
|
| 109 |
+
hoverlabel=dict(
|
| 110 |
+
bgcolor="white",
|
| 111 |
+
font_size=13,
|
| 112 |
+
font_family=_FONT_STACK,
|
| 113 |
+
bordercolor=COLORS["border"],
|
| 114 |
+
),
|
| 115 |
+
legend=dict(
|
| 116 |
+
bgcolor="rgba(255,255,255,0.85)",
|
| 117 |
+
bordercolor="rgba(0,0,0,0)",
|
| 118 |
+
font=dict(size=12),
|
| 119 |
+
),
|
| 120 |
+
)
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Dark variant for 3-D plots, globe visualisations, and hero panels.
|
| 124 |
+
PLOTLY_TEMPLATE_DARK: dict = dict(
|
| 125 |
+
layout=dict(
|
| 126 |
+
paper_bgcolor=COLORS["bg_dark"],
|
| 127 |
+
plot_bgcolor=COLORS["bg_dark"],
|
| 128 |
+
font=dict(
|
| 129 |
+
family=_FONT_STACK,
|
| 130 |
+
color="#E0E0E0",
|
| 131 |
+
size=13,
|
| 132 |
+
),
|
| 133 |
+
title=dict(
|
| 134 |
+
font=dict(size=18, color="#E0E0E0"),
|
| 135 |
+
x=0.02,
|
| 136 |
+
xanchor="left",
|
| 137 |
+
),
|
| 138 |
+
xaxis=dict(
|
| 139 |
+
showgrid=False,
|
| 140 |
+
zeroline=False,
|
| 141 |
+
showline=False,
|
| 142 |
+
color="#E0E0E0",
|
| 143 |
+
),
|
| 144 |
+
yaxis=dict(
|
| 145 |
+
showgrid=False,
|
| 146 |
+
zeroline=False,
|
| 147 |
+
showline=False,
|
| 148 |
+
color="#E0E0E0",
|
| 149 |
+
),
|
| 150 |
+
margin=dict(l=30, r=20, t=50, b=30),
|
| 151 |
+
hoverlabel=dict(
|
| 152 |
+
bgcolor="#263245",
|
| 153 |
+
font_size=13,
|
| 154 |
+
font_family=_FONT_STACK,
|
| 155 |
+
bordercolor="#3a4a60",
|
| 156 |
+
),
|
| 157 |
+
legend=dict(
|
| 158 |
+
bgcolor="rgba(26,35,50,0.85)",
|
| 159 |
+
bordercolor="rgba(0,0,0,0)",
|
| 160 |
+
font=dict(size=12, color="#E0E0E0"),
|
| 161 |
+
),
|
| 162 |
+
)
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Modebar buttons to hide for a cleaner look.
|
| 166 |
+
_MODEBAR_REMOVE = [
|
| 167 |
+
"toImage",
|
| 168 |
+
"zoom2d",
|
| 169 |
+
"pan2d",
|
| 170 |
+
"select2d",
|
| 171 |
+
"lasso2d",
|
| 172 |
+
"autoScale2d",
|
| 173 |
+
"resetScale2d",
|
| 174 |
+
"zoomIn2d",
|
| 175 |
+
"zoomOut2d",
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def apply_template(fig: "go.Figure", dark: bool = False) -> "go.Figure":
|
| 180 |
+
"""Apply the Atlas Plotly template to *fig* and hide the modebar.
|
| 181 |
+
|
| 182 |
+
Parameters
|
| 183 |
+
----------
|
| 184 |
+
fig : plotly.graph_objects.Figure
|
| 185 |
+
The figure to style in-place.
|
| 186 |
+
dark : bool, optional
|
| 187 |
+
If ``True``, use the dark-background variant suitable for globe
|
| 188 |
+
and 3-D scenes. Defaults to ``False``.
|
| 189 |
+
|
| 190 |
+
Returns
|
| 191 |
+
-------
|
| 192 |
+
plotly.graph_objects.Figure
|
| 193 |
+
The same figure, for chaining.
|
| 194 |
+
"""
|
| 195 |
+
template = PLOTLY_TEMPLATE_DARK if dark else PLOTLY_TEMPLATE
|
| 196 |
+
fig.update_layout(**template["layout"])
|
| 197 |
+
fig.update_layout(modebar=dict(remove=_MODEBAR_REMOVE))
|
| 198 |
+
return fig
|
src/precompute.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
"""Offline precomputation for the Pigeon Pea Pangenome Atlas."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
import pandas as pd
|
| 5 |
from scipy.spatial.distance import pdist, squareform
|
|
@@ -257,3 +261,180 @@ def compute_cluster_markers(pav: pd.DataFrame, embedding: pd.DataFrame,
|
|
| 257 |
df = pd.DataFrame(records)
|
| 258 |
logger.info(f"Cluster markers: {len(df)} total across {df['cluster_id'].nunique()} clusters")
|
| 259 |
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Offline precomputation for the Pigeon Pea Pangenome Atlas."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from collections import Counter, defaultdict
|
| 6 |
+
|
| 7 |
import numpy as np
|
| 8 |
import pandas as pd
|
| 9 |
from scipy.spatial.distance import pdist, squareform
|
|
|
|
| 261 |
df = pd.DataFrame(records)
|
| 262 |
logger.info(f"Cluster markers: {len(df)} total across {df['cluster_id'].nunique()} clusters")
|
| 263 |
return df
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
# New precomputation functions for UI overhaul
|
| 268 |
+
# ---------------------------------------------------------------------------
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@timer
|
| 272 |
+
def compute_line_embedding_3d(pav: pd.DataFrame,
|
| 273 |
+
embedding_2d: pd.DataFrame) -> pd.DataFrame:
|
| 274 |
+
"""
|
| 275 |
+
3D UMAP embedding of lines, reusing cluster_id from the 2D embedding.
|
| 276 |
+
Output columns: line_id, umap_x, umap_y, umap_z, cluster_id
|
| 277 |
+
"""
|
| 278 |
+
import umap
|
| 279 |
+
|
| 280 |
+
# Transpose: rows = lines, columns = genes
|
| 281 |
+
X = pav.T.values.astype(np.float32)
|
| 282 |
+
line_ids = list(pav.columns)
|
| 283 |
+
|
| 284 |
+
# UMAP with 3 components
|
| 285 |
+
reducer = umap.UMAP(n_components=3, metric="jaccard", n_neighbors=15,
|
| 286 |
+
min_dist=0.1, random_state=42)
|
| 287 |
+
embedding = reducer.fit_transform(X)
|
| 288 |
+
|
| 289 |
+
# Reuse cluster_id from 2D embedding
|
| 290 |
+
cluster_map = dict(zip(embedding_2d["line_id"], embedding_2d["cluster_id"]))
|
| 291 |
+
|
| 292 |
+
df = pd.DataFrame({
|
| 293 |
+
"line_id": line_ids,
|
| 294 |
+
"umap_x": embedding[:, 0],
|
| 295 |
+
"umap_y": embedding[:, 1],
|
| 296 |
+
"umap_z": embedding[:, 2],
|
| 297 |
+
"cluster_id": [int(cluster_map.get(lid, -1)) for lid in line_ids],
|
| 298 |
+
})
|
| 299 |
+
logger.info(f"3D UMAP embedding computed for {len(df)} lines")
|
| 300 |
+
return df
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@timer
|
| 304 |
+
def build_sunburst_data(gene_freq: pd.DataFrame, output_path: str) -> None:
|
| 305 |
+
"""
|
| 306 |
+
Build Plotly go.Sunburst hierarchy arrays and save as JSON.
|
| 307 |
+
Structure: total -> core / shell / cloud
|
| 308 |
+
"""
|
| 309 |
+
core_count = int((gene_freq["core_class"] == "core").sum())
|
| 310 |
+
shell_count = int((gene_freq["core_class"] == "shell").sum())
|
| 311 |
+
cloud_count = int((gene_freq["core_class"] == "cloud").sum())
|
| 312 |
+
total_count = core_count + shell_count + cloud_count
|
| 313 |
+
|
| 314 |
+
data = {
|
| 315 |
+
"ids": ["total", "core", "shell", "cloud"],
|
| 316 |
+
"labels": ["All Genes", "Core", "Shell", "Cloud"],
|
| 317 |
+
"parents": ["", "total", "total", "total"],
|
| 318 |
+
"values": [total_count, core_count, shell_count, cloud_count],
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
with open(output_path, "w") as f:
|
| 322 |
+
json.dump(data, f, indent=2)
|
| 323 |
+
logger.info(f"Sunburst hierarchy saved: {output_path} "
|
| 324 |
+
f"(total={total_count}, core={core_count}, "
|
| 325 |
+
f"shell={shell_count}, cloud={cloud_count})")
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@timer
|
| 329 |
+
def build_polar_contig_layout(hotspots: pd.DataFrame,
|
| 330 |
+
contig_index: dict,
|
| 331 |
+
output_path: str,
|
| 332 |
+
top_n: int = 20) -> None:
|
| 333 |
+
"""
|
| 334 |
+
Assign the top contigs (by gene count) angular sectors for a polar layout.
|
| 335 |
+
Saves per-contig metadata and per-bin variability mapped to angular positions.
|
| 336 |
+
"""
|
| 337 |
+
# Aggregate gene counts per contig from hotspot bins
|
| 338 |
+
contig_gene_counts = (
|
| 339 |
+
hotspots.groupby("contig_id")["total_genes"]
|
| 340 |
+
.sum()
|
| 341 |
+
.nlargest(top_n)
|
| 342 |
+
)
|
| 343 |
+
top_contigs = list(contig_gene_counts.index)
|
| 344 |
+
|
| 345 |
+
# Sum total length of selected contigs (use contig_index if available)
|
| 346 |
+
contig_lengths = {}
|
| 347 |
+
for cid in top_contigs:
|
| 348 |
+
contig_lengths[cid] = contig_index.get(cid, int(
|
| 349 |
+
hotspots[hotspots["contig_id"] == cid]["bin_end"].max()))
|
| 350 |
+
|
| 351 |
+
total_length = sum(contig_lengths.values())
|
| 352 |
+
|
| 353 |
+
# Assign angular sectors proportional to contig length
|
| 354 |
+
sectors = []
|
| 355 |
+
theta_cursor = 0.0
|
| 356 |
+
for cid in top_contigs:
|
| 357 |
+
length = contig_lengths[cid]
|
| 358 |
+
arc = (length / total_length) * 360.0 if total_length > 0 else 0
|
| 359 |
+
theta_start = round(theta_cursor, 4)
|
| 360 |
+
theta_end = round(theta_cursor + arc, 4)
|
| 361 |
+
|
| 362 |
+
# Map bins for this contig to angular positions
|
| 363 |
+
contig_bins = hotspots[hotspots["contig_id"] == cid].sort_values("bin_start")
|
| 364 |
+
bins_mapped = []
|
| 365 |
+
for _, row in contig_bins.iterrows():
|
| 366 |
+
# Map bin midpoint position to angular position within the sector
|
| 367 |
+
bin_mid = (row["bin_start"] + row["bin_end"]) / 2
|
| 368 |
+
frac = bin_mid / length if length > 0 else 0
|
| 369 |
+
theta_bin = theta_start + frac * arc
|
| 370 |
+
bins_mapped.append({
|
| 371 |
+
"theta": round(theta_bin, 4),
|
| 372 |
+
"total_genes": int(row["total_genes"]),
|
| 373 |
+
"variability_score": float(row["variability_score"]),
|
| 374 |
+
"core_genes": int(row["core_genes"]),
|
| 375 |
+
"shell_genes": int(row["shell_genes"]),
|
| 376 |
+
"cloud_genes": int(row["cloud_genes"]),
|
| 377 |
+
})
|
| 378 |
+
|
| 379 |
+
sectors.append({
|
| 380 |
+
"contig_id": cid,
|
| 381 |
+
"theta_start": theta_start,
|
| 382 |
+
"theta_end": theta_end,
|
| 383 |
+
"total_genes": int(contig_gene_counts[cid]),
|
| 384 |
+
"total_length": int(length),
|
| 385 |
+
"bins": bins_mapped,
|
| 386 |
+
})
|
| 387 |
+
|
| 388 |
+
theta_cursor += arc
|
| 389 |
+
|
| 390 |
+
with open(output_path, "w") as f:
|
| 391 |
+
json.dump(sectors, f, indent=2)
|
| 392 |
+
logger.info(f"Polar contig layout saved: {output_path} "
|
| 393 |
+
f"({len(sectors)} contigs, 360-degree arc)")
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
@timer
|
| 397 |
+
def compute_radar_axes(protein_index: pd.DataFrame,
|
| 398 |
+
output_path: str,
|
| 399 |
+
top_n: int = 10) -> None:
|
| 400 |
+
"""
|
| 401 |
+
Find the top amino acids across all proteins and compute global mean percentages.
|
| 402 |
+
Parses composition_summary strings (e.g. 'L:9.8%, S:7.2%, A:6.5%, G:5.8%, V:5.5%').
|
| 403 |
+
Saves: { "axes": [...], "global_mean": {aa: mean_pct, ...} }
|
| 404 |
+
"""
|
| 405 |
+
# Parse all composition summaries to accumulate per-protein AA percentages
|
| 406 |
+
aa_totals = defaultdict(list) # aa -> list of pct values (one per protein)
|
| 407 |
+
|
| 408 |
+
for comp_str in protein_index["composition_summary"]:
|
| 409 |
+
if not comp_str or pd.isna(comp_str):
|
| 410 |
+
continue
|
| 411 |
+
# Parse tokens like "L:9.8%"
|
| 412 |
+
for token in comp_str.split(","):
|
| 413 |
+
token = token.strip()
|
| 414 |
+
match = re.match(r"([A-Z]):(\d+\.?\d*)%", token)
|
| 415 |
+
if match:
|
| 416 |
+
aa = match.group(1)
|
| 417 |
+
pct = float(match.group(2))
|
| 418 |
+
aa_totals[aa].append(pct)
|
| 419 |
+
|
| 420 |
+
# Compute mean percentage for each AA (proteins where AA was not in top-5
|
| 421 |
+
# are treated as 0 for ranking, but we report only the mean when present)
|
| 422 |
+
n_proteins = len(protein_index)
|
| 423 |
+
aa_mean = {}
|
| 424 |
+
for aa, pct_list in aa_totals.items():
|
| 425 |
+
# Mean across ALL proteins (assume 0 for those where it wasn't in top-5)
|
| 426 |
+
aa_mean[aa] = round(sum(pct_list) / n_proteins, 3)
|
| 427 |
+
|
| 428 |
+
# Select top-N by global mean
|
| 429 |
+
sorted_aas = sorted(aa_mean.items(), key=lambda x: -x[1])[:top_n]
|
| 430 |
+
axes = [aa for aa, _ in sorted_aas]
|
| 431 |
+
global_mean = {aa: pct for aa, pct in sorted_aas}
|
| 432 |
+
|
| 433 |
+
data = {
|
| 434 |
+
"axes": axes,
|
| 435 |
+
"global_mean": global_mean,
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
with open(output_path, "w") as f:
|
| 439 |
+
json.dump(data, f, indent=2)
|
| 440 |
+
logger.info(f"Radar axes saved: {output_path} (top {top_n} AAs: {axes})")
|
src/utils.py
CHANGED
|
@@ -40,6 +40,27 @@ KNOWN_COUNTRIES = {
|
|
| 40 |
"Malawi", "Italy", "Kenya", "Sri_Lanka", "Thailand", "Nepal",
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
def parse_country(line_id: str) -> str:
|
| 45 |
"""Extract country from line ID (last token after underscore)."""
|
|
@@ -53,3 +74,128 @@ def parse_country(line_id: str) -> str:
|
|
| 53 |
if two_word in KNOWN_COUNTRIES:
|
| 54 |
return two_word
|
| 55 |
return "Unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
"Malawi", "Italy", "Kenya", "Sri_Lanka", "Thailand", "Nepal",
|
| 41 |
}
|
| 42 |
|
| 43 |
+
# Approximate centroid coordinates (lat, lon) for each country.
|
| 44 |
+
COUNTRY_COORDS = {
|
| 45 |
+
"India": (20.59, 78.96),
|
| 46 |
+
"Philippines": (12.88, 121.77),
|
| 47 |
+
"Kenya": (-1.29, 36.82),
|
| 48 |
+
"Nepal": (28.39, 84.12),
|
| 49 |
+
"Myanmar": (21.92, 95.96),
|
| 50 |
+
"Uganda": (1.37, 32.29),
|
| 51 |
+
"Zaire": (-4.04, 21.76),
|
| 52 |
+
"Indonesia": (-0.79, 113.92),
|
| 53 |
+
"Jamaica": (18.11, -77.30),
|
| 54 |
+
"South_Africa": (-30.56, 22.94),
|
| 55 |
+
"Puerto_Rico": (18.22, -66.59),
|
| 56 |
+
"Sierra_Leone": (8.46, -11.78),
|
| 57 |
+
"Nigeria": (9.08, 7.49),
|
| 58 |
+
"Malawi": (-13.25, 34.30),
|
| 59 |
+
"Italy": (41.87, 12.57),
|
| 60 |
+
"Sri_Lanka": (7.87, 80.77),
|
| 61 |
+
"Thailand": (15.87, 100.99),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
|
| 65 |
def parse_country(line_id: str) -> str:
|
| 66 |
"""Extract country from line ID (last token after underscore)."""
|
|
|
|
| 74 |
if two_word in KNOWN_COUNTRIES:
|
| 75 |
return two_word
|
| 76 |
return "Unknown"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# =====================================================================
|
| 80 |
+
# HTML builder helpers
|
| 81 |
+
# =====================================================================
|
| 82 |
+
|
| 83 |
+
def build_hero_header(
|
| 84 |
+
total_genes: int,
|
| 85 |
+
n_lines: int,
|
| 86 |
+
n_countries: int,
|
| 87 |
+
n_clusters: int,
|
| 88 |
+
) -> str:
|
| 89 |
+
"""Return an HTML string for the hero dashboard header.
|
| 90 |
+
|
| 91 |
+
Renders a dark (#1a2332) banner with large stat numbers and small
|
| 92 |
+
uppercase labels. Uses the ``.hero-header``, ``.hero-stat``, and
|
| 93 |
+
``.hero-subtitle`` CSS classes defined in ``ui/theme.py``.
|
| 94 |
+
|
| 95 |
+
Parameters
|
| 96 |
+
----------
|
| 97 |
+
total_genes : int
|
| 98 |
+
Total number of genes in the pangenome.
|
| 99 |
+
n_lines : int
|
| 100 |
+
Number of accession lines (e.g. 89 + reference).
|
| 101 |
+
n_countries : int
|
| 102 |
+
Number of countries of origin.
|
| 103 |
+
n_clusters : int
|
| 104 |
+
Number of genomic clusters.
|
| 105 |
+
"""
|
| 106 |
+
stats = [
|
| 107 |
+
(f"{total_genes:,}", "Total Genes"),
|
| 108 |
+
(str(n_lines), "Lines"),
|
| 109 |
+
(str(n_countries), "Countries"),
|
| 110 |
+
(str(n_clusters), "Clusters"),
|
| 111 |
+
]
|
| 112 |
+
stat_html = "\n".join(
|
| 113 |
+
f'<span class="hero-stat">'
|
| 114 |
+
f'<span class="stat-number">{value}</span>'
|
| 115 |
+
f'<span class="stat-label">{label}</span>'
|
| 116 |
+
f"</span>"
|
| 117 |
+
for value, label in stats
|
| 118 |
+
)
|
| 119 |
+
return (
|
| 120 |
+
'<div class="hero-header">'
|
| 121 |
+
"<h1>Pigeon Pea Pangenome Atlas</h1>"
|
| 122 |
+
'<p class="hero-subtitle">'
|
| 123 |
+
"An interactive exploration of presence-absence variation across "
|
| 124 |
+
"pigeon pea accessions worldwide."
|
| 125 |
+
"</p>"
|
| 126 |
+
'<div class="hero-stats">'
|
| 127 |
+
f"{stat_html}"
|
| 128 |
+
"</div>"
|
| 129 |
+
"</div>"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def build_metric_card(value: str, label: str, color: str = "green") -> str:
|
| 134 |
+
"""Return an HTML string for a single metric card.
|
| 135 |
+
|
| 136 |
+
Parameters
|
| 137 |
+
----------
|
| 138 |
+
value : str
|
| 139 |
+
The large number or text to display (e.g. ``"55,512"``).
|
| 140 |
+
label : str
|
| 141 |
+
Short uppercase caption below the number (e.g. ``"TOTAL GENES"``).
|
| 142 |
+
color : str, optional
|
| 143 |
+
Color accent for the top border. One of ``"green"`` (default),
|
| 144 |
+
``"amber"``, ``"red"``, or ``"blue"``. Maps to CSS modifier
|
| 145 |
+
classes on ``.metric-card``.
|
| 146 |
+
"""
|
| 147 |
+
color_class = ""
|
| 148 |
+
if color in ("amber", "red", "blue"):
|
| 149 |
+
color_class = f" {color}"
|
| 150 |
+
return (
|
| 151 |
+
f'<div class="metric-card{color_class}">'
|
| 152 |
+
f'<div class="metric-value">{value}</div>'
|
| 153 |
+
f'<div class="metric-label">{label}</div>'
|
| 154 |
+
"</div>"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def build_progress_stepper(current_step: int = 0, total_steps: int = 6) -> str:
|
| 159 |
+
"""Return an HTML string for a visual progress stepper.
|
| 160 |
+
|
| 161 |
+
Renders a horizontal row of numbered circles connected by lines.
|
| 162 |
+
Steps before *current_step* are marked complete (green filled),
|
| 163 |
+
*current_step* itself gets a green ring, and later steps are dimmed.
|
| 164 |
+
|
| 165 |
+
Uses ``.progress-stepper``, ``.step-complete``, ``.step-current``,
|
| 166 |
+
and ``.step-future`` CSS classes from ``ui/theme.py``.
|
| 167 |
+
|
| 168 |
+
Parameters
|
| 169 |
+
----------
|
| 170 |
+
current_step : int
|
| 171 |
+
Zero-based index of the active step.
|
| 172 |
+
total_steps : int
|
| 173 |
+
Total number of steps (default 6, matching the quest count).
|
| 174 |
+
"""
|
| 175 |
+
step_labels = [
|
| 176 |
+
"Explorer",
|
| 177 |
+
"Map the World",
|
| 178 |
+
"Core vs Accessory",
|
| 179 |
+
"Genome Landmarks",
|
| 180 |
+
"Protein Relics",
|
| 181 |
+
"Field Report",
|
| 182 |
+
]
|
| 183 |
+
parts: list[str] = []
|
| 184 |
+
for i in range(total_steps):
|
| 185 |
+
label = step_labels[i] if i < len(step_labels) else f"Step {i + 1}"
|
| 186 |
+
if i < current_step:
|
| 187 |
+
cls = "step step-complete"
|
| 188 |
+
dot_content = "✓" # checkmark
|
| 189 |
+
elif i == current_step:
|
| 190 |
+
cls = "step step-current"
|
| 191 |
+
dot_content = str(i + 1)
|
| 192 |
+
else:
|
| 193 |
+
cls = "step step-future"
|
| 194 |
+
dot_content = str(i + 1)
|
| 195 |
+
parts.append(
|
| 196 |
+
f'<div class="{cls}">'
|
| 197 |
+
f'<span class="dot">{dot_content}</span>'
|
| 198 |
+
f"<span>{label}</span>"
|
| 199 |
+
f"</div>"
|
| 200 |
+
)
|
| 201 |
+
return '<div class="progress-stepper">' + "".join(parts) + "</div>"
|
ui/final.py
CHANGED
|
@@ -1,27 +1,354 @@
|
|
| 1 |
-
"""Final tab:
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_final_tab():
|
| 7 |
-
"""Build Final Report tab components. Returns dict of components.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"including your selected line, findings, and backpack collection."
|
|
|
|
| 13 |
)
|
| 14 |
|
| 15 |
-
generate_btn = gr.Button(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
report_md = gr.Markdown(
|
|
|
|
|
|
|
| 18 |
|
| 19 |
with gr.Row():
|
| 20 |
download_json = gr.File(label="Download JSON", visible=False)
|
| 21 |
download_csv = gr.File(label="Download CSV", visible=False)
|
| 22 |
|
| 23 |
-
gr.
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
return {
|
| 27 |
"tab": tab,
|
|
|
|
| 1 |
+
"""Final tab / Chapter 6: Your Report — field report generation and export."""
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
+
from src.plot_config import COLORS
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# =====================================================================
|
| 9 |
+
# Module-level HTML builders (callable from callbacks)
|
| 10 |
+
# =====================================================================
|
| 11 |
+
|
| 12 |
+
def build_report_html(state, data: dict) -> str:
|
| 13 |
+
"""Build a presentation-ready styled HTML field report.
|
| 14 |
+
|
| 15 |
+
Parameters
|
| 16 |
+
----------
|
| 17 |
+
state : AppState
|
| 18 |
+
Current application state with selected_line, backpack, achievements.
|
| 19 |
+
data : dict
|
| 20 |
+
Loaded data dictionary with line_stats, embedding, similarity, etc.
|
| 21 |
+
|
| 22 |
+
Returns
|
| 23 |
+
-------
|
| 24 |
+
str
|
| 25 |
+
Self-contained HTML string with inline CSS for portability.
|
| 26 |
+
"""
|
| 27 |
+
if state is None or not state.selected_line:
|
| 28 |
+
return (
|
| 29 |
+
'<div style="text-align:center;padding:40px;color:#757575;">'
|
| 30 |
+
'<p style="font-size:16px;">No line selected yet.</p>'
|
| 31 |
+
'<p style="font-size:13px;">Go back to Chapter 0 to choose a line first.</p>'
|
| 32 |
+
"</div>"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
line_id = state.selected_line
|
| 36 |
+
line_stats = data["line_stats"]
|
| 37 |
+
embedding = data["embedding"]
|
| 38 |
+
similarity = data["similarity"]
|
| 39 |
+
gene_freq = data["gene_freq"]
|
| 40 |
+
pav = data.get("pav")
|
| 41 |
+
|
| 42 |
+
# -- Gather stats --
|
| 43 |
+
ls_row = line_stats[line_stats["line_id"] == line_id]
|
| 44 |
+
country = ls_row.iloc[0]["country"] if len(ls_row) > 0 else "Unknown"
|
| 45 |
+
genes_present = int(ls_row.iloc[0]["genes_present_count"]) if len(ls_row) > 0 else 0
|
| 46 |
+
unique_genes = int(ls_row.iloc[0]["unique_genes_count"]) if len(ls_row) > 0 else 0
|
| 47 |
+
|
| 48 |
+
emb_row = embedding[embedding["line_id"] == line_id]
|
| 49 |
+
cluster_id = int(emb_row.iloc[0]["cluster_id"]) if len(emb_row) > 0 else -1
|
| 50 |
+
|
| 51 |
+
# Nearest neighbors
|
| 52 |
+
sim_rows = similarity[similarity["line_id"] == line_id].nlargest(3, "jaccard_score")
|
| 53 |
+
neighbors = [
|
| 54 |
+
(r["neighbor_line_id"], f"{r['jaccard_score']:.3f}")
|
| 55 |
+
for _, r in sim_rows.iterrows()
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
# Core/shell/cloud breakdown
|
| 59 |
+
core_count = shell_count = cloud_count = 0
|
| 60 |
+
if pav is not None and line_id in pav.columns:
|
| 61 |
+
my_genes = set(pav.index[pav[line_id] == 1])
|
| 62 |
+
my_freq = gene_freq[gene_freq["gene_id"].isin(my_genes)]
|
| 63 |
+
core_count = int((my_freq["core_class"] == "core").sum())
|
| 64 |
+
shell_count = int((my_freq["core_class"] == "shell").sum())
|
| 65 |
+
cloud_count = int((my_freq["core_class"] == "cloud").sum())
|
| 66 |
+
|
| 67 |
+
# Rare genes
|
| 68 |
+
rare_rows = []
|
| 69 |
+
if pav is not None and line_id in pav.columns:
|
| 70 |
+
my_genes_list = pav.index[pav[line_id] == 1].tolist()
|
| 71 |
+
rare = gene_freq[
|
| 72 |
+
(gene_freq["gene_id"].isin(my_genes_list))
|
| 73 |
+
& (gene_freq["freq_count"] <= 5)
|
| 74 |
+
].nsmallest(5, "freq_count")
|
| 75 |
+
for _, r in rare.iterrows():
|
| 76 |
+
rare_rows.append((r["gene_id"], int(r["freq_count"]), r["core_class"]))
|
| 77 |
+
|
| 78 |
+
# Backpack
|
| 79 |
+
backpack_rows = []
|
| 80 |
+
for g in (state.backpack_genes or []):
|
| 81 |
+
gf = gene_freq[gene_freq["gene_id"] == g]
|
| 82 |
+
if len(gf) > 0:
|
| 83 |
+
backpack_rows.append((g, gf.iloc[0]["core_class"], int(gf.iloc[0]["freq_count"])))
|
| 84 |
+
else:
|
| 85 |
+
backpack_rows.append((g, "unknown", 0))
|
| 86 |
+
|
| 87 |
+
# -- Build HTML --
|
| 88 |
+
# Metric cards row
|
| 89 |
+
metrics = [
|
| 90 |
+
(f"{genes_present:,}", "Genes Present", COLORS["core"]),
|
| 91 |
+
(str(unique_genes), "Unique Genes", COLORS["accent"]),
|
| 92 |
+
(str(cluster_id), "Cluster", COLORS["selected"]),
|
| 93 |
+
(str(len(state.backpack_genes)), "Backpack Genes", COLORS["shell"]),
|
| 94 |
+
]
|
| 95 |
+
metric_cards = ""
|
| 96 |
+
for val, label, color in metrics:
|
| 97 |
+
metric_cards += (
|
| 98 |
+
f'<div style="flex:1;min-width:120px;background:#FFFFFF;border-radius:12px;'
|
| 99 |
+
f"padding:20px;text-align:center;border-top:3px solid {color};"
|
| 100 |
+
f'box-shadow:0 2px 8px rgba(0,0,0,0.06);">'
|
| 101 |
+
f'<div style="font-size:28px;font-weight:700;color:#1A1A1A;">{val}</div>'
|
| 102 |
+
f'<div style="font-size:11px;font-weight:600;text-transform:uppercase;'
|
| 103 |
+
f'letter-spacing:1px;color:#757575;margin-top:4px;">{label}</div>'
|
| 104 |
+
f"</div>"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Neighbor rows
|
| 108 |
+
neighbor_html = ""
|
| 109 |
+
for name, score in neighbors:
|
| 110 |
+
neighbor_html += (
|
| 111 |
+
f'<div style="display:flex;justify-content:space-between;padding:8px 0;'
|
| 112 |
+
f'border-bottom:1px solid #F0F0E8;">'
|
| 113 |
+
f'<span style="font-weight:500;">{name}</span>'
|
| 114 |
+
f'<span style="color:#757575;font-family:monospace;">{score}</span>'
|
| 115 |
+
f"</div>"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Composition bar
|
| 119 |
+
total_csc = core_count + shell_count + cloud_count
|
| 120 |
+
if total_csc > 0:
|
| 121 |
+
core_pct = core_count / total_csc * 100
|
| 122 |
+
shell_pct = shell_count / total_csc * 100
|
| 123 |
+
cloud_pct = cloud_count / total_csc * 100
|
| 124 |
+
else:
|
| 125 |
+
core_pct = shell_pct = cloud_pct = 0
|
| 126 |
+
|
| 127 |
+
composition_bar = (
|
| 128 |
+
f'<div style="display:flex;height:24px;border-radius:6px;overflow:hidden;margin:12px 0;">'
|
| 129 |
+
f'<div style="width:{core_pct:.1f}%;background:{COLORS["core"]};"></div>'
|
| 130 |
+
f'<div style="width:{shell_pct:.1f}%;background:{COLORS["shell"]};"></div>'
|
| 131 |
+
f'<div style="width:{cloud_pct:.1f}%;background:{COLORS["cloud"]};"></div>'
|
| 132 |
+
f"</div>"
|
| 133 |
+
f'<div style="display:flex;justify-content:space-between;font-size:12px;color:#757575;">'
|
| 134 |
+
f'<span style="color:{COLORS["core"]};font-weight:600;">Core: {core_count:,}</span>'
|
| 135 |
+
f'<span style="color:{COLORS["shell"]};font-weight:600;">Shell: {shell_count:,}</span>'
|
| 136 |
+
f'<span style="color:{COLORS["cloud"]};font-weight:600;">Cloud: {cloud_count:,}</span>'
|
| 137 |
+
f"</div>"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Rare genes table
|
| 141 |
+
rare_html = ""
|
| 142 |
+
if rare_rows:
|
| 143 |
+
rare_html += (
|
| 144 |
+
'<table style="width:100%;border-collapse:collapse;font-size:13px;">'
|
| 145 |
+
'<tr style="border-bottom:2px solid #E0E0E0;">'
|
| 146 |
+
'<th style="text-align:left;padding:8px 4px;color:#757575;">Gene</th>'
|
| 147 |
+
'<th style="text-align:center;padding:8px 4px;color:#757575;">Lines</th>'
|
| 148 |
+
'<th style="text-align:center;padding:8px 4px;color:#757575;">Class</th>'
|
| 149 |
+
"</tr>"
|
| 150 |
+
)
|
| 151 |
+
for gene, count, cls in rare_rows:
|
| 152 |
+
badge_color = {"core": COLORS["core"], "shell": COLORS["shell"], "cloud": COLORS["cloud"]}.get(cls, "#9E9E9E")
|
| 153 |
+
badge_text_color = "#FFFFFF" if cls != "shell" else "#333333"
|
| 154 |
+
rare_html += (
|
| 155 |
+
f'<tr style="border-bottom:1px solid #F0F0E8;">'
|
| 156 |
+
f'<td style="padding:6px 4px;font-family:monospace;font-weight:500;">{gene}</td>'
|
| 157 |
+
f'<td style="text-align:center;padding:6px 4px;">{count}</td>'
|
| 158 |
+
f'<td style="text-align:center;padding:6px 4px;">'
|
| 159 |
+
f'<span style="display:inline-block;padding:2px 10px;border-radius:12px;'
|
| 160 |
+
f"font-size:11px;font-weight:600;background:{badge_color};color:{badge_text_color};"
|
| 161 |
+
f'">{cls}</span></td></tr>'
|
| 162 |
+
)
|
| 163 |
+
rare_html += "</table>"
|
| 164 |
+
else:
|
| 165 |
+
rare_html = '<p style="color:#757575;font-size:13px;">No rare genes (≤5 lines) found.</p>'
|
| 166 |
+
|
| 167 |
+
# Backpack table
|
| 168 |
+
backpack_html = ""
|
| 169 |
+
if backpack_rows:
|
| 170 |
+
backpack_html += (
|
| 171 |
+
'<table style="width:100%;border-collapse:collapse;font-size:13px;">'
|
| 172 |
+
'<tr style="border-bottom:2px solid #E0E0E0;">'
|
| 173 |
+
'<th style="text-align:left;padding:8px 4px;color:#757575;">Gene</th>'
|
| 174 |
+
'<th style="text-align:center;padding:8px 4px;color:#757575;">Class</th>'
|
| 175 |
+
'<th style="text-align:center;padding:8px 4px;color:#757575;">Lines</th>'
|
| 176 |
+
"</tr>"
|
| 177 |
+
)
|
| 178 |
+
for gene, cls, count in backpack_rows:
|
| 179 |
+
badge_color = {"core": COLORS["core"], "shell": COLORS["shell"], "cloud": COLORS["cloud"]}.get(cls, "#9E9E9E")
|
| 180 |
+
badge_text_color = "#FFFFFF" if cls != "shell" else "#333333"
|
| 181 |
+
backpack_html += (
|
| 182 |
+
f'<tr style="border-bottom:1px solid #F0F0E8;">'
|
| 183 |
+
f'<td style="padding:6px 4px;font-family:monospace;font-weight:500;">{gene}</td>'
|
| 184 |
+
f'<td style="text-align:center;padding:6px 4px;">'
|
| 185 |
+
f'<span style="display:inline-block;padding:2px 10px;border-radius:12px;'
|
| 186 |
+
f"font-size:11px;font-weight:600;background:{badge_color};color:{badge_text_color};"
|
| 187 |
+
f'">{cls}</span></td>'
|
| 188 |
+
f'<td style="text-align:center;padding:6px 4px;">{count}</td>'
|
| 189 |
+
f"</tr>"
|
| 190 |
+
)
|
| 191 |
+
backpack_html += "</table>"
|
| 192 |
+
else:
|
| 193 |
+
backpack_html = '<p style="color:#757575;font-size:13px;">No genes pinned to backpack.</p>'
|
| 194 |
+
|
| 195 |
+
# Achievement pills
|
| 196 |
+
achievements_pills = ""
|
| 197 |
+
for a in sorted(state.achievements):
|
| 198 |
+
achievements_pills += (
|
| 199 |
+
f'<span style="display:inline-block;padding:6px 16px;border-radius:20px;'
|
| 200 |
+
f"background:linear-gradient(135deg, #D4A017, #F9A825);color:#333;"
|
| 201 |
+
f'font-weight:600;font-size:12px;margin:4px;box-shadow:0 2px 4px rgba(0,0,0,0.1);">'
|
| 202 |
+
f"{a}</span>"
|
| 203 |
+
)
|
| 204 |
+
if not achievements_pills:
|
| 205 |
+
achievements_pills = '<span style="color:#757575;font-size:13px;">No achievements yet</span>'
|
| 206 |
+
|
| 207 |
+
# -- Assemble full report --
|
| 208 |
+
html = f'''
|
| 209 |
+
<div style="font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 210 |
+
max-width:720px;margin:0 auto;">
|
| 211 |
+
|
| 212 |
+
<!-- Header -->
|
| 213 |
+
<div style="background:{COLORS["bg_dark"]};color:white;padding:32px 36px;
|
| 214 |
+
border-radius:16px;margin-bottom:24px;">
|
| 215 |
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;
|
| 216 |
+
letter-spacing:1.5px;color:{COLORS["text_muted"]};margin-bottom:8px;">
|
| 217 |
+
Field Report</div>
|
| 218 |
+
<h2 style="margin:0 0 6px 0;font-size:26px;font-weight:700;">{line_id}</h2>
|
| 219 |
+
<p style="margin:0;font-size:14px;color:{COLORS["text_muted"]};">
|
| 220 |
+
Origin: {country} · Cluster {cluster_id}</p>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<!-- Metric cards -->
|
| 224 |
+
<div style="display:flex;gap:12px;margin-bottom:24px;flex-wrap:wrap;">
|
| 225 |
+
{metric_cards}
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<!-- Nearest Neighbors -->
|
| 229 |
+
<div style="background:#FFFFFF;border-radius:12px;padding:20px 24px;
|
| 230 |
+
box-shadow:0 2px 8px rgba(0,0,0,0.06);margin-bottom:16px;">
|
| 231 |
+
<h3 style="margin:0 0 12px 0;font-size:15px;font-weight:600;
|
| 232 |
+
color:{COLORS["text_primary"]};">Nearest Neighbors</h3>
|
| 233 |
+
{neighbor_html}
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<!-- Gene Composition -->
|
| 237 |
+
<div style="background:#FFFFFF;border-radius:12px;padding:20px 24px;
|
| 238 |
+
box-shadow:0 2px 8px rgba(0,0,0,0.06);margin-bottom:16px;">
|
| 239 |
+
<h3 style="margin:0 0 12px 0;font-size:15px;font-weight:600;
|
| 240 |
+
color:{COLORS["text_primary"]};">Gene Composition</h3>
|
| 241 |
+
{composition_bar}
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<!-- Top 5 Rare Genes -->
|
| 245 |
+
<div style="background:#FFFFFF;border-radius:12px;padding:20px 24px;
|
| 246 |
+
box-shadow:0 2px 8px rgba(0,0,0,0.06);margin-bottom:16px;">
|
| 247 |
+
<h3 style="margin:0 0 12px 0;font-size:15px;font-weight:600;
|
| 248 |
+
color:{COLORS["text_primary"]};">Top 5 Rare Genes</h3>
|
| 249 |
+
{rare_html}
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<!-- Backpack Collection -->
|
| 253 |
+
<div style="background:#FFFFFF;border-radius:12px;padding:20px 24px;
|
| 254 |
+
box-shadow:0 2px 8px rgba(0,0,0,0.06);margin-bottom:16px;">
|
| 255 |
+
<h3 style="margin:0 0 12px 0;font-size:15px;font-weight:600;
|
| 256 |
+
color:{COLORS["text_primary"]};">Backpack Collection</h3>
|
| 257 |
+
{backpack_html}
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
<!-- Achievements -->
|
| 261 |
+
<div style="background:#FFFFFF;border-radius:12px;padding:20px 24px;
|
| 262 |
+
box-shadow:0 2px 8px rgba(0,0,0,0.06);margin-bottom:16px;">
|
| 263 |
+
<h3 style="margin:0 0 12px 0;font-size:15px;font-weight:600;
|
| 264 |
+
color:{COLORS["text_primary"]};">Achievements Earned</h3>
|
| 265 |
+
<div>{achievements_pills}</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<!-- Footer -->
|
| 269 |
+
<div style="text-align:center;padding:16px 0;font-size:11px;color:#757575;">
|
| 270 |
+
Generated by Pigeon Pea Pangenome Atlas
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
'''
|
| 274 |
+
return html
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def build_achievements_html(state) -> str:
|
| 278 |
+
"""Build styled HTML for achievement badges.
|
| 279 |
+
|
| 280 |
+
Parameters
|
| 281 |
+
----------
|
| 282 |
+
state : AppState
|
| 283 |
+
Current application state.
|
| 284 |
+
|
| 285 |
+
Returns
|
| 286 |
+
-------
|
| 287 |
+
str
|
| 288 |
+
HTML string with styled badge pills.
|
| 289 |
+
"""
|
| 290 |
+
if state is None or not state.achievements:
|
| 291 |
+
return (
|
| 292 |
+
'<div style="padding:12px;text-align:center;color:#757575;">'
|
| 293 |
+
"Complete quests to earn badges!</div>"
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
pills = []
|
| 297 |
+
for a in sorted(state.achievements):
|
| 298 |
+
pills.append(
|
| 299 |
+
f'<span class="achievement-badge">{a}</span>'
|
| 300 |
+
)
|
| 301 |
+
return '<div style="display:flex;flex-wrap:wrap;gap:6px;">' + "".join(pills) + "</div>"
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# =====================================================================
|
| 305 |
+
# Gradio UI builder
|
| 306 |
+
# =====================================================================
|
| 307 |
|
| 308 |
def build_final_tab():
|
| 309 |
+
"""Build Final Report tab components. Returns dict of components.
|
| 310 |
+
|
| 311 |
+
Returned keys (prefixed with ``final_`` by layout.py):
|
| 312 |
+
tab, generate_btn, report_md, download_json, download_csv, achievements_html
|
| 313 |
+
"""
|
| 314 |
+
with gr.Tab("Your Report", id="final") as tab:
|
| 315 |
+
# -- Header --
|
| 316 |
+
gr.HTML(
|
| 317 |
+
'<div style="padding:4px 0 12px 0;">'
|
| 318 |
+
'<h2 style="margin:0 0 4px 0;font-size:22px;font-weight:700;'
|
| 319 |
+
f'color:{COLORS["text_primary"]};">Chapter 6: Your Report</h2>'
|
| 320 |
+
'<p style="margin:0;font-size:14px;color:#757575;line-height:1.5;">'
|
| 321 |
+
"Generate a presentation-ready summary of your pangenome exploration, "
|
| 322 |
"including your selected line, findings, and backpack collection."
|
| 323 |
+
"</p></div>"
|
| 324 |
)
|
| 325 |
|
| 326 |
+
generate_btn = gr.Button(
|
| 327 |
+
"Generate Report",
|
| 328 |
+
variant="primary",
|
| 329 |
+
size="lg",
|
| 330 |
+
)
|
| 331 |
|
| 332 |
+
report_md = gr.Markdown(
|
| 333 |
+
value="*Click 'Generate Report' to create your field report.*"
|
| 334 |
+
)
|
| 335 |
|
| 336 |
with gr.Row():
|
| 337 |
download_json = gr.File(label="Download JSON", visible=False)
|
| 338 |
download_csv = gr.File(label="Download CSV", visible=False)
|
| 339 |
|
| 340 |
+
gr.HTML(
|
| 341 |
+
'<div style="margin-top:16px;padding:4px 0;">'
|
| 342 |
+
'<h3 style="margin:0 0 4px 0;font-size:17px;font-weight:600;'
|
| 343 |
+
f'color:{COLORS["text_primary"]};">Achievements Earned</h3>'
|
| 344 |
+
"</div>"
|
| 345 |
+
)
|
| 346 |
+
achievements_html = gr.HTML(
|
| 347 |
+
value=(
|
| 348 |
+
'<div style="padding:12px;text-align:center;color:#757575;">'
|
| 349 |
+
"Complete quests to earn badges!</div>"
|
| 350 |
+
),
|
| 351 |
+
)
|
| 352 |
|
| 353 |
return {
|
| 354 |
"tab": tab,
|
ui/gene_card_ui.py
CHANGED
|
@@ -1,21 +1,185 @@
|
|
| 1 |
-
"""Gene Card side panel UI."""
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_gene_card_panel():
|
| 7 |
-
"""Build Gene Card side panel. Returns dict of components.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
with gr.Column(visible=False, scale=1) as gene_card_col:
|
| 9 |
-
gr.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
gene_card_html = gr.HTML(value="")
|
| 11 |
|
| 12 |
with gr.Row():
|
| 13 |
-
show_genome_btn = gr.Button(
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
with gr.Row():
|
| 17 |
-
pin_card_btn = gr.Button(
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
gene_report_file = gr.File(label="Gene Report", visible=False)
|
| 21 |
|
|
|
|
| 1 |
+
"""Gene Card side panel UI with premium card styling."""
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
+
from src.plot_config import COLORS
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# =====================================================================
|
| 9 |
+
# Module-level HTML builders (callable from callbacks / gene_card.py)
|
| 10 |
+
# =====================================================================
|
| 11 |
+
|
| 12 |
+
def render_gene_card_premium(card: dict) -> str:
|
| 13 |
+
"""Render a premium-styled Gene Card HTML from a card data dict.
|
| 14 |
+
|
| 15 |
+
Parameters
|
| 16 |
+
----------
|
| 17 |
+
card : dict
|
| 18 |
+
Gene card data dict as returned by ``src.gene_card.build_gene_card()``.
|
| 19 |
+
Expected keys: gene_id, core_class, freq_count, freq_pct,
|
| 20 |
+
presence_vector, contig, start, end, strand, protein_length,
|
| 21 |
+
composition_summary.
|
| 22 |
+
|
| 23 |
+
Returns
|
| 24 |
+
-------
|
| 25 |
+
str
|
| 26 |
+
Self-contained HTML string with inline CSS.
|
| 27 |
+
"""
|
| 28 |
+
gene_id = card.get("gene_id", "unknown")
|
| 29 |
+
|
| 30 |
+
# -- Core/shell/cloud pill badge --
|
| 31 |
+
cc = card.get("core_class", "unknown")
|
| 32 |
+
badge_styles = {
|
| 33 |
+
"core": (COLORS["core"], "#FFFFFF", "Core"),
|
| 34 |
+
"shell": (COLORS["shell"], "#333333", "Shell"),
|
| 35 |
+
"cloud": (COLORS["cloud"], "#FFFFFF", "Cloud"),
|
| 36 |
+
}
|
| 37 |
+
bg, fg, label = badge_styles.get(cc, ("#9E9E9E", "#FFFFFF", "Unknown"))
|
| 38 |
+
badge_html = (
|
| 39 |
+
f'<span style="display:inline-block;padding:3px 12px;border-radius:16px;'
|
| 40 |
+
f"font-size:11px;font-weight:700;background:{bg};color:{fg};"
|
| 41 |
+
f'letter-spacing:0.5px;vertical-align:middle;">{label}</span>'
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# -- Gene ID header --
|
| 45 |
+
header_html = (
|
| 46 |
+
f'<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">'
|
| 47 |
+
f'<span style="font-family:\'SF Mono\',SFMono-Regular,ui-monospace,Menlo,monospace;'
|
| 48 |
+
f"font-size:18px;font-weight:700;color:{COLORS['text_primary']};"
|
| 49 |
+
f'">{gene_id}</span>'
|
| 50 |
+
f"{badge_html}"
|
| 51 |
+
f"</div>"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# -- Frequency line --
|
| 55 |
+
freq_html = ""
|
| 56 |
+
if card.get("freq_count") is not None:
|
| 57 |
+
freq_html = (
|
| 58 |
+
f'<div style="font-size:13px;color:{COLORS["text_secondary"]};margin-bottom:14px;">'
|
| 59 |
+
f'Present in <span style="font-weight:600;color:{COLORS["text_primary"]};">'
|
| 60 |
+
f'{card["freq_count"]}</span> lines '
|
| 61 |
+
f'(<span style="font-weight:600;">{card["freq_pct"]:.1f}%</span>)'
|
| 62 |
+
f"</div>"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# -- Presence barcode (thicker 4px bars) --
|
| 66 |
+
barcode_html = ""
|
| 67 |
+
if card.get("presence_vector") is not None:
|
| 68 |
+
bars = []
|
| 69 |
+
for val in card["presence_vector"]:
|
| 70 |
+
color = COLORS["core"] if val == 1 else "#EEEEEE"
|
| 71 |
+
bars.append(
|
| 72 |
+
f'<span style="display:inline-block;width:4px;height:24px;'
|
| 73 |
+
f'background:{color};margin:0;border-radius:1px;"></span>'
|
| 74 |
+
)
|
| 75 |
+
barcode_html = (
|
| 76 |
+
f'<div style="margin-bottom:14px;">'
|
| 77 |
+
f'<div style="font-size:11px;font-weight:600;text-transform:uppercase;'
|
| 78 |
+
f'letter-spacing:1px;color:{COLORS["text_secondary"]};margin-bottom:6px;">'
|
| 79 |
+
f"Presence Barcode</div>"
|
| 80 |
+
f'<div style="display:flex;gap:1px;flex-wrap:wrap;">{"".join(bars)}</div>'
|
| 81 |
+
f"</div>"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# -- Location info --
|
| 85 |
+
if card.get("contig"):
|
| 86 |
+
contig_short = card["contig"]
|
| 87 |
+
if "|" in contig_short:
|
| 88 |
+
contig_short = contig_short.split("|")[-1]
|
| 89 |
+
location_html = (
|
| 90 |
+
f'<div style="margin-bottom:14px;">'
|
| 91 |
+
f'<div style="font-size:11px;font-weight:600;text-transform:uppercase;'
|
| 92 |
+
f'letter-spacing:1px;color:{COLORS["text_secondary"]};margin-bottom:6px;">'
|
| 93 |
+
f"Genomic Location</div>"
|
| 94 |
+
f'<div style="font-size:13px;line-height:1.6;">'
|
| 95 |
+
f'<div><span style="color:{COLORS["text_secondary"]};width:60px;display:inline-block;">'
|
| 96 |
+
f'Contig</span> <code style="background:#F5F5F0;padding:2px 6px;border-radius:4px;'
|
| 97 |
+
f'font-size:12px;">{contig_short}</code></div>'
|
| 98 |
+
f'<div><span style="color:{COLORS["text_secondary"]};width:60px;display:inline-block;">'
|
| 99 |
+
f'Position</span> <span style="font-weight:500;">{card["start"]:,} – '
|
| 100 |
+
f'{card["end"]:,}</span> '
|
| 101 |
+
f'<span style="color:{COLORS["text_secondary"]};">({card["strand"]})</span></div>'
|
| 102 |
+
f"</div></div>"
|
| 103 |
+
)
|
| 104 |
+
else:
|
| 105 |
+
location_html = (
|
| 106 |
+
f'<div style="margin-bottom:14px;font-size:13px;color:{COLORS["text_secondary"]};">'
|
| 107 |
+
f"<em>No coordinate annotation available</em></div>"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# -- Protein length --
|
| 111 |
+
protein_html = ""
|
| 112 |
+
if card.get("protein_length"):
|
| 113 |
+
protein_html = (
|
| 114 |
+
f'<div style="margin-bottom:14px;">'
|
| 115 |
+
f'<div style="font-size:11px;font-weight:600;text-transform:uppercase;'
|
| 116 |
+
f'letter-spacing:1px;color:{COLORS["text_secondary"]};margin-bottom:6px;">'
|
| 117 |
+
f"Protein</div>"
|
| 118 |
+
f'<div style="font-size:13px;">'
|
| 119 |
+
f'<span style="font-weight:600;font-size:20px;color:{COLORS["text_primary"]};">'
|
| 120 |
+
f'{card["protein_length"]}</span>'
|
| 121 |
+
f'<span style="color:{COLORS["text_secondary"]};margin-left:4px;">amino acids</span>'
|
| 122 |
+
f"</div></div>"
|
| 123 |
+
)
|
| 124 |
+
else:
|
| 125 |
+
protein_html = (
|
| 126 |
+
f'<div style="margin-bottom:14px;font-size:13px;color:{COLORS["text_secondary"]};">'
|
| 127 |
+
f"<em>No protein data available</em></div>"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# -- Assemble card --
|
| 131 |
+
return f'''
|
| 132 |
+
<div class="gene-card-panel" style="padding:20px;">
|
| 133 |
+
{header_html}
|
| 134 |
+
{freq_html}
|
| 135 |
+
{barcode_html}
|
| 136 |
+
{location_html}
|
| 137 |
+
{protein_html}
|
| 138 |
+
</div>
|
| 139 |
+
'''
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# =====================================================================
|
| 143 |
+
# Gradio UI builder
|
| 144 |
+
# =====================================================================
|
| 145 |
|
| 146 |
def build_gene_card_panel():
|
| 147 |
+
"""Build Gene Card side panel. Returns dict of components.
|
| 148 |
+
|
| 149 |
+
Returned keys (prefixed with ``gc_`` by layout.py):
|
| 150 |
+
column, gene_card_html, show_genome_btn, show_protein_btn,
|
| 151 |
+
pin_card_btn, download_gene_btn, gene_report_file
|
| 152 |
+
"""
|
| 153 |
with gr.Column(visible=False, scale=1) as gene_card_col:
|
| 154 |
+
gr.HTML(
|
| 155 |
+
'<div style="font-size:15px;font-weight:700;margin-bottom:4px;'
|
| 156 |
+
f'color:{COLORS["text_primary"]};">Gene Card</div>'
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
gene_card_html = gr.HTML(value="")
|
| 160 |
|
| 161 |
with gr.Row():
|
| 162 |
+
show_genome_btn = gr.Button(
|
| 163 |
+
"Show on Genome",
|
| 164 |
+
size="sm",
|
| 165 |
+
variant="secondary",
|
| 166 |
+
)
|
| 167 |
+
show_protein_btn = gr.Button(
|
| 168 |
+
"Show Protein",
|
| 169 |
+
size="sm",
|
| 170 |
+
variant="secondary",
|
| 171 |
+
)
|
| 172 |
|
| 173 |
with gr.Row():
|
| 174 |
+
pin_card_btn = gr.Button(
|
| 175 |
+
"Pin to Backpack",
|
| 176 |
+
size="sm",
|
| 177 |
+
variant="secondary",
|
| 178 |
+
)
|
| 179 |
+
download_gene_btn = gr.Button(
|
| 180 |
+
"Download Report",
|
| 181 |
+
size="sm",
|
| 182 |
+
)
|
| 183 |
|
| 184 |
gene_report_file = gr.File(label="Gene Report", visible=False)
|
| 185 |
|
ui/layout.py
CHANGED
|
@@ -34,25 +34,6 @@ def build_app(line_choices: list[str], contig_choices: list[str],
|
|
| 34 |
elem_classes=["progress-tracker"],
|
| 35 |
)
|
| 36 |
|
| 37 |
-
# Global filters
|
| 38 |
-
with gr.Accordion("Global Filters", open=False):
|
| 39 |
-
with gr.Row():
|
| 40 |
-
country_filter = gr.CheckboxGroup(
|
| 41 |
-
choices=[],
|
| 42 |
-
label="Filter by country",
|
| 43 |
-
info="Leave empty to show all",
|
| 44 |
-
)
|
| 45 |
-
annotated_toggle = gr.Checkbox(
|
| 46 |
-
label="Only annotated genes (with GFF entry)",
|
| 47 |
-
value=False,
|
| 48 |
-
)
|
| 49 |
-
with gr.Accordion("What is PAV?", open=False):
|
| 50 |
-
gr.Markdown(
|
| 51 |
-
"**Presence-Absence Variation (PAV)** describes genes that are present "
|
| 52 |
-
"in some lines but absent from others. The pangenome captures all genes "
|
| 53 |
-
"across the species, not just those in a single reference genome."
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
# Main content area
|
| 57 |
with gr.Row():
|
| 58 |
# Main tabs (left ~75%)
|
|
@@ -76,8 +57,6 @@ def build_app(line_choices: list[str], contig_choices: list[str],
|
|
| 76 |
"state": state,
|
| 77 |
"progress_html": progress_html,
|
| 78 |
"tabs": tabs,
|
| 79 |
-
"country_filter": country_filter,
|
| 80 |
-
"annotated_toggle": annotated_toggle,
|
| 81 |
"data_health_html": data_health_html,
|
| 82 |
**{f"q0_{k}": v for k, v in q0.items()},
|
| 83 |
**{f"q1_{k}": v for k, v in q1.items()},
|
|
@@ -94,12 +73,12 @@ def build_app(line_choices: list[str], contig_choices: list[str],
|
|
| 94 |
def _build_progress_html(active_quest: int) -> str:
|
| 95 |
"""Build progress tracker HTML."""
|
| 96 |
steps = [
|
| 97 |
-
("
|
| 98 |
-
("
|
| 99 |
-
("
|
| 100 |
-
("Genome
|
| 101 |
-
("Protein
|
| 102 |
-
("
|
| 103 |
]
|
| 104 |
parts = []
|
| 105 |
for label, idx in steps:
|
|
|
|
| 34 |
elem_classes=["progress-tracker"],
|
| 35 |
)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
# Main content area
|
| 38 |
with gr.Row():
|
| 39 |
# Main tabs (left ~75%)
|
|
|
|
| 57 |
"state": state,
|
| 58 |
"progress_html": progress_html,
|
| 59 |
"tabs": tabs,
|
|
|
|
|
|
|
| 60 |
"data_health_html": data_health_html,
|
| 61 |
**{f"q0_{k}": v for k, v in q0.items()},
|
| 62 |
**{f"q1_{k}": v for k, v in q1.items()},
|
|
|
|
| 73 |
def _build_progress_html(active_quest: int) -> str:
|
| 74 |
"""Build progress tracker HTML."""
|
| 75 |
steps = [
|
| 76 |
+
("Origins", 0),
|
| 77 |
+
("Genetic Landscape", 1),
|
| 78 |
+
("Gene Universe", 2),
|
| 79 |
+
("Genome Explorer", 3),
|
| 80 |
+
("Protein World", 4),
|
| 81 |
+
("Your Report", 5),
|
| 82 |
]
|
| 83 |
parts = []
|
| 84 |
for label, idx in steps:
|
ui/quest0.py
CHANGED
|
@@ -1,54 +1,194 @@
|
|
| 1 |
-
"""Quest 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_quest0(line_choices: list[str]):
|
| 7 |
-
"""Build Quest 0 tab components. Returns dict of components.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
interactive=True,
|
| 20 |
)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
with gr.Row():
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
label="Total Genes Present",
|
| 25 |
interactive=False,
|
| 26 |
value="--",
|
| 27 |
)
|
| 28 |
-
|
| 29 |
label="Unique Genes",
|
| 30 |
interactive=False,
|
| 31 |
value="--",
|
| 32 |
info="Genes found only in this line",
|
| 33 |
)
|
| 34 |
-
|
| 35 |
label="Nearest Neighbor",
|
| 36 |
interactive=False,
|
| 37 |
value="--",
|
| 38 |
info="Most similar line by gene content",
|
| 39 |
)
|
| 40 |
|
|
|
|
| 41 |
start_btn = gr.Button(
|
| 42 |
-
"
|
| 43 |
variant="primary",
|
| 44 |
size="lg",
|
| 45 |
)
|
| 46 |
|
| 47 |
return {
|
| 48 |
"tab": tab,
|
|
|
|
|
|
|
| 49 |
"line_dropdown": line_dropdown,
|
| 50 |
-
"total_genes":
|
| 51 |
-
"unique_genes":
|
| 52 |
-
"nearest_neighbor":
|
| 53 |
"start_btn": start_btn,
|
| 54 |
}
|
|
|
|
| 1 |
+
"""Quest 0 — Chapter 1: Origins.
|
| 2 |
+
|
| 3 |
+
Hero header, interactive 3D globe of line origins, line selection
|
| 4 |
+
with metric cards, and a 'Begin Exploration' call to action.
|
| 5 |
+
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
|
| 10 |
+
from src.plot_config import COLORS, COUNTRY_COLORS, apply_template
|
| 11 |
+
from src.utils import COUNTRY_COORDS, build_hero_header, build_metric_card
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# =====================================================================
|
| 15 |
+
# Standalone figure builder — importable by callbacks.py
|
| 16 |
+
# =====================================================================
|
| 17 |
+
|
| 18 |
+
def build_globe_figure(line_stats_df):
|
| 19 |
+
"""Build a 3D orthographic globe showing pigeon pea line origins.
|
| 20 |
+
|
| 21 |
+
Parameters
|
| 22 |
+
----------
|
| 23 |
+
line_stats_df : pandas.DataFrame
|
| 24 |
+
Must contain at least a ``country`` column.
|
| 25 |
+
|
| 26 |
+
Returns
|
| 27 |
+
-------
|
| 28 |
+
plotly.graph_objects.Figure
|
| 29 |
+
"""
|
| 30 |
+
country_counts = line_stats_df.groupby("country").size().reset_index(name="count")
|
| 31 |
+
|
| 32 |
+
fig = go.Figure()
|
| 33 |
+
for _, row in country_counts.iterrows():
|
| 34 |
+
country = row["country"]
|
| 35 |
+
count = row["count"]
|
| 36 |
+
if country in COUNTRY_COORDS:
|
| 37 |
+
lat, lon = COUNTRY_COORDS[country]
|
| 38 |
+
fig.add_trace(go.Scattergeo(
|
| 39 |
+
lat=[lat],
|
| 40 |
+
lon=[lon],
|
| 41 |
+
text=f"{country}: {count} lines",
|
| 42 |
+
hoverinfo="text",
|
| 43 |
+
marker=dict(
|
| 44 |
+
size=max(8, count ** 0.5 * 5),
|
| 45 |
+
color=COUNTRY_COLORS.get(country, "#9E9E9E"),
|
| 46 |
+
opacity=0.9,
|
| 47 |
+
line=dict(width=1.5, color="white"),
|
| 48 |
+
),
|
| 49 |
+
name=country,
|
| 50 |
+
showlegend=True,
|
| 51 |
+
))
|
| 52 |
+
|
| 53 |
+
fig.update_geos(
|
| 54 |
+
projection_type="orthographic",
|
| 55 |
+
showocean=True, oceancolor=COLORS["bg_dark"],
|
| 56 |
+
showland=True, landcolor="#2a3a4a",
|
| 57 |
+
showcoastlines=True, coastlinecolor="#4a5a6a",
|
| 58 |
+
showcountries=True, countrycolor="#3a4a5a",
|
| 59 |
+
showlakes=False,
|
| 60 |
+
bgcolor=COLORS["bg_dark"],
|
| 61 |
+
projection_rotation=dict(lon=78, lat=20), # Centre on India
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
fig.update_layout(
|
| 65 |
+
height=500,
|
| 66 |
+
margin=dict(l=0, r=0, t=0, b=0),
|
| 67 |
+
paper_bgcolor=COLORS["bg_dark"],
|
| 68 |
+
geo=dict(bgcolor=COLORS["bg_dark"]),
|
| 69 |
+
legend=dict(
|
| 70 |
+
bgcolor="rgba(26,35,50,0.85)",
|
| 71 |
+
font=dict(color="#E0E0E0", size=11),
|
| 72 |
+
bordercolor="rgba(0,0,0,0)",
|
| 73 |
+
x=0.02,
|
| 74 |
+
y=0.98,
|
| 75 |
+
),
|
| 76 |
+
showlegend=True,
|
| 77 |
+
)
|
| 78 |
+
return fig
|
| 79 |
+
|
| 80 |
|
| 81 |
+
def _empty_globe():
|
| 82 |
+
"""Return a minimal placeholder globe before data is loaded."""
|
| 83 |
+
fig = go.Figure(go.Scattergeo())
|
| 84 |
+
fig.update_geos(
|
| 85 |
+
projection_type="orthographic",
|
| 86 |
+
showocean=True, oceancolor=COLORS["bg_dark"],
|
| 87 |
+
showland=True, landcolor="#2a3a4a",
|
| 88 |
+
showcoastlines=True, coastlinecolor="#4a5a6a",
|
| 89 |
+
showcountries=True, countrycolor="#3a4a5a",
|
| 90 |
+
showlakes=False,
|
| 91 |
+
bgcolor=COLORS["bg_dark"],
|
| 92 |
+
projection_rotation=dict(lon=78, lat=20),
|
| 93 |
+
)
|
| 94 |
+
fig.update_layout(
|
| 95 |
+
height=500,
|
| 96 |
+
margin=dict(l=0, r=0, t=0, b=0),
|
| 97 |
+
paper_bgcolor=COLORS["bg_dark"],
|
| 98 |
+
geo=dict(bgcolor=COLORS["bg_dark"]),
|
| 99 |
+
)
|
| 100 |
+
return fig
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# =====================================================================
|
| 104 |
+
# Build the Gradio tab
|
| 105 |
+
# =====================================================================
|
| 106 |
|
| 107 |
def build_quest0(line_choices: list[str]):
|
| 108 |
+
"""Build Quest 0 tab components. Returns dict of components.
|
| 109 |
+
|
| 110 |
+
The returned dictionary keys **must** match the original contract so
|
| 111 |
+
that ``layout.py`` can prefix them with ``q0_`` and ``app.py`` can
|
| 112 |
+
wire callbacks without changes.
|
| 113 |
+
|
| 114 |
+
Keys: tab, line_dropdown, total_genes, unique_genes,
|
| 115 |
+
nearest_neighbor, start_btn
|
| 116 |
+
"""
|
| 117 |
+
with gr.Tab("Origins", id="quest0") as tab:
|
| 118 |
+
|
| 119 |
+
# -- A) Hero header --------------------------------------------------
|
| 120 |
+
hero_html = gr.HTML(
|
| 121 |
+
value=build_hero_header(
|
| 122 |
+
total_genes=55_512,
|
| 123 |
+
n_lines=90,
|
| 124 |
+
n_countries=17,
|
| 125 |
+
n_clusters=3,
|
| 126 |
+
),
|
| 127 |
)
|
| 128 |
|
| 129 |
+
# -- B) Interactive 3D globe -----------------------------------------
|
| 130 |
+
globe_plot = gr.Plot(
|
| 131 |
+
value=_empty_globe(),
|
| 132 |
+
label="Origins Globe",
|
|
|
|
| 133 |
)
|
| 134 |
|
| 135 |
+
gr.HTML(
|
| 136 |
+
'<p style="text-align:center; color:#757575; font-size:13px; '
|
| 137 |
+
'margin-top:-8px; margin-bottom:16px;">'
|
| 138 |
+
"Drag to rotate the globe. Click a country in the legend to filter lines."
|
| 139 |
+
"</p>"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# -- C) Country filter + Line selection --------------------------------
|
| 143 |
with gr.Row():
|
| 144 |
+
country_dropdown = gr.Dropdown(
|
| 145 |
+
choices=["All countries"] + sorted(COUNTRY_COORDS.keys()),
|
| 146 |
+
value="All countries",
|
| 147 |
+
label="Filter by country",
|
| 148 |
+
interactive=True,
|
| 149 |
+
scale=1,
|
| 150 |
+
)
|
| 151 |
+
line_dropdown = gr.Dropdown(
|
| 152 |
+
choices=line_choices,
|
| 153 |
+
label="Select a pigeon pea line",
|
| 154 |
+
info="90 lines from 17 countries",
|
| 155 |
+
interactive=True,
|
| 156 |
+
scale=2,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
with gr.Row(equal_height=True):
|
| 160 |
+
total_genes_html = gr.Textbox(
|
| 161 |
label="Total Genes Present",
|
| 162 |
interactive=False,
|
| 163 |
value="--",
|
| 164 |
)
|
| 165 |
+
unique_genes_html = gr.Textbox(
|
| 166 |
label="Unique Genes",
|
| 167 |
interactive=False,
|
| 168 |
value="--",
|
| 169 |
info="Genes found only in this line",
|
| 170 |
)
|
| 171 |
+
nearest_neighbor_html = gr.Textbox(
|
| 172 |
label="Nearest Neighbor",
|
| 173 |
interactive=False,
|
| 174 |
value="--",
|
| 175 |
info="Most similar line by gene content",
|
| 176 |
)
|
| 177 |
|
| 178 |
+
# -- "Begin Exploration" button ---------------------------------------
|
| 179 |
start_btn = gr.Button(
|
| 180 |
+
"Begin Exploration",
|
| 181 |
variant="primary",
|
| 182 |
size="lg",
|
| 183 |
)
|
| 184 |
|
| 185 |
return {
|
| 186 |
"tab": tab,
|
| 187 |
+
"globe_plot": globe_plot,
|
| 188 |
+
"country_dropdown": country_dropdown,
|
| 189 |
"line_dropdown": line_dropdown,
|
| 190 |
+
"total_genes": total_genes_html,
|
| 191 |
+
"unique_genes": unique_genes_html,
|
| 192 |
+
"nearest_neighbor": nearest_neighbor_html,
|
| 193 |
"start_btn": start_btn,
|
| 194 |
}
|
ui/quest1.py
CHANGED
|
@@ -1,17 +1,188 @@
|
|
| 1 |
-
"""Quest 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_quest1():
|
| 7 |
-
"""Build Quest 1 tab components. Returns dict of components.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
|
|
|
|
| 15 |
color_radio = gr.Radio(
|
| 16 |
choices=["Country", "Cluster"],
|
| 17 |
value="Country",
|
|
@@ -19,8 +190,21 @@ def build_quest1():
|
|
| 19 |
interactive=True,
|
| 20 |
)
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
with gr.Row():
|
| 25 |
party_display = gr.Textbox(
|
| 26 |
label="Selected party (lasso/click to select)",
|
|
@@ -28,19 +212,28 @@ def build_quest1():
|
|
| 28 |
value="None selected",
|
| 29 |
lines=2,
|
| 30 |
)
|
| 31 |
-
compare_btn = gr.Button(
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
comparison_plot = gr.Plot(label="Comparison", visible=False)
|
| 34 |
|
| 35 |
with gr.Accordion("What does this mean?", open=False):
|
| 36 |
-
gr.
|
| 37 |
-
"
|
| 38 |
-
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
)
|
| 43 |
|
|
|
|
| 44 |
return {
|
| 45 |
"tab": tab,
|
| 46 |
"color_radio": color_radio,
|
|
|
|
| 1 |
+
"""Quest 1 — Chapter 2: Genetic Landscape.
|
| 2 |
+
|
| 3 |
+
3D UMAP constellation of 90 pigeon pea lines, colored by country or
|
| 4 |
+
cluster, with comparison tools and neighbor list.
|
| 5 |
+
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
|
| 10 |
+
from src.plot_config import (
|
| 11 |
+
COLORS, COUNTRY_COLORS, CLUSTER_COLORS, apply_template,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# =====================================================================
|
| 16 |
+
# Standalone figure builder — importable by callbacks.py
|
| 17 |
+
# =====================================================================
|
| 18 |
+
|
| 19 |
+
def build_umap_3d(embedding_df, line_stats_df,
|
| 20 |
+
color_by="country", selected_line=None):
|
| 21 |
+
"""Build a 3D UMAP scatter with constellation effect.
|
| 22 |
+
|
| 23 |
+
If the embedding only contains 2D coordinates (``umap_x``, ``umap_y``),
|
| 24 |
+
the third axis is set to zero so the visualisation still renders
|
| 25 |
+
correctly (it will appear flat until a 3D embedding is computed).
|
| 26 |
+
|
| 27 |
+
Parameters
|
| 28 |
+
----------
|
| 29 |
+
embedding_df : pandas.DataFrame
|
| 30 |
+
Must contain ``line_id``, ``umap_x``, ``umap_y``, and optionally
|
| 31 |
+
``umap_z`` and ``cluster_id``.
|
| 32 |
+
line_stats_df : pandas.DataFrame
|
| 33 |
+
Must contain ``line_id`` and ``country``.
|
| 34 |
+
color_by : str
|
| 35 |
+
``'country'`` or ``'cluster'``.
|
| 36 |
+
selected_line : str or None
|
| 37 |
+
Line to highlight with a gold diamond.
|
| 38 |
+
|
| 39 |
+
Returns
|
| 40 |
+
-------
|
| 41 |
+
plotly.graph_objects.Figure
|
| 42 |
+
"""
|
| 43 |
+
df = embedding_df.merge(
|
| 44 |
+
line_stats_df[["line_id", "country"]], on="line_id", how="left",
|
| 45 |
+
)
|
| 46 |
+
df["country"] = df["country"].fillna("Unknown")
|
| 47 |
+
|
| 48 |
+
# Gracefully handle missing umap_z
|
| 49 |
+
if "umap_z" not in df.columns:
|
| 50 |
+
df["umap_z"] = 0.0
|
| 51 |
+
|
| 52 |
+
fig = go.Figure()
|
| 53 |
+
|
| 54 |
+
if color_by == "country":
|
| 55 |
+
for country in sorted(df["country"].unique()):
|
| 56 |
+
mask = df["country"] == country
|
| 57 |
+
subset = df[mask]
|
| 58 |
+
fig.add_trace(go.Scatter3d(
|
| 59 |
+
x=subset["umap_x"],
|
| 60 |
+
y=subset["umap_y"],
|
| 61 |
+
z=subset["umap_z"],
|
| 62 |
+
mode="markers",
|
| 63 |
+
marker=dict(
|
| 64 |
+
size=5,
|
| 65 |
+
color=COUNTRY_COLORS.get(country, "#9E9E9E"),
|
| 66 |
+
opacity=0.85,
|
| 67 |
+
line=dict(width=1, color="white"),
|
| 68 |
+
),
|
| 69 |
+
name=country,
|
| 70 |
+
text=subset["line_id"],
|
| 71 |
+
hovertemplate="%{text}<br>Country: " + country + "<extra></extra>",
|
| 72 |
+
))
|
| 73 |
+
else: # cluster
|
| 74 |
+
if "cluster_id" not in df.columns:
|
| 75 |
+
df["cluster_id"] = 0
|
| 76 |
+
for cid in sorted(df["cluster_id"].unique()):
|
| 77 |
+
mask = df["cluster_id"] == cid
|
| 78 |
+
subset = df[mask]
|
| 79 |
+
color = CLUSTER_COLORS[int(cid)] if int(cid) < len(CLUSTER_COLORS) else "#9E9E9E"
|
| 80 |
+
fig.add_trace(go.Scatter3d(
|
| 81 |
+
x=subset["umap_x"],
|
| 82 |
+
y=subset["umap_y"],
|
| 83 |
+
z=subset["umap_z"],
|
| 84 |
+
mode="markers",
|
| 85 |
+
marker=dict(
|
| 86 |
+
size=5,
|
| 87 |
+
color=color,
|
| 88 |
+
opacity=0.85,
|
| 89 |
+
line=dict(width=1, color="white"),
|
| 90 |
+
),
|
| 91 |
+
name=f"Cluster {cid}",
|
| 92 |
+
text=subset["line_id"],
|
| 93 |
+
hovertemplate="%{text}<br>Cluster: " + str(cid) + "<extra></extra>",
|
| 94 |
+
))
|
| 95 |
+
|
| 96 |
+
# Highlight selected line
|
| 97 |
+
if selected_line and selected_line in df["line_id"].values:
|
| 98 |
+
sel = df[df["line_id"] == selected_line].iloc[0]
|
| 99 |
+
fig.add_trace(go.Scatter3d(
|
| 100 |
+
x=[sel["umap_x"]],
|
| 101 |
+
y=[sel["umap_y"]],
|
| 102 |
+
z=[sel["umap_z"]],
|
| 103 |
+
mode="markers+text",
|
| 104 |
+
marker=dict(
|
| 105 |
+
size=12,
|
| 106 |
+
color=COLORS["selected"],
|
| 107 |
+
symbol="diamond",
|
| 108 |
+
line=dict(width=2, color="white"),
|
| 109 |
+
),
|
| 110 |
+
text=[selected_line],
|
| 111 |
+
textposition="top center",
|
| 112 |
+
name="Your Line",
|
| 113 |
+
hovertemplate=selected_line + "<extra></extra>",
|
| 114 |
+
))
|
| 115 |
|
| 116 |
+
fig.update_layout(
|
| 117 |
+
scene=dict(
|
| 118 |
+
xaxis=dict(showgrid=False, showticklabels=False, title="",
|
| 119 |
+
showbackground=False),
|
| 120 |
+
yaxis=dict(showgrid=False, showticklabels=False, title="",
|
| 121 |
+
showbackground=False),
|
| 122 |
+
zaxis=dict(showgrid=False, showticklabels=False, title="",
|
| 123 |
+
showbackground=False),
|
| 124 |
+
bgcolor=COLORS["bg_dark"],
|
| 125 |
+
),
|
| 126 |
+
paper_bgcolor=COLORS["bg_dark"],
|
| 127 |
+
height=550,
|
| 128 |
+
margin=dict(l=0, r=0, t=30, b=0),
|
| 129 |
+
legend=dict(
|
| 130 |
+
bgcolor="rgba(26,35,50,0.85)",
|
| 131 |
+
font=dict(color="#E0E0E0"),
|
| 132 |
+
),
|
| 133 |
+
)
|
| 134 |
+
return fig
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _empty_umap_3d():
|
| 138 |
+
"""Return a minimal placeholder 3D scatter before data is loaded."""
|
| 139 |
+
fig = go.Figure(go.Scatter3d(x=[0], y=[0], z=[0], mode="markers",
|
| 140 |
+
marker=dict(size=1, opacity=0)))
|
| 141 |
+
fig.update_layout(
|
| 142 |
+
scene=dict(
|
| 143 |
+
xaxis=dict(showgrid=False, showticklabels=False, title="",
|
| 144 |
+
showbackground=False),
|
| 145 |
+
yaxis=dict(showgrid=False, showticklabels=False, title="",
|
| 146 |
+
showbackground=False),
|
| 147 |
+
zaxis=dict(showgrid=False, showticklabels=False, title="",
|
| 148 |
+
showbackground=False),
|
| 149 |
+
bgcolor=COLORS["bg_dark"],
|
| 150 |
+
),
|
| 151 |
+
paper_bgcolor=COLORS["bg_dark"],
|
| 152 |
+
height=550,
|
| 153 |
+
margin=dict(l=0, r=0, t=30, b=0),
|
| 154 |
+
)
|
| 155 |
+
return fig
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# =====================================================================
|
| 159 |
+
# Build the Gradio tab
|
| 160 |
+
# =====================================================================
|
| 161 |
|
| 162 |
def build_quest1():
|
| 163 |
+
"""Build Quest 1 tab components. Returns dict of components.
|
| 164 |
+
|
| 165 |
+
The returned dictionary keys **must** match the original contract so
|
| 166 |
+
that ``layout.py`` can prefix them with ``q1_`` and ``app.py`` can
|
| 167 |
+
wire callbacks without changes.
|
| 168 |
+
|
| 169 |
+
Keys: tab, color_radio, umap_plot, party_display,
|
| 170 |
+
compare_btn, comparison_plot
|
| 171 |
+
"""
|
| 172 |
+
with gr.Tab("Genetic Landscape", id="quest1") as tab:
|
| 173 |
+
|
| 174 |
+
gr.HTML(
|
| 175 |
+
'<h2 style="margin:0 0 4px 0; font-size:22px; font-weight:700; '
|
| 176 |
+
'color:#1A1A1A;">'
|
| 177 |
+
"Genetic Landscape"
|
| 178 |
+
"</h2>"
|
| 179 |
+
'<p style="color:#757575; font-size:14px; margin:0 0 16px 0;">'
|
| 180 |
+
"Each point is a breeding line. Lines closer together share "
|
| 181 |
+
"more genes."
|
| 182 |
+
"</p>"
|
| 183 |
)
|
| 184 |
|
| 185 |
+
# -- A) Color-by control ---------------------------------------------
|
| 186 |
color_radio = gr.Radio(
|
| 187 |
choices=["Country", "Cluster"],
|
| 188 |
value="Country",
|
|
|
|
| 190 |
interactive=True,
|
| 191 |
)
|
| 192 |
|
| 193 |
+
# -- B) 3D UMAP constellation ----------------------------------------
|
| 194 |
+
umap_plot = gr.Plot(
|
| 195 |
+
value=_empty_umap_3d(),
|
| 196 |
+
label="UMAP of 90 pigeon pea lines",
|
| 197 |
+
)
|
| 198 |
|
| 199 |
+
gr.HTML(
|
| 200 |
+
'<p style="text-align:center; color:#757575; font-size:13px; '
|
| 201 |
+
'margin-top:-8px; margin-bottom:16px;">'
|
| 202 |
+
"Drag to rotate. Hover for line details. "
|
| 203 |
+
"Switch coloring above to explore country or cluster groupings."
|
| 204 |
+
"</p>"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# -- C) Neighbor list + comparison ------------------------------------
|
| 208 |
with gr.Row():
|
| 209 |
party_display = gr.Textbox(
|
| 210 |
label="Selected party (lasso/click to select)",
|
|
|
|
| 212 |
value="None selected",
|
| 213 |
lines=2,
|
| 214 |
)
|
| 215 |
+
compare_btn = gr.Button(
|
| 216 |
+
"Compare my line to party",
|
| 217 |
+
variant="secondary",
|
| 218 |
+
)
|
| 219 |
|
| 220 |
comparison_plot = gr.Plot(label="Comparison", visible=False)
|
| 221 |
|
| 222 |
with gr.Accordion("What does this mean?", open=False):
|
| 223 |
+
gr.HTML(
|
| 224 |
+
'<div style="padding:8px 12px; line-height:1.7; '
|
| 225 |
+
'color:#1A1A1A; font-size:14px;">'
|
| 226 |
+
"<b>UMAP</b> reduces the high-dimensional PAV matrix so "
|
| 227 |
+
"that similar lines appear close together.<br>"
|
| 228 |
+
"<b>Country coloring</b> reveals geographic origins.<br>"
|
| 229 |
+
"<b>Cluster coloring</b> shows groups identified by "
|
| 230 |
+
"KMeans (k=3, silhouette=0.649).<br>"
|
| 231 |
+
"Click a point to see its stats; lasso-select multiple "
|
| 232 |
+
"points to compare with your chosen line."
|
| 233 |
+
"</div>"
|
| 234 |
)
|
| 235 |
|
| 236 |
+
# Return same keys as original for backward compatibility
|
| 237 |
return {
|
| 238 |
"tab": tab,
|
| 239 |
"color_radio": color_radio,
|
ui/quest2.py
CHANGED
|
@@ -1,17 +1,151 @@
|
|
| 1 |
-
"""Quest 2: Core
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_quest2():
|
| 7 |
-
"""Build Quest 2 tab components.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
|
|
|
|
| 15 |
with gr.Row():
|
| 16 |
core_slider = gr.Slider(
|
| 17 |
minimum=50, maximum=100, value=95, step=1,
|
|
@@ -24,10 +158,10 @@ def build_quest2():
|
|
| 24 |
info="Genes present in fewer than this % of lines",
|
| 25 |
)
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
histogram_plot = gr.Plot(label="Gene frequency distribution")
|
| 30 |
|
|
|
|
| 31 |
gr.Markdown("### Gene Treasure List")
|
| 32 |
filter_radio = gr.Radio(
|
| 33 |
choices=["All", "Unique to my line", "Rare (<5 lines)", "Cluster markers"],
|
|
|
|
| 1 |
+
"""Quest 2: Gene Universe — Core / Shell / Cloud classification explorer.
|
| 2 |
|
| 3 |
+
Features a Sunburst hero chart with adjustable thresholds, frequency
|
| 4 |
+
histogram, and a gene treasure table with pin-to-backpack support.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
import gradio as gr
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
|
| 11 |
+
from src.utils import PRECOMPUTED_DIR
|
| 12 |
+
from src.plot_config import COLORS, apply_template
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Load sunburst hierarchy once at import time
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
_SUNBURST_PATH = PRECOMPUTED_DIR / "sunburst_hierarchy.json"
|
| 18 |
+
_SUNBURST_DATA: dict | None = None
|
| 19 |
+
if _SUNBURST_PATH.exists():
|
| 20 |
+
with open(_SUNBURST_PATH) as _f:
|
| 21 |
+
_SUNBURST_DATA = json.load(_f)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Standalone chart builders (callable from callbacks / app.py)
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
def build_sunburst_figure(sunburst_data=None, gene_freq_df=None, state=None,
|
| 29 |
+
core_thresh=95, cloud_thresh=15):
|
| 30 |
+
"""Build the gene-universe sunburst chart.
|
| 31 |
+
|
| 32 |
+
Parameters
|
| 33 |
+
----------
|
| 34 |
+
sunburst_data : dict or None
|
| 35 |
+
Pre-loaded hierarchy dict with keys ``ids``, ``labels``, ``parents``,
|
| 36 |
+
``values``. Falls back to the module-level ``_SUNBURST_DATA``.
|
| 37 |
+
gene_freq_df : DataFrame or None
|
| 38 |
+
If provided the counts are recomputed live from the frequency column
|
| 39 |
+
using the given thresholds.
|
| 40 |
+
core_thresh, cloud_thresh : float
|
| 41 |
+
Percentage thresholds for core (>=) and cloud (<).
|
| 42 |
+
"""
|
| 43 |
+
if sunburst_data is None:
|
| 44 |
+
sunburst_data = _SUNBURST_DATA
|
| 45 |
+
|
| 46 |
+
# Recompute counts based on thresholds when live data is available
|
| 47 |
+
if gene_freq_df is not None:
|
| 48 |
+
core_count = int((gene_freq_df["freq_pct"] >= core_thresh).sum())
|
| 49 |
+
cloud_count = int((gene_freq_df["freq_pct"] < cloud_thresh).sum())
|
| 50 |
+
shell_count = len(gene_freq_df) - core_count - cloud_count
|
| 51 |
+
total = len(gene_freq_df)
|
| 52 |
+
ids = ["total", "core", "shell", "cloud"]
|
| 53 |
+
labels = [
|
| 54 |
+
"All Genes",
|
| 55 |
+
f"Core ({core_count:,})",
|
| 56 |
+
f"Shell ({shell_count:,})",
|
| 57 |
+
f"Cloud ({cloud_count:,})",
|
| 58 |
+
]
|
| 59 |
+
parents = ["", "total", "total", "total"]
|
| 60 |
+
values = [total, core_count, shell_count, cloud_count]
|
| 61 |
+
elif sunburst_data is not None:
|
| 62 |
+
ids = sunburst_data["ids"]
|
| 63 |
+
labels = sunburst_data["labels"]
|
| 64 |
+
parents = sunburst_data["parents"]
|
| 65 |
+
values = sunburst_data["values"]
|
| 66 |
+
else:
|
| 67 |
+
# Fallback — empty figure
|
| 68 |
+
fig = go.Figure()
|
| 69 |
+
fig.add_annotation(text="Sunburst data not available", showarrow=False)
|
| 70 |
+
return fig
|
| 71 |
|
| 72 |
+
fig = go.Figure(go.Sunburst(
|
| 73 |
+
ids=ids,
|
| 74 |
+
labels=labels,
|
| 75 |
+
parents=parents,
|
| 76 |
+
values=values,
|
| 77 |
+
branchvalues="total",
|
| 78 |
+
marker=dict(colors=[
|
| 79 |
+
COLORS["bg_light"], # root ring
|
| 80 |
+
COLORS["core"],
|
| 81 |
+
COLORS["shell"],
|
| 82 |
+
COLORS["cloud"],
|
| 83 |
+
]),
|
| 84 |
+
textinfo="label+percent parent",
|
| 85 |
+
hovertemplate=(
|
| 86 |
+
"<b>%{label}</b><br>"
|
| 87 |
+
"%{value:,} genes<br>"
|
| 88 |
+
"%{percentParent:.1%} of total"
|
| 89 |
+
"<extra></extra>"
|
| 90 |
+
),
|
| 91 |
+
insidetextorientation="radial",
|
| 92 |
+
))
|
| 93 |
+
fig.update_layout(
|
| 94 |
+
height=450,
|
| 95 |
+
margin=dict(l=10, r=10, t=10, b=10),
|
| 96 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 97 |
+
)
|
| 98 |
+
return fig
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# Infographic header HTML
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
_INFOGRAPHIC_HTML = """
|
| 106 |
+
<div style="display:flex;gap:24px;justify-content:center;padding:20px;">
|
| 107 |
+
<div style="text-align:center;">
|
| 108 |
+
<span style="color:#2E7D32;font-size:28px;">\u25cf</span>
|
| 109 |
+
<div style="font-weight:700;">Core (\u226595%)</div>
|
| 110 |
+
<div style="color:#757575;font-size:13px;">Essential genes shared by nearly all lines</div>
|
| 111 |
+
</div>
|
| 112 |
+
<div style="text-align:center;">
|
| 113 |
+
<span style="color:#F9A825;font-size:28px;">\u25cf</span>
|
| 114 |
+
<div style="font-weight:700;">Shell (15\u201395%)</div>
|
| 115 |
+
<div style="color:#757575;font-size:13px;">Group-specific genes found in subsets</div>
|
| 116 |
+
</div>
|
| 117 |
+
<div style="text-align:center;">
|
| 118 |
+
<span style="color:#C62828;font-size:28px;">\u25cf</span>
|
| 119 |
+
<div style="font-weight:700;">Cloud (<15%)</div>
|
| 120 |
+
<div style="color:#757575;font-size:13px;">Rare genes unique to few lines</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
""".strip()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
# Tab builder
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
|
| 130 |
def build_quest2():
|
| 131 |
+
"""Build Quest 2 tab components.
|
| 132 |
+
|
| 133 |
+
Returns a dict whose keys match the original wiring contract:
|
| 134 |
+
tab, core_slider, cloud_slider, donut_plot, histogram_plot,
|
| 135 |
+
filter_radio, treasure_table, selected_gene_text, pin_btn,
|
| 136 |
+
backpack_display
|
| 137 |
+
"""
|
| 138 |
+
with gr.Tab("Gene Universe", id="quest2") as tab:
|
| 139 |
+
# --- A) Infographic header ---
|
| 140 |
+
gr.HTML(value=_INFOGRAPHIC_HTML)
|
| 141 |
+
|
| 142 |
+
# --- B) Sunburst hero chart ---
|
| 143 |
+
donut_plot = gr.Plot(
|
| 144 |
+
label="Gene Universe Sunburst",
|
| 145 |
+
value=build_sunburst_figure(),
|
| 146 |
)
|
| 147 |
|
| 148 |
+
# --- C) Threshold sliders ---
|
| 149 |
with gr.Row():
|
| 150 |
core_slider = gr.Slider(
|
| 151 |
minimum=50, maximum=100, value=95, step=1,
|
|
|
|
| 158 |
info="Genes present in fewer than this % of lines",
|
| 159 |
)
|
| 160 |
|
| 161 |
+
# --- D) Frequency histogram ---
|
| 162 |
+
histogram_plot = gr.Plot(label="Gene frequency distribution")
|
|
|
|
| 163 |
|
| 164 |
+
# --- E) Treasure table + gene selection + backpack ---
|
| 165 |
gr.Markdown("### Gene Treasure List")
|
| 166 |
filter_radio = gr.Radio(
|
| 167 |
choices=["All", "Unique to my line", "Rare (<5 lines)", "Cluster markers"],
|
ui/quest3.py
CHANGED
|
@@ -1,26 +1,171 @@
|
|
| 1 |
-
"""Quest 3: Genome
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_quest3(contig_choices: list[str]):
|
| 7 |
-
"""Build Quest 3 tab components.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
)
|
| 14 |
|
|
|
|
|
|
|
| 15 |
contig_dropdown = gr.Dropdown(
|
| 16 |
choices=contig_choices,
|
| 17 |
label="Select contig (top contigs by gene count)",
|
| 18 |
interactive=True,
|
| 19 |
)
|
| 20 |
|
| 21 |
-
heatmap_plot = gr.Plot(label="Variability heatmap (contigs x bins)")
|
| 22 |
-
|
| 23 |
-
gr.Markdown("### Contig Detail")
|
| 24 |
track_plot = gr.Plot(label="Gene track (colored by class)", visible=False)
|
| 25 |
|
| 26 |
region_table = gr.Dataframe(
|
|
|
|
| 1 |
+
"""Quest 3: Genome Explorer — Circos-style polar ring chart and contig drill-down.
|
| 2 |
|
| 3 |
+
Features a circular genome overview showing variability hotspots across
|
| 4 |
+
contigs, plus a linear gene-track view when drilling into a single contig.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
import gradio as gr
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
|
| 11 |
+
from src.utils import PRECOMPUTED_DIR
|
| 12 |
+
from src.plot_config import COLORS, apply_template
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Load polar contig layout once at import time
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
_POLAR_PATH = PRECOMPUTED_DIR / "polar_contig_layout.json"
|
| 18 |
+
_POLAR_DATA: list | None = None
|
| 19 |
+
if _POLAR_PATH.exists():
|
| 20 |
+
with open(_POLAR_PATH) as _f:
|
| 21 |
+
_POLAR_DATA = json.load(_f)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Standalone chart builder (callable from callbacks / app.py)
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
def build_circos_figure(polar_layout=None):
|
| 29 |
+
"""Build a Circos-style circular genome plot.
|
| 30 |
+
|
| 31 |
+
Parameters
|
| 32 |
+
----------
|
| 33 |
+
polar_layout : list or None
|
| 34 |
+
List of contig dicts from ``polar_contig_layout.json``. Falls back
|
| 35 |
+
to the module-level ``_POLAR_DATA`` when *None*.
|
| 36 |
+
"""
|
| 37 |
+
if polar_layout is None:
|
| 38 |
+
polar_layout = _POLAR_DATA
|
| 39 |
+
|
| 40 |
+
if not polar_layout:
|
| 41 |
+
fig = go.Figure()
|
| 42 |
+
fig.add_annotation(text="Polar layout data not available", showarrow=False)
|
| 43 |
+
return fig
|
| 44 |
+
|
| 45 |
+
fig = go.Figure()
|
| 46 |
+
|
| 47 |
+
for contig in polar_layout:
|
| 48 |
+
contig_id = contig["contig_id"]
|
| 49 |
+
# Truncate contig name for readability
|
| 50 |
+
short_name = (
|
| 51 |
+
contig_id.split("|")[-2]
|
| 52 |
+
if "|" in contig_id
|
| 53 |
+
else contig_id[-15:]
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
n_bins = max(len(contig["bins"]), 1)
|
| 57 |
+
bin_width = (contig["theta_end"] - contig["theta_start"]) / n_bins
|
| 58 |
+
|
| 59 |
+
# Add bins as polar bars
|
| 60 |
+
for b in contig["bins"]:
|
| 61 |
+
score = b.get("variability_score", 0)
|
| 62 |
+
# Color by variability: green -> amber -> red
|
| 63 |
+
if score == 0:
|
| 64 |
+
color = COLORS["core"]
|
| 65 |
+
elif score < 2:
|
| 66 |
+
color = COLORS["shell"]
|
| 67 |
+
else:
|
| 68 |
+
color = COLORS["cloud"]
|
| 69 |
|
| 70 |
+
fig.add_trace(go.Barpolar(
|
| 71 |
+
r=[b["total_genes"]],
|
| 72 |
+
theta=[b["theta"]],
|
| 73 |
+
width=[bin_width],
|
| 74 |
+
marker_color=color,
|
| 75 |
+
marker_line_color="rgba(255,255,255,0.3)",
|
| 76 |
+
marker_line_width=0.5,
|
| 77 |
+
opacity=0.85,
|
| 78 |
+
hovertemplate=(
|
| 79 |
+
f"{short_name}<br>"
|
| 80 |
+
f"Genes: %{{r}}<br>"
|
| 81 |
+
f"Score: {score:.1f}"
|
| 82 |
+
"<extra></extra>"
|
| 83 |
+
),
|
| 84 |
+
showlegend=False,
|
| 85 |
+
))
|
| 86 |
+
|
| 87 |
+
# Add a contig label at the midpoint
|
| 88 |
+
mid_theta = (contig["theta_start"] + contig["theta_end"]) / 2
|
| 89 |
+
max_r = (
|
| 90 |
+
max(b["total_genes"] for b in contig["bins"]) + 3
|
| 91 |
+
if contig["bins"]
|
| 92 |
+
else 5
|
| 93 |
+
)
|
| 94 |
+
fig.add_trace(go.Scatterpolar(
|
| 95 |
+
r=[max_r],
|
| 96 |
+
theta=[mid_theta],
|
| 97 |
+
mode="text",
|
| 98 |
+
text=[short_name],
|
| 99 |
+
textfont=dict(size=8, color=COLORS["text_secondary"]),
|
| 100 |
+
showlegend=False,
|
| 101 |
+
hoverinfo="skip",
|
| 102 |
+
))
|
| 103 |
+
|
| 104 |
+
fig.update_layout(
|
| 105 |
+
polar=dict(
|
| 106 |
+
bgcolor="rgba(0,0,0,0)",
|
| 107 |
+
radialaxis=dict(showticklabels=False, showline=False, showgrid=False),
|
| 108 |
+
angularaxis=dict(
|
| 109 |
+
showticklabels=False,
|
| 110 |
+
showline=False,
|
| 111 |
+
showgrid=False,
|
| 112 |
+
direction="clockwise",
|
| 113 |
+
),
|
| 114 |
+
),
|
| 115 |
+
height=500,
|
| 116 |
+
margin=dict(l=20, r=20, t=20, b=20),
|
| 117 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 118 |
+
showlegend=False,
|
| 119 |
+
)
|
| 120 |
+
return fig
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# Explanation header HTML
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
_EXPLANATION_HTML = (
|
| 128 |
+
'<div style="padding:12px 20px;color:#424242;font-size:14px;line-height:1.6;">'
|
| 129 |
+
"Where in the genome does genetic diversity concentrate? "
|
| 130 |
+
"The outer ring shows variability — "
|
| 131 |
+
'<span style="color:#C62828;font-weight:600;">red</span> regions are hotspots '
|
| 132 |
+
"with rare or variable genes, "
|
| 133 |
+
'<span style="color:#F9A825;font-weight:600;">amber</span> marks moderate variation, '
|
| 134 |
+
"and "
|
| 135 |
+
'<span style="color:#2E7D32;font-weight:600;">green</span> regions are conserved.'
|
| 136 |
+
"</div>"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
# Tab builder
|
| 142 |
+
# ---------------------------------------------------------------------------
|
| 143 |
|
| 144 |
def build_quest3(contig_choices: list[str]):
|
| 145 |
+
"""Build Quest 3 tab components.
|
| 146 |
+
|
| 147 |
+
Returns a dict whose keys match the original wiring contract:
|
| 148 |
+
tab, contig_dropdown, heatmap_plot, track_plot, region_table,
|
| 149 |
+
region_gene_text
|
| 150 |
+
"""
|
| 151 |
+
with gr.Tab("Genome Explorer", id="quest3") as tab:
|
| 152 |
+
# --- A) Explanation header ---
|
| 153 |
+
gr.HTML(value=_EXPLANATION_HTML)
|
| 154 |
+
|
| 155 |
+
# --- B) Circos-style polar ring chart (replaces old heatmap hero) ---
|
| 156 |
+
heatmap_plot = gr.Plot(
|
| 157 |
+
label="Circular Genome Overview",
|
| 158 |
+
value=build_circos_figure(),
|
| 159 |
)
|
| 160 |
|
| 161 |
+
# --- C) Contig drill-down ---
|
| 162 |
+
gr.Markdown("### Contig Detail")
|
| 163 |
contig_dropdown = gr.Dropdown(
|
| 164 |
choices=contig_choices,
|
| 165 |
label="Select contig (top contigs by gene count)",
|
| 166 |
interactive=True,
|
| 167 |
)
|
| 168 |
|
|
|
|
|
|
|
|
|
|
| 169 |
track_plot = gr.Plot(label="Gene track (colored by class)", visible=False)
|
| 170 |
|
| 171 |
region_table = gr.Dataframe(
|
ui/quest4.py
CHANGED
|
@@ -1,34 +1,218 @@
|
|
| 1 |
-
"""Quest 4: Protein
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def build_quest4(gene_choices: list[str]):
|
| 7 |
-
"""Build Quest 4 tab components. Returns dict of components.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"Explore the protein products of pangenome genes. Compare amino acid "
|
| 12 |
-
"compositions
|
|
|
|
| 13 |
)
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
-
gr.
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
comparison_bar_plot = gr.Plot(label="Protein lengths comparison")
|
| 31 |
-
composition_heatmap = gr.Plot(label="Amino acid composition heatmap")
|
| 32 |
|
| 33 |
return {
|
| 34 |
"tab": tab,
|
|
|
|
| 1 |
+
"""Quest 4 / Chapter 5: Protein World — protein analysis with radar charts."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
|
| 9 |
+
from src.plot_config import COLORS, apply_template
|
| 10 |
+
from src.utils import PRECOMPUTED_DIR
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# =====================================================================
|
| 14 |
+
# Module-level helpers (callable from callbacks)
|
| 15 |
+
# =====================================================================
|
| 16 |
+
|
| 17 |
+
def _load_radar_axes() -> dict:
|
| 18 |
+
"""Load radar axes configuration from precomputed JSON."""
|
| 19 |
+
path = PRECOMPUTED_DIR / "radar_axes.json"
|
| 20 |
+
if path.exists():
|
| 21 |
+
with open(path) as f:
|
| 22 |
+
return json.load(f)
|
| 23 |
+
# Fallback: top 10 common amino acids with reasonable defaults
|
| 24 |
+
return {
|
| 25 |
+
"axes": ["L", "S", "E", "A", "K", "V", "G", "R", "P", "I"],
|
| 26 |
+
"global_mean": {
|
| 27 |
+
"L": 8.6, "S": 6.7, "E": 3.9, "A": 3.8, "K": 3.5,
|
| 28 |
+
"V": 3.4, "G": 3.2, "R": 2.9, "P": 1.9, "I": 1.8,
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# Cache at module level so it is loaded once
|
| 34 |
+
_RADAR_AXES = _load_radar_axes()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def parse_composition(comp_summary: str) -> dict[str, float]:
|
| 38 |
+
"""Parse composition_summary string like 'A:15.1%, S:9.2%, ...' into dict."""
|
| 39 |
+
result: dict[str, float] = {}
|
| 40 |
+
if not comp_summary or comp_summary == "N/A":
|
| 41 |
+
return result
|
| 42 |
+
for part in comp_summary.split(","):
|
| 43 |
+
part = part.strip()
|
| 44 |
+
if ":" in part:
|
| 45 |
+
aa, pct = part.split(":", 1)
|
| 46 |
+
try:
|
| 47 |
+
result[aa.strip()] = float(pct.strip().rstrip("%"))
|
| 48 |
+
except ValueError:
|
| 49 |
+
pass
|
| 50 |
+
return result
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def hex_to_rgb(hex_color: str) -> str:
|
| 54 |
+
"""Convert hex color '#RRGGBB' to 'r,g,b' string for rgba()."""
|
| 55 |
+
h = hex_color.lstrip("#")
|
| 56 |
+
return ",".join(str(int(h[i : i + 2], 16)) for i in (0, 2, 4))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def build_radar_figure(gene_ids: list[str], protein_df, radar_axes_data: dict | None = None) -> go.Figure:
|
| 60 |
+
"""Build radar/spider chart comparing amino acid composition of proteins.
|
| 61 |
+
|
| 62 |
+
Parameters
|
| 63 |
+
----------
|
| 64 |
+
gene_ids : list[str]
|
| 65 |
+
Gene IDs to plot (max 5).
|
| 66 |
+
protein_df : pd.DataFrame
|
| 67 |
+
The protein index dataframe with columns gene_id, protein_length, composition_summary.
|
| 68 |
+
radar_axes_data : dict, optional
|
| 69 |
+
Dict with 'axes' and 'global_mean' keys. Falls back to module cache.
|
| 70 |
+
"""
|
| 71 |
+
if radar_axes_data is None:
|
| 72 |
+
radar_axes_data = _RADAR_AXES
|
| 73 |
|
| 74 |
+
axes = radar_axes_data["axes"]
|
| 75 |
+
global_mean = radar_axes_data["global_mean"]
|
| 76 |
+
|
| 77 |
+
fig = go.Figure()
|
| 78 |
+
|
| 79 |
+
# Add global mean as a dashed reference polygon
|
| 80 |
+
mean_values = [global_mean.get(aa, 0) for aa in axes]
|
| 81 |
+
fig.add_trace(go.Scatterpolar(
|
| 82 |
+
r=mean_values + [mean_values[0]],
|
| 83 |
+
theta=axes + [axes[0]],
|
| 84 |
+
fill="none",
|
| 85 |
+
line=dict(color="#757575", dash="dash", width=1),
|
| 86 |
+
name="Global Average",
|
| 87 |
+
opacity=0.6,
|
| 88 |
+
))
|
| 89 |
+
|
| 90 |
+
# Palette for up to 5 genes
|
| 91 |
+
palette = [COLORS["core"], COLORS["accent"], COLORS["cloud"], COLORS["selected"], "#6A1B9A"]
|
| 92 |
+
|
| 93 |
+
for i, gene_id in enumerate(gene_ids[:5]):
|
| 94 |
+
row = protein_df[protein_df["gene_id"] == gene_id]
|
| 95 |
+
if row.empty:
|
| 96 |
+
continue
|
| 97 |
+
comp = parse_composition(row.iloc[0]["composition_summary"])
|
| 98 |
+
values = [comp.get(aa, 0) for aa in axes]
|
| 99 |
+
|
| 100 |
+
fig.add_trace(go.Scatterpolar(
|
| 101 |
+
r=values + [values[0]],
|
| 102 |
+
theta=axes + [axes[0]],
|
| 103 |
+
fill="toself",
|
| 104 |
+
fillcolor=f"rgba({hex_to_rgb(palette[i % len(palette)])},0.15)",
|
| 105 |
+
line=dict(color=palette[i % len(palette)], width=2),
|
| 106 |
+
name=gene_id,
|
| 107 |
+
))
|
| 108 |
+
|
| 109 |
+
fig.update_layout(
|
| 110 |
+
polar=dict(
|
| 111 |
+
bgcolor="rgba(0,0,0,0)",
|
| 112 |
+
radialaxis=dict(
|
| 113 |
+
showticklabels=True,
|
| 114 |
+
tickfont=dict(size=10, color="#757575"),
|
| 115 |
+
gridcolor="#E0E0E0",
|
| 116 |
+
showline=False,
|
| 117 |
+
),
|
| 118 |
+
angularaxis=dict(
|
| 119 |
+
tickfont=dict(size=12, color=COLORS["text_primary"]),
|
| 120 |
+
gridcolor="#E0E0E0",
|
| 121 |
+
),
|
| 122 |
+
),
|
| 123 |
+
height=450,
|
| 124 |
+
margin=dict(l=60, r=60, t=40, b=40),
|
| 125 |
+
paper_bgcolor="rgba(0,0,0,0)",
|
| 126 |
+
legend=dict(bgcolor="rgba(255,255,255,0.85)"),
|
| 127 |
+
)
|
| 128 |
+
return fig
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def build_protein_length_bar(gene_id: str, protein_df) -> str:
|
| 132 |
+
"""Build HTML for protein length percentile visualization.
|
| 133 |
+
|
| 134 |
+
Returns an inline-styled metric card with a position indicator on a
|
| 135 |
+
min-max bar showing where the selected protein falls.
|
| 136 |
+
"""
|
| 137 |
+
row = protein_df[protein_df["gene_id"] == gene_id]
|
| 138 |
+
if row.empty:
|
| 139 |
+
return '<div style="color:#757575;padding:16px;">No protein data available</div>'
|
| 140 |
+
|
| 141 |
+
length = int(row.iloc[0]["protein_length"])
|
| 142 |
+
min_len = int(protein_df["protein_length"].min())
|
| 143 |
+
max_len = int(protein_df["protein_length"].max())
|
| 144 |
+
pct = (length - min_len) / (max_len - min_len) * 100 if max_len > min_len else 50
|
| 145 |
+
|
| 146 |
+
return f'''
|
| 147 |
+
<div class="metric-card">
|
| 148 |
+
<div class="metric-value">{length:,} aa</div>
|
| 149 |
+
<div class="metric-label">Protein Length</div>
|
| 150 |
+
<div style="margin-top:12px;position:relative;height:8px;background:#E0E0E0;border-radius:4px;">
|
| 151 |
+
<div style="position:absolute;left:{pct:.1f}%;top:-4px;width:16px;height:16px;
|
| 152 |
+
background:{COLORS['core']};border-radius:50%;border:2px solid white;
|
| 153 |
+
box-shadow:0 1px 3px rgba(0,0,0,0.3);transform:translateX(-50%);"></div>
|
| 154 |
+
</div>
|
| 155 |
+
<div style="display:flex;justify-content:space-between;margin-top:4px;font-size:11px;color:#757575;">
|
| 156 |
+
<span>{min_len:,} aa</span><span>{max_len:,} aa</span>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
'''
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# =====================================================================
|
| 163 |
+
# Gradio UI builder
|
| 164 |
+
# =====================================================================
|
| 165 |
|
| 166 |
def build_quest4(gene_choices: list[str]):
|
| 167 |
+
"""Build Quest 4 tab components. Returns dict of components.
|
| 168 |
+
|
| 169 |
+
Returned keys (prefixed with ``q4_`` by layout.py):
|
| 170 |
+
tab, gene_dropdown, protein_stats_html,
|
| 171 |
+
comparison_bar_plot, composition_heatmap
|
| 172 |
+
"""
|
| 173 |
+
with gr.Tab("Protein World", id="quest4") as tab:
|
| 174 |
+
# -- Header --
|
| 175 |
+
gr.HTML(
|
| 176 |
+
'<div style="padding:4px 0 12px 0;">'
|
| 177 |
+
'<h2 style="margin:0 0 4px 0;font-size:22px;font-weight:700;'
|
| 178 |
+
f'color:{COLORS["text_primary"]};">Chapter 5: Protein World</h2>'
|
| 179 |
+
'<p style="margin:0;font-size:14px;color:#757575;line-height:1.5;">'
|
| 180 |
"Explore the protein products of pangenome genes. Compare amino acid "
|
| 181 |
+
"compositions with radar charts and examine length distributions."
|
| 182 |
+
"</p></div>"
|
| 183 |
)
|
| 184 |
|
| 185 |
+
# -- Single-gene selector --
|
| 186 |
+
with gr.Row():
|
| 187 |
+
with gr.Column(scale=2):
|
| 188 |
+
gene_dropdown = gr.Dropdown(
|
| 189 |
+
choices=gene_choices,
|
| 190 |
+
label="Select a gene (or pick from backpack)",
|
| 191 |
+
interactive=True,
|
| 192 |
+
allow_custom_value=True,
|
| 193 |
+
)
|
| 194 |
+
with gr.Column(scale=3):
|
| 195 |
+
protein_stats_html = gr.HTML(
|
| 196 |
+
value=(
|
| 197 |
+
'<div style="padding:16px;color:#757575;text-align:center;">'
|
| 198 |
+
"Select a gene to see protein statistics</div>"
|
| 199 |
+
),
|
| 200 |
+
label="Protein Statistics",
|
| 201 |
+
)
|
| 202 |
|
| 203 |
+
# -- Backpack comparison section --
|
| 204 |
+
gr.HTML(
|
| 205 |
+
'<div style="margin-top:16px;padding:4px 0;">'
|
| 206 |
+
'<h3 style="margin:0 0 4px 0;font-size:17px;font-weight:600;'
|
| 207 |
+
f'color:{COLORS["text_primary"]};">Backpack Comparison</h3>'
|
| 208 |
+
'<p style="margin:0;font-size:13px;color:#757575;">'
|
| 209 |
+
"Pin at least 2 genes to your backpack to see radar and heatmap comparisons."
|
| 210 |
+
"</p></div>"
|
| 211 |
)
|
| 212 |
|
| 213 |
+
with gr.Row():
|
| 214 |
+
comparison_bar_plot = gr.Plot(label="Amino Acid Radar")
|
| 215 |
+
composition_heatmap = gr.Plot(label="Composition Heatmap")
|
|
|
|
|
|
|
| 216 |
|
| 217 |
return {
|
| 218 |
"tab": tab,
|
ui/theme.py
CHANGED
|
@@ -1,8 +1,440 @@
|
|
| 1 |
-
"""Custom Gradio theme for the Pigeon Pea Pangenome Atlas.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
CUSTOM_CSS = """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
.quest-badge {
|
| 7 |
display: inline-block;
|
| 8 |
padding: 4px 12px;
|
|
@@ -12,14 +444,15 @@ CUSTOM_CSS = """
|
|
| 12 |
margin: 2px 4px;
|
| 13 |
}
|
| 14 |
.badge-core { background: #2E7D32; color: white; }
|
| 15 |
-
.badge-shell { background: #
|
| 16 |
-
.badge-cloud { background: #
|
| 17 |
|
| 18 |
.gene-card {
|
| 19 |
border: 2px solid #2E7D32;
|
| 20 |
-
border-radius:
|
| 21 |
padding: 16px;
|
| 22 |
-
background: #
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
.presence-barcode span {
|
|
@@ -28,7 +461,7 @@ CUSTOM_CSS = """
|
|
| 28 |
height: 20px;
|
| 29 |
margin: 0;
|
| 30 |
}
|
| 31 |
-
.presence-barcode .present { background: #
|
| 32 |
.presence-barcode .absent { background: #E0E0E0; }
|
| 33 |
|
| 34 |
.progress-tracker {
|
|
@@ -55,10 +488,11 @@ CUSTOM_CSS = """
|
|
| 55 |
|
| 56 |
.stat-card {
|
| 57 |
text-align: center;
|
| 58 |
-
padding:
|
| 59 |
-
border-radius:
|
| 60 |
-
background: #
|
| 61 |
-
border:
|
|
|
|
| 62 |
}
|
| 63 |
.stat-card .stat-value {
|
| 64 |
font-size: 1.8em;
|
|
@@ -67,35 +501,17 @@ CUSTOM_CSS = """
|
|
| 67 |
}
|
| 68 |
.stat-card .stat-label {
|
| 69 |
font-size: 0.85em;
|
| 70 |
-
color: #
|
| 71 |
}
|
| 72 |
|
| 73 |
.achievement-badge {
|
| 74 |
display: inline-block;
|
| 75 |
padding: 6px 14px;
|
| 76 |
border-radius: 20px;
|
| 77 |
-
background: linear-gradient(135deg, #
|
| 78 |
color: #333;
|
| 79 |
font-weight: 600;
|
| 80 |
margin: 4px;
|
| 81 |
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 82 |
}
|
| 83 |
"""
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def build_theme():
|
| 87 |
-
"""Build custom Gradio theme."""
|
| 88 |
-
theme = gr.themes.Soft(
|
| 89 |
-
primary_hue=gr.themes.colors.green,
|
| 90 |
-
secondary_hue=gr.themes.colors.amber,
|
| 91 |
-
neutral_hue=gr.themes.colors.gray,
|
| 92 |
-
font=gr.themes.GoogleFont("Inter"),
|
| 93 |
-
).set(
|
| 94 |
-
body_background_fill="#FAFAF5",
|
| 95 |
-
block_border_width="1px",
|
| 96 |
-
block_border_color="#C8E6C9",
|
| 97 |
-
block_radius="8px",
|
| 98 |
-
button_primary_background_fill="#2E7D32",
|
| 99 |
-
button_primary_text_color="white",
|
| 100 |
-
)
|
| 101 |
-
return theme
|
|
|
|
| 1 |
+
"""Custom Gradio theme and CSS for the Pigeon Pea Pangenome Atlas.
|
| 2 |
+
|
| 3 |
+
Provides a premium botanical/academic visual identity with forest-green
|
| 4 |
+
primary color, gold accents, and clean card-based layouts.
|
| 5 |
+
|
| 6 |
+
Exports
|
| 7 |
+
-------
|
| 8 |
+
get_theme() – returns the configured ``gr.themes.Base`` instance.
|
| 9 |
+
build_theme() – alias kept for backward compatibility.
|
| 10 |
+
CUSTOM_CSS – CSS string injected into ``gr.Blocks(css=...)``.
|
| 11 |
+
"""
|
| 12 |
|
| 13 |
import gradio as gr
|
| 14 |
|
| 15 |
+
# =====================================================================
|
| 16 |
+
# Gradio theme
|
| 17 |
+
# =====================================================================
|
| 18 |
+
|
| 19 |
+
def get_theme() -> gr.themes.Base:
|
| 20 |
+
"""Return a Gradio theme object with the Atlas visual identity.
|
| 21 |
+
|
| 22 |
+
* Primary hue : forest green (#2E7D32)
|
| 23 |
+
* Secondary hue: gold (#D4A017)
|
| 24 |
+
* Neutral hue : warm gray
|
| 25 |
+
* Border radius: 12px containers, 8px buttons
|
| 26 |
+
* Font : system sans-serif stack
|
| 27 |
+
"""
|
| 28 |
+
theme = gr.themes.Base(
|
| 29 |
+
primary_hue=gr.themes.colors.green,
|
| 30 |
+
secondary_hue=gr.themes.colors.amber,
|
| 31 |
+
neutral_hue=gr.themes.Color(
|
| 32 |
+
name="warmgray",
|
| 33 |
+
c50="#FAFAF5",
|
| 34 |
+
c100="#F5F5F0",
|
| 35 |
+
c200="#E8E8E0",
|
| 36 |
+
c300="#D4D4CC",
|
| 37 |
+
c400="#A8A8A0",
|
| 38 |
+
c500="#787870",
|
| 39 |
+
c600="#5C5C55",
|
| 40 |
+
c700="#45453F",
|
| 41 |
+
c800="#2E2E28",
|
| 42 |
+
c900="#1A1A15",
|
| 43 |
+
c950="#0D0D0A",
|
| 44 |
+
),
|
| 45 |
+
font=[
|
| 46 |
+
"-apple-system",
|
| 47 |
+
"BlinkMacSystemFont",
|
| 48 |
+
"Segoe UI",
|
| 49 |
+
"Roboto",
|
| 50 |
+
"Helvetica Neue",
|
| 51 |
+
"Arial",
|
| 52 |
+
"sans-serif",
|
| 53 |
+
],
|
| 54 |
+
font_mono=[
|
| 55 |
+
"SF Mono",
|
| 56 |
+
"SFMono-Regular",
|
| 57 |
+
"ui-monospace",
|
| 58 |
+
"Menlo",
|
| 59 |
+
"monospace",
|
| 60 |
+
],
|
| 61 |
+
).set(
|
| 62 |
+
# Page
|
| 63 |
+
body_background_fill="#FAFAF5",
|
| 64 |
+
body_text_color="#1A1A1A",
|
| 65 |
+
|
| 66 |
+
# Containers / blocks
|
| 67 |
+
block_background_fill="#FFFFFF",
|
| 68 |
+
block_border_width="0px",
|
| 69 |
+
block_border_color="transparent",
|
| 70 |
+
block_radius="12px",
|
| 71 |
+
block_shadow="0 2px 8px rgba(0,0,0,0.06)",
|
| 72 |
+
|
| 73 |
+
# Buttons
|
| 74 |
+
button_primary_background_fill="linear-gradient(135deg, #2E7D32, #43A047)",
|
| 75 |
+
button_primary_background_fill_hover="linear-gradient(135deg, #256b29, #388E3C)",
|
| 76 |
+
button_primary_text_color="white",
|
| 77 |
+
button_primary_border_color="transparent",
|
| 78 |
+
button_secondary_background_fill="transparent",
|
| 79 |
+
button_secondary_border_color="#2E7D32",
|
| 80 |
+
button_secondary_text_color="#2E7D32",
|
| 81 |
+
button_large_radius="8px",
|
| 82 |
+
button_small_radius="8px",
|
| 83 |
+
|
| 84 |
+
# Inputs
|
| 85 |
+
input_radius="8px",
|
| 86 |
+
input_border_color="#E0E0E0",
|
| 87 |
+
input_background_fill="#FFFFFF",
|
| 88 |
+
|
| 89 |
+
# Spacing
|
| 90 |
+
layout_gap="16px",
|
| 91 |
+
|
| 92 |
+
# Shadows
|
| 93 |
+
shadow_spread="0px",
|
| 94 |
+
)
|
| 95 |
+
return theme
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# Backward-compatible alias so existing ``from ui.theme import build_theme``
|
| 99 |
+
# continues to work without changes.
|
| 100 |
+
build_theme = get_theme
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# =====================================================================
|
| 104 |
+
# Custom CSS
|
| 105 |
+
# =====================================================================
|
| 106 |
+
|
| 107 |
CUSTOM_CSS = """
|
| 108 |
+
/* =================================================================
|
| 109 |
+
Pigeon Pea Pangenome Atlas — Custom CSS
|
| 110 |
+
=================================================================
|
| 111 |
+
Class reference (keep in sync with Python HTML builders):
|
| 112 |
+
Page : body overrides
|
| 113 |
+
Cards : .metric-card, .gene-card-panel
|
| 114 |
+
Hero : .hero-header, .hero-stat, .hero-subtitle
|
| 115 |
+
Badges : .gene-badge-core, .gene-badge-shell, .gene-badge-cloud
|
| 116 |
+
Backpack : .backpack-chip, .backpack-chip-core/shell/cloud
|
| 117 |
+
Stepper : .progress-stepper, .step-complete, .step-current, .step-future
|
| 118 |
+
Buttons : .btn-primary, .btn-secondary
|
| 119 |
+
Legacy : .quest-badge, .badge-core/shell/cloud, .gene-card,
|
| 120 |
+
.presence-barcode, .progress-tracker, .progress-step,
|
| 121 |
+
.stat-card, .achievement-badge
|
| 122 |
+
================================================================= */
|
| 123 |
+
|
| 124 |
+
/* ---- Page-level overrides ---- */
|
| 125 |
+
body,
|
| 126 |
+
.gradio-container {
|
| 127 |
+
background: #FAFAF5 !important;
|
| 128 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
| 129 |
+
'Helvetica Neue', Arial, sans-serif;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* Remove default Gradio container borders */
|
| 133 |
+
.gradio-container .gr-box,
|
| 134 |
+
.gradio-container .gr-panel {
|
| 135 |
+
border: none !important;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* ---- Card styling ---- */
|
| 139 |
+
.gr-block,
|
| 140 |
+
.gr-panel,
|
| 141 |
+
.gr-group {
|
| 142 |
+
background: #FFFFFF;
|
| 143 |
+
border-radius: 16px !important;
|
| 144 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/* ---- Tabs — underline style ---- */
|
| 148 |
+
.tabs > .tab-nav {
|
| 149 |
+
border-bottom: 2px solid #E0E0E0 !important;
|
| 150 |
+
background: transparent !important;
|
| 151 |
+
}
|
| 152 |
+
.tabs > .tab-nav > button {
|
| 153 |
+
border: none !important;
|
| 154 |
+
border-radius: 0 !important;
|
| 155 |
+
background: transparent !important;
|
| 156 |
+
padding: 10px 20px !important;
|
| 157 |
+
font-size: 14px;
|
| 158 |
+
font-weight: 500;
|
| 159 |
+
color: #757575;
|
| 160 |
+
transition: color 0.2s, border-color 0.2s;
|
| 161 |
+
position: relative;
|
| 162 |
+
}
|
| 163 |
+
.tabs > .tab-nav > button.selected {
|
| 164 |
+
color: #2E7D32 !important;
|
| 165 |
+
font-weight: 600;
|
| 166 |
+
border-bottom: 3px solid #2E7D32 !important;
|
| 167 |
+
}
|
| 168 |
+
.tabs > .tab-nav > button:hover {
|
| 169 |
+
color: #2E7D32;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/* ---- Hero header ---- */
|
| 173 |
+
.hero-header {
|
| 174 |
+
background: #1a2332;
|
| 175 |
+
color: #FFFFFF;
|
| 176 |
+
padding: 40px 48px;
|
| 177 |
+
border-radius: 16px;
|
| 178 |
+
margin-bottom: 24px;
|
| 179 |
+
}
|
| 180 |
+
.hero-header h1 {
|
| 181 |
+
margin: 0 0 8px 0;
|
| 182 |
+
font-size: 28px;
|
| 183 |
+
font-weight: 700;
|
| 184 |
+
letter-spacing: -0.5px;
|
| 185 |
+
}
|
| 186 |
+
.hero-subtitle {
|
| 187 |
+
font-size: 16px;
|
| 188 |
+
color: #94a3b8;
|
| 189 |
+
margin-bottom: 28px;
|
| 190 |
+
line-height: 1.5;
|
| 191 |
+
}
|
| 192 |
+
.hero-stat {
|
| 193 |
+
display: inline-block;
|
| 194 |
+
text-align: center;
|
| 195 |
+
margin-right: 48px;
|
| 196 |
+
vertical-align: top;
|
| 197 |
+
}
|
| 198 |
+
.hero-stat .stat-number {
|
| 199 |
+
display: block;
|
| 200 |
+
font-size: 48px;
|
| 201 |
+
font-weight: 700;
|
| 202 |
+
color: #FFFFFF;
|
| 203 |
+
line-height: 1.1;
|
| 204 |
+
}
|
| 205 |
+
.hero-stat .stat-label {
|
| 206 |
+
display: block;
|
| 207 |
+
font-size: 12px;
|
| 208 |
+
font-weight: 400;
|
| 209 |
+
color: #94a3b8;
|
| 210 |
+
text-transform: uppercase;
|
| 211 |
+
letter-spacing: 1px;
|
| 212 |
+
margin-top: 6px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* ---- Metric cards ---- */
|
| 216 |
+
.metric-card {
|
| 217 |
+
background: #FFFFFF;
|
| 218 |
+
border-radius: 16px;
|
| 219 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 220 |
+
padding: 24px;
|
| 221 |
+
border-top: 3px solid #2E7D32;
|
| 222 |
+
text-align: center;
|
| 223 |
+
transition: box-shadow 0.2s;
|
| 224 |
+
}
|
| 225 |
+
.metric-card:hover {
|
| 226 |
+
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
|
| 227 |
+
}
|
| 228 |
+
.metric-card.amber {
|
| 229 |
+
border-top-color: #F9A825;
|
| 230 |
+
}
|
| 231 |
+
.metric-card.red {
|
| 232 |
+
border-top-color: #C62828;
|
| 233 |
+
}
|
| 234 |
+
.metric-card.blue {
|
| 235 |
+
border-top-color: #1565C0;
|
| 236 |
+
}
|
| 237 |
+
.metric-value {
|
| 238 |
+
font-size: 36px;
|
| 239 |
+
font-weight: 700;
|
| 240 |
+
color: #1A1A1A;
|
| 241 |
+
line-height: 1.1;
|
| 242 |
+
margin-bottom: 4px;
|
| 243 |
+
}
|
| 244 |
+
.metric-label {
|
| 245 |
+
font-size: 11px;
|
| 246 |
+
font-weight: 600;
|
| 247 |
+
text-transform: uppercase;
|
| 248 |
+
letter-spacing: 1.2px;
|
| 249 |
+
color: #757575;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/* ---- Gene card side panel ---- */
|
| 253 |
+
.gene-card-panel {
|
| 254 |
+
background: #FFFFFF;
|
| 255 |
+
border-radius: 16px;
|
| 256 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 257 |
+
padding: 20px;
|
| 258 |
+
border-left: 3px solid #2E7D32;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/* Gene classification badges */
|
| 262 |
+
.gene-badge-core {
|
| 263 |
+
display: inline-block;
|
| 264 |
+
padding: 4px 14px;
|
| 265 |
+
border-radius: 20px;
|
| 266 |
+
font-size: 12px;
|
| 267 |
+
font-weight: 600;
|
| 268 |
+
background: #2E7D32;
|
| 269 |
+
color: #FFFFFF;
|
| 270 |
+
}
|
| 271 |
+
.gene-badge-shell {
|
| 272 |
+
display: inline-block;
|
| 273 |
+
padding: 4px 14px;
|
| 274 |
+
border-radius: 20px;
|
| 275 |
+
font-size: 12px;
|
| 276 |
+
font-weight: 600;
|
| 277 |
+
background: #F9A825;
|
| 278 |
+
color: #333333;
|
| 279 |
+
}
|
| 280 |
+
.gene-badge-cloud {
|
| 281 |
+
display: inline-block;
|
| 282 |
+
padding: 4px 14px;
|
| 283 |
+
border-radius: 20px;
|
| 284 |
+
font-size: 12px;
|
| 285 |
+
font-weight: 600;
|
| 286 |
+
background: #C62828;
|
| 287 |
+
color: #FFFFFF;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/* ---- Backpack chips ---- */
|
| 291 |
+
.backpack-chip {
|
| 292 |
+
display: inline-block;
|
| 293 |
+
padding: 4px 12px;
|
| 294 |
+
border-radius: 16px;
|
| 295 |
+
font-size: 12px;
|
| 296 |
+
font-weight: 600;
|
| 297 |
+
margin: 2px 4px;
|
| 298 |
+
vertical-align: middle;
|
| 299 |
+
}
|
| 300 |
+
.backpack-chip-core {
|
| 301 |
+
background: #E8F5E9;
|
| 302 |
+
color: #2E7D32;
|
| 303 |
+
border: 1px solid #C8E6C9;
|
| 304 |
+
}
|
| 305 |
+
.backpack-chip-shell {
|
| 306 |
+
background: #FFF8E1;
|
| 307 |
+
color: #F9A825;
|
| 308 |
+
border: 1px solid #FFECB3;
|
| 309 |
+
}
|
| 310 |
+
.backpack-chip-cloud {
|
| 311 |
+
background: #FFEBEE;
|
| 312 |
+
color: #C62828;
|
| 313 |
+
border: 1px solid #FFCDD2;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
/* ---- Progress stepper ---- */
|
| 317 |
+
.progress-stepper {
|
| 318 |
+
display: flex;
|
| 319 |
+
flex-direction: row;
|
| 320 |
+
align-items: center;
|
| 321 |
+
justify-content: center;
|
| 322 |
+
gap: 0;
|
| 323 |
+
padding: 16px 0;
|
| 324 |
+
}
|
| 325 |
+
.progress-stepper .step {
|
| 326 |
+
display: flex;
|
| 327 |
+
align-items: center;
|
| 328 |
+
gap: 8px;
|
| 329 |
+
font-size: 13px;
|
| 330 |
+
color: #757575;
|
| 331 |
+
padding: 0 16px;
|
| 332 |
+
position: relative;
|
| 333 |
+
}
|
| 334 |
+
.progress-stepper .step::after {
|
| 335 |
+
content: "";
|
| 336 |
+
position: absolute;
|
| 337 |
+
right: -2px;
|
| 338 |
+
width: 24px;
|
| 339 |
+
height: 2px;
|
| 340 |
+
background: #E0E0E0;
|
| 341 |
+
}
|
| 342 |
+
.progress-stepper .step:last-child::after {
|
| 343 |
+
display: none;
|
| 344 |
+
}
|
| 345 |
+
.progress-stepper .step .dot {
|
| 346 |
+
width: 24px;
|
| 347 |
+
height: 24px;
|
| 348 |
+
border-radius: 50%;
|
| 349 |
+
display: inline-flex;
|
| 350 |
+
align-items: center;
|
| 351 |
+
justify-content: center;
|
| 352 |
+
font-size: 12px;
|
| 353 |
+
font-weight: 700;
|
| 354 |
+
flex-shrink: 0;
|
| 355 |
+
}
|
| 356 |
+
/* Completed step: filled green circle with checkmark */
|
| 357 |
+
.step-complete .dot {
|
| 358 |
+
background: #2E7D32;
|
| 359 |
+
color: #FFFFFF;
|
| 360 |
+
}
|
| 361 |
+
.step-complete {
|
| 362 |
+
color: #2E7D32;
|
| 363 |
+
font-weight: 500;
|
| 364 |
+
}
|
| 365 |
+
/* Current step: green ring, bold label */
|
| 366 |
+
.step-current .dot {
|
| 367 |
+
background: transparent;
|
| 368 |
+
border: 2.5px solid #2E7D32;
|
| 369 |
+
color: #2E7D32;
|
| 370 |
+
}
|
| 371 |
+
.step-current {
|
| 372 |
+
color: #2E7D32;
|
| 373 |
+
font-weight: 700;
|
| 374 |
+
}
|
| 375 |
+
/* Future step: gray dimmed circle */
|
| 376 |
+
.step-future .dot {
|
| 377 |
+
background: #E0E0E0;
|
| 378 |
+
color: #9E9E9E;
|
| 379 |
+
}
|
| 380 |
+
.step-future {
|
| 381 |
+
color: #9E9E9E;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
/* ---- Buttons ---- */
|
| 385 |
+
.btn-primary {
|
| 386 |
+
display: inline-block;
|
| 387 |
+
padding: 10px 24px;
|
| 388 |
+
background: linear-gradient(135deg, #2E7D32, #43A047);
|
| 389 |
+
color: #FFFFFF !important;
|
| 390 |
+
border: none;
|
| 391 |
+
border-radius: 8px;
|
| 392 |
+
font-size: 14px;
|
| 393 |
+
font-weight: 600;
|
| 394 |
+
cursor: pointer;
|
| 395 |
+
transition: opacity 0.2s, box-shadow 0.2s;
|
| 396 |
+
text-decoration: none;
|
| 397 |
+
}
|
| 398 |
+
.btn-primary:hover {
|
| 399 |
+
opacity: 0.92;
|
| 400 |
+
box-shadow: 0 4px 12px rgba(46,125,50,0.25);
|
| 401 |
+
}
|
| 402 |
+
.btn-secondary {
|
| 403 |
+
display: inline-block;
|
| 404 |
+
padding: 10px 24px;
|
| 405 |
+
background: transparent;
|
| 406 |
+
color: #2E7D32 !important;
|
| 407 |
+
border: 2px solid #2E7D32;
|
| 408 |
+
border-radius: 8px;
|
| 409 |
+
font-size: 14px;
|
| 410 |
+
font-weight: 600;
|
| 411 |
+
cursor: pointer;
|
| 412 |
+
transition: background 0.2s;
|
| 413 |
+
text-decoration: none;
|
| 414 |
+
}
|
| 415 |
+
.btn-secondary:hover {
|
| 416 |
+
background: #E8F5E9;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
/* ---- Hide Gradio footer / reduce noise ---- */
|
| 420 |
+
footer {
|
| 421 |
+
display: none !important;
|
| 422 |
+
}
|
| 423 |
+
.gradio-container .gr-padded {
|
| 424 |
+
padding: 12px !important;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
/* Clean accordion styling */
|
| 428 |
+
.gr-accordion {
|
| 429 |
+
border: 1px solid #E0E0E0 !important;
|
| 430 |
+
border-radius: 12px !important;
|
| 431 |
+
box-shadow: none !important;
|
| 432 |
+
}
|
| 433 |
+
.gr-accordion > .label-wrap {
|
| 434 |
+
padding: 12px 16px !important;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/* ---- Legacy classes (backward compat) ---- */
|
| 438 |
.quest-badge {
|
| 439 |
display: inline-block;
|
| 440 |
padding: 4px 12px;
|
|
|
|
| 444 |
margin: 2px 4px;
|
| 445 |
}
|
| 446 |
.badge-core { background: #2E7D32; color: white; }
|
| 447 |
+
.badge-shell { background: #F9A825; color: #333; }
|
| 448 |
+
.badge-cloud { background: #C62828; color: white; }
|
| 449 |
|
| 450 |
.gene-card {
|
| 451 |
border: 2px solid #2E7D32;
|
| 452 |
+
border-radius: 12px;
|
| 453 |
padding: 16px;
|
| 454 |
+
background: #FFFFFF;
|
| 455 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 456 |
}
|
| 457 |
|
| 458 |
.presence-barcode span {
|
|
|
|
| 461 |
height: 20px;
|
| 462 |
margin: 0;
|
| 463 |
}
|
| 464 |
+
.presence-barcode .present { background: #2E7D32; }
|
| 465 |
.presence-barcode .absent { background: #E0E0E0; }
|
| 466 |
|
| 467 |
.progress-tracker {
|
|
|
|
| 488 |
|
| 489 |
.stat-card {
|
| 490 |
text-align: center;
|
| 491 |
+
padding: 20px;
|
| 492 |
+
border-radius: 12px;
|
| 493 |
+
background: #FFFFFF;
|
| 494 |
+
border-top: 3px solid #2E7D32;
|
| 495 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
| 496 |
}
|
| 497 |
.stat-card .stat-value {
|
| 498 |
font-size: 1.8em;
|
|
|
|
| 501 |
}
|
| 502 |
.stat-card .stat-label {
|
| 503 |
font-size: 0.85em;
|
| 504 |
+
color: #757575;
|
| 505 |
}
|
| 506 |
|
| 507 |
.achievement-badge {
|
| 508 |
display: inline-block;
|
| 509 |
padding: 6px 14px;
|
| 510 |
border-radius: 20px;
|
| 511 |
+
background: linear-gradient(135deg, #D4A017, #F9A825);
|
| 512 |
color: #333;
|
| 513 |
font-weight: 600;
|
| 514 |
margin: 4px;
|
| 515 |
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 516 |
}
|
| 517 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|