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 CHANGED
@@ -4,7 +4,7 @@ emoji: "\U0001F331"
4
  colorFrom: green
5
  colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 5.29.0
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
- DATA["embedding"] = pd.read_parquet(p / "line_embedding.parquet")
 
 
 
 
 
 
 
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 Plotly UMAP scatter."""
65
- embedding = data["embedding"]
66
- line_stats = data["line_stats"]
67
-
68
- df = embedding.merge(line_stats[["line_id", "country"]], on="line_id", how="left")
69
- df["country"] = df["country"].fillna("Unknown")
70
-
71
- color_col = "country" if color_by == "Country" else "cluster_id"
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 = "&#10003;" # 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: Field Report generation and export."""
2
 
3
  import gradio as gr
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def build_final_tab():
7
- """Build Final Report tab components. Returns dict of components."""
8
- with gr.Tab("Field Report", id="final") as tab:
9
- gr.Markdown("## Your Exploration Report")
10
- gr.Markdown(
11
- "Generate a summary of your pangenome exploration journey, "
 
 
 
 
 
 
 
 
12
  "including your selected line, findings, and backpack collection."
 
13
  )
14
 
15
- generate_btn = gr.Button("Generate Report", variant="primary")
 
 
 
 
16
 
17
- report_md = gr.Markdown(value="*Click 'Generate Report' to create your field report.*")
 
 
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.Markdown("### Achievements Earned")
24
- achievements_html = gr.HTML(value="<p>Complete quests to earn badges!</p>")
 
 
 
 
 
 
 
 
 
 
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 (&le;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} &middot; 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.Markdown("### Gene Card")
 
 
 
 
10
  gene_card_html = gr.HTML(value="")
11
 
12
  with gr.Row():
13
- show_genome_btn = gr.Button("Show on Genome", size="sm")
14
- show_protein_btn = gr.Button("Show Protein", size="sm")
 
 
 
 
 
 
 
 
15
 
16
  with gr.Row():
17
- pin_card_btn = gr.Button("Pin to Backpack", size="sm", variant="secondary")
18
- download_gene_btn = gr.Button("Download Report", size="sm")
 
 
 
 
 
 
 
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"]:,} &ndash; '
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
- ("Explorer", 0),
98
- ("Map the World", 1),
99
- ("Core vs Accessory", 2),
100
- ("Genome Landmarks", 3),
101
- ("Protein Relics", 4),
102
- ("Field Report", 5),
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: Choose Your Explorer line selection and overview."""
 
 
 
 
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
- with gr.Tab("Choose Your Explorer", id="quest0") as tab:
9
- gr.Markdown("## Choose your line to explore")
10
- gr.Markdown(
11
- "Select one of the 89 pigeon pea lines to begin your pangenome journey. "
12
- "Each line has a unique gene repertoire shaped by geography and breeding history."
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  )
14
 
15
- line_dropdown = gr.Dropdown(
16
- choices=line_choices,
17
- label="Select a pigeon pea line",
18
- info="89 lines from across the world",
19
- interactive=True,
20
  )
21
 
 
 
 
 
 
 
 
 
22
  with gr.Row():
23
- total_genes_box = gr.Textbox(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  label="Total Genes Present",
25
  interactive=False,
26
  value="--",
27
  )
28
- unique_genes_box = gr.Textbox(
29
  label="Unique Genes",
30
  interactive=False,
31
  value="--",
32
  info="Genes found only in this line",
33
  )
34
- nearest_neighbor_box = gr.Textbox(
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
- "Start the Journey",
43
  variant="primary",
44
  size="lg",
45
  )
46
 
47
  return {
48
  "tab": tab,
 
 
49
  "line_dropdown": line_dropdown,
50
- "total_genes": total_genes_box,
51
- "unique_genes": unique_genes_box,
52
- "nearest_neighbor": nearest_neighbor_box,
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: Map the World UMAP scatter of lines."""
 
 
 
 
2
 
