Ashkan Taghipour (The University of Western Australia)
UI overhaul: immersive chapter-based experience
14ba315
"""Shared helpers for the Pigeon Pea Pangenome Atlas."""
import os
import logging
import time
from pathlib import Path
from functools import wraps
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("pangenome")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = PROJECT_ROOT / "data"
PRECOMPUTED_DIR = PROJECT_ROOT / "precomputed"
def find_file(directory: Path, pattern: str) -> Path:
"""Find first file matching glob pattern in directory."""
matches = list(directory.glob(pattern))
if not matches:
raise FileNotFoundError(f"No file matching '{pattern}' in {directory}")
return matches[0]
def timer(func):
"""Decorator that logs execution time."""
@wraps(func)
def wrapper(*args, **kwargs):
t0 = time.time()
result = func(*args, **kwargs)
dt = time.time() - t0
logger.info(f"{func.__name__} completed in {dt:.2f}s")
return result
return wrapper
KNOWN_COUNTRIES = {
"India", "Myanmar", "Unknown", "Zaire", "Uganda", "Indonesia", "Jamaica",
"South_Africa", "Puerto_Rico", "Philippines", "Sierra_Leone", "Nigeria",
"Malawi", "Italy", "Kenya", "Sri_Lanka", "Thailand", "Nepal",
}
# Approximate centroid coordinates (lat, lon) for each country.
COUNTRY_COORDS = {
"India": (20.59, 78.96),
"Philippines": (12.88, 121.77),
"Kenya": (-1.29, 36.82),
"Nepal": (28.39, 84.12),
"Myanmar": (21.92, 95.96),
"Uganda": (1.37, 32.29),
"Zaire": (-4.04, 21.76),
"Indonesia": (-0.79, 113.92),
"Jamaica": (18.11, -77.30),
"South_Africa": (-30.56, 22.94),
"Puerto_Rico": (18.22, -66.59),
"Sierra_Leone": (8.46, -11.78),
"Nigeria": (9.08, 7.49),
"Malawi": (-13.25, 34.30),
"Italy": (41.87, 12.57),
"Sri_Lanka": (7.87, 80.77),
"Thailand": (15.87, 100.99),
}
def parse_country(line_id: str) -> str:
"""Extract country from line ID (last token after underscore)."""
parts = line_id.rsplit("_", 1)
if len(parts) == 2 and parts[1] in KNOWN_COUNTRIES:
return parts[1]
# Try two-word countries
parts2 = line_id.rsplit("_", 2)
if len(parts2) >= 3:
two_word = f"{parts2[-2]}_{parts2[-1]}"
if two_word in KNOWN_COUNTRIES:
return two_word
return "Unknown"
# =====================================================================
# HTML builder helpers
# =====================================================================
def build_hero_header(
total_genes: int,
n_lines: int,
n_countries: int,
n_clusters: int,
) -> str:
"""Return an HTML string for the hero dashboard header.
Renders a dark (#1a2332) banner with large stat numbers and small
uppercase labels. Uses the ``.hero-header``, ``.hero-stat``, and
``.hero-subtitle`` CSS classes defined in ``ui/theme.py``.
Parameters
----------
total_genes : int
Total number of genes in the pangenome.
n_lines : int
Number of accession lines (e.g. 89 + reference).
n_countries : int
Number of countries of origin.
n_clusters : int
Number of genomic clusters.
"""
stats = [
(f"{total_genes:,}", "Total Genes"),
(str(n_lines), "Lines"),
(str(n_countries), "Countries"),
(str(n_clusters), "Clusters"),
]
stat_html = "\n".join(
f'<span class="hero-stat">'
f'<span class="stat-number">{value}</span>'
f'<span class="stat-label">{label}</span>'
f"</span>"
for value, label in stats
)
return (
'<div class="hero-header">'
"<h1>Pigeon Pea Pangenome Atlas</h1>"
'<p class="hero-subtitle">'
"An interactive exploration of presence-absence variation across "
"pigeon pea accessions worldwide."
"</p>"
'<div class="hero-stats">'
f"{stat_html}"
"</div>"
"</div>"
)
def build_metric_card(value: str, label: str, color: str = "green") -> str:
"""Return an HTML string for a single metric card.
Parameters
----------
value : str
The large number or text to display (e.g. ``"55,512"``).
label : str
Short uppercase caption below the number (e.g. ``"TOTAL GENES"``).
color : str, optional
Color accent for the top border. One of ``"green"`` (default),
``"amber"``, ``"red"``, or ``"blue"``. Maps to CSS modifier
classes on ``.metric-card``.
"""
color_class = ""
if color in ("amber", "red", "blue"):
color_class = f" {color}"
return (
f'<div class="metric-card{color_class}">'
f'<div class="metric-value">{value}</div>'
f'<div class="metric-label">{label}</div>'
"</div>"
)
def build_progress_stepper(current_step: int = 0, total_steps: int = 6) -> str:
"""Return an HTML string for a visual progress stepper.
Renders a horizontal row of numbered circles connected by lines.
Steps before *current_step* are marked complete (green filled),
*current_step* itself gets a green ring, and later steps are dimmed.
Uses ``.progress-stepper``, ``.step-complete``, ``.step-current``,
and ``.step-future`` CSS classes from ``ui/theme.py``.
Parameters
----------
current_step : int
Zero-based index of the active step.
total_steps : int
Total number of steps (default 6, matching the quest count).
"""
step_labels = [
"Explorer",
"Map the World",
"Core vs Accessory",
"Genome Landmarks",
"Protein Relics",
"Field Report",
]
parts: list[str] = []
for i in range(total_steps):
label = step_labels[i] if i < len(step_labels) else f"Step {i + 1}"
if i < current_step:
cls = "step step-complete"
dot_content = "&#10003;" # checkmark
elif i == current_step:
cls = "step step-current"
dot_content = str(i + 1)
else:
cls = "step step-future"
dot_content = str(i + 1)
parts.append(
f'<div class="{cls}">'
f'<span class="dot">{dot_content}</span>'
f"<span>{label}</span>"
f"</div>"
)
return '<div class="progress-stepper">' + "".join(parts) + "</div>"