3
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def build_quest1():
7
- """Build Quest 1 tab components. Returns dict of components."""
8
- with gr.Tab("Map the World", id="quest1") as tab:
9
- gr.Markdown("## How do 89 lines relate by gene content?")
10
- gr.Markdown(
11
- "This UMAP projection arranges lines by their gene presence/absence profiles. "
12
- "Lines closer together share more genes."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- umap_plot = gr.Plot(label="UMAP of 89 pigeon pea lines")
 
 
 
 
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("Compare my line to party", variant="secondary")
 
 
 
32
 
33
  comparison_plot = gr.Plot(label="Comparison", visible=False)
34
 
35
  with gr.Accordion("What does this mean?", open=False):
36
- gr.Markdown(
37
- "**UMAP** reduces the high-dimensional PAV matrix to 2D.\n\n"
38
- "- **Country coloring** shows geographic origins.\n"
39
- "- **Cluster coloring** shows groups identified by KMeans.\n"
40
- "- **Click** a point to see its stats.\n"
41
- "- **Lasso select** multiple points to compare with your chosen line."
 
 
 
 
 
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 vs Accessory gene classification explorer."""
2
 
 
 
 
 
 
3
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def build_quest2():
7
- """Build Quest 2 tab components. Returns dict of components."""
8
- with gr.Tab("Core vs Accessory", id="quest2") as tab:
9
- gr.Markdown("## Explore the Core, Shell, and Cloud genome")
10
- gr.Markdown(
11
- "Genes are classified by how many of the 89 lines carry them. "
12
- "Adjust the thresholds to explore different definitions."
 
 
 
 
 
 
 
 
 
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
- with gr.Row():
28
- donut_plot = gr.Plot(label="Core / Shell / Cloud distribution")
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 (&lt;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 Landmarkshotspot exploration."""
2
 
 
 
 
 
 
3
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def build_quest3(contig_choices: list[str]):
7
- """Build Quest 3 tab components. Returns dict of components."""
8
- with gr.Tab("Genome Landmarks", id="quest3") as tab:
9
- gr.Markdown("## Explore genomic hotspots of variation")
10
- gr.Markdown(
11
- "The genome is divided into 100 kb bins. Hotter bins contain more "
12
- "variable (shell/cloud) genes — potential regions of adaptation."
 
 
 
 
 
 
 
 
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 ExplorerCircos-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 &mdash; "
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 Relics — protein analysis."""
 
 
 
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
- with gr.Tab("Protein Relics", id="quest4") as tab:
9
- gr.Markdown("## Examine protein properties")
10
- gr.Markdown(
 
 
 
 
 
 
 
 
 
11
  "Explore the protein products of pangenome genes. Compare amino acid "
12
- "compositions and lengths across your backpack collection."
 
13
  )
14
 
15
- gene_dropdown = gr.Dropdown(
16
- choices=gene_choices,
17
- label="Select a gene (or pick from backpack)",
18
- interactive=True,
19
- allow_custom_value=True,
20
- )
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- protein_stats_html = gr.HTML(
23
- value="<p>Select a gene to see protein stats</p>",
24
- label="Protein Statistics",
 
 
 
 
 
25
  )
26
 
27
- gr.Markdown("### Backpack Comparison")
28
- gr.Markdown("Pin at least 2 genes to your backpack to see comparisons.")
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: #FFC107; color: #333; }
16
- .badge-cloud { background: #F44336; color: white; }
17
 
18
  .gene-card {
19
  border: 2px solid #2E7D32;
20
- border-radius: 8px;
21
  padding: 16px;
22
- background: #F1F8E9;
 
23
  }
24
 
25
  .presence-barcode span {
@@ -28,7 +461,7 @@ CUSTOM_CSS = """
28
  height: 20px;
29
  margin: 0;
30
  }
31
- .presence-barcode .present { background: #4CAF50; }
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: 16px;
59
- border-radius: 8px;
60
- background: #F1F8E9;
61
- border: 1px solid #C8E6C9;
 
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: #666;
71
  }
72
 
73
  .achievement-badge {
74
  display: inline-block;
75
  padding: 6px 14px;
76
  border-radius: 20px;
77
- background: linear-gradient(135deg, #FFC107, #FF9800);
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
  """