"""
Self-contained HTML report generator for kaleidocell results.
Public API
----------
get_html — build a tabbed, single-file HTML report from a results_mp dict.
"""
from __future__ import annotations
import base64
import io
import os
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Default GMT paths (relative to the kaleidocell/ package root)
# ---------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
# _HERE = .../site-packages/kaleidocell/ (regular install)
# = .../src/kaleidocell/ (editable install)
# files/ lives inside the package directory in both cases.
_FILES_DIR = os.path.join(_HERE, "files")
# ---------------------------------------------------------------------------
# Logo loader — searches several candidate paths so the function works in
# both editable (`pip install -e .`) and regular (`pip install .`) installs.
# ---------------------------------------------------------------------------
def _load_logo_b64() -> str | None:
"""Return the KaleidoCell logo as a base64-encoded PNG string, or None."""
candidates = [
# editable install: src/kaleidocell/ → kaleidocell/images/
os.path.join(_HERE, "..", "..", "images", "kaleidocell_logo.png"),
# regular install: the logo shipped inside the package
os.path.join(_HERE, "images", "kaleidocell_logo.png"),
]
for p in candidates:
p = os.path.normpath(p)
if os.path.isfile(p):
with open(p, "rb") as fh:
return base64.b64encode(fh.read()).decode("utf-8")
return None
_DEFAULT_GMT: dict[str, str] = {
"GO Biological Process": os.path.join(
_FILES_DIR, "c5.go.bp.v2026.1.Hs.symbols.gmt"
),
}
# ---------------------------------------------------------------------------
# Figure capture helpers
# ---------------------------------------------------------------------------
def _fig_to_b64(fig) -> str:
"""Encode a matplotlib figure as a base64 PNG string."""
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8")
def _drain_new_figures(
figs_before: set[int],
pdf_dir: str | None = None,
pdf_prefix: str = "figure",
scale: float = 1.0,
) -> list[str]:
"""Capture and close all figures created after *figs_before* was snapshot.
When *pdf_dir* is given each figure is also saved as a PDF before
being closed. Multiple figures from the same call are numbered
``{pdf_prefix}_1.pdf``, ``{pdf_prefix}_2.pdf``, etc.
*scale* multiplies the figure dimensions before PNG encoding (does not
affect the saved PDF, which uses the original size).
"""
new_nums = sorted(set(plt.get_fignums()) - figs_before)
b64s = []
for i, n in enumerate(new_nums):
fig = plt.figure(n)
if pdf_dir:
suffix = f"_{i + 1}" if len(new_nums) > 1 else ""
pdf_path = os.path.join(pdf_dir, f"{pdf_prefix}{suffix}.pdf")
fig.savefig(pdf_path, format="pdf", bbox_inches="tight")
if scale != 1.0:
w, h = fig.get_size_inches()
fig.set_size_inches(w * scale, h * scale)
b64s.append(_fig_to_b64(fig))
plt.close(fig)
return b64s
# ---------------------------------------------------------------------------
# HTML building-block helpers
# ---------------------------------------------------------------------------
def _img_tag(b64: str, alt: str = "", css_class: str = "plot") -> str:
return f'<img class="{css_class}" src="data:image/png;base64,{b64}" alt="{alt}" />'
def _warn_box(msg: str) -> str:
return f'<p class="warn">⚠ {msg}</p>'
def _section_title(title: str) -> str:
return f'<p class="section-title">{title}</p>'
def _gene_table_section(mp_dict: dict, top_n: int | None = None) -> str:
"""Build a grid of per-MP gene/score tables."""
cards = []
for mp_name, series in mp_dict.items():
top = series if top_n is None else series.head(top_n)
rows = "".join(
f"<tr><td>{_esc(str(g))}</td><td>{s:.5f}</td></tr>"
for g, s in top.items()
)
cards.append(
f'<div class="gene-card">'
f'<h3>{_esc(mp_name)}'
f' <span class="ngenes">({len(series)} genes)</span></h3>'
f'<div class="gene-table-wrap"><table>'
f'<thead><tr><th>Gene</th><th>Score</th></tr></thead>'
f'<tbody>{rows}</tbody>'
f'</table></div></div>'
)
return f'<div class="gene-grid">{"".join(cards)}</div>'
def _esc(s: str) -> str:
"""Minimal HTML escaping."""
return s.replace("&", "&").replace("<", "<").replace(">", ">")
# ---------------------------------------------------------------------------
# HTML page assembly
# ---------------------------------------------------------------------------
_PAGE_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>kaleidocell — Results</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0;}}
body{{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;}}
/* ── Header: dark left → white right gradient so the white-bg logo blends in ── */
.page-header{{
display:flex;
align-items:center;
justify-content:space-between;
background:linear-gradient(to right,#2c3e50 0%,#2c3e50 42%,#3d5a73 56%,#dbe8f0 72%,#f2f8fb 85%,#ffffff 100%);
padding:0 0 0 28px;
border-bottom:3px solid #3498db;
min-height:72px;
overflow:hidden;
}}
.page-header-title{{
color:#fff;
font-size:1.0rem;
font-weight:400;
letter-spacing:.6px;
white-space:nowrap;
flex-shrink:0;
padding-right:40px;
opacity:.85;
}}
.page-header-title strong{{
display:block;
font-size:1.3rem;
font-weight:700;
color:#fff;
letter-spacing:.3px;
opacity:1;
}}
.page-header-title strong span{{color:#3498db;}}
.page-header-logo{{
height:70px;
width:auto;
display:block;
flex-shrink:0;
/* the logo has a white background; it sits in the white end of the gradient */
}}
/* ── Navigation tab bar ── */
.tabbar{{display:flex;flex-wrap:wrap;background:#34495e;}}
.tablinks{{background:none;border:none;color:#bdc3c7;padding:13px 22px;
cursor:pointer;font-size:14px;transition:background .15s,color .15s;}}
.tablinks:hover{{background:#2c3e50;color:#ecf0f1;}}
.tablinks.active{{background:#2c3e50;color:#3498db;
border-bottom:3px solid #3498db;font-weight:600;}}
/* ── Tab content ── */
.tabcontent{{display:none;padding:28px;max-width:1400px;margin:0 auto;}}
.tabcontent.active{{display:block;}}
/* ── Plot images ── */
img.plot{{max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;
margin:10px 0;background:#fff;display:block;}}
/* ── Gene card grid (general, auto-fill) ── */
.gene-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));
gap:20px;margin-top:14px;}}
/* ── GSEA grid: exactly 2 columns ── */
.gsea-grid{{display:grid;grid-template-columns:repeat(2,1fr);
gap:20px;margin-top:14px;}}
.gene-card{{background:#fff;border:1px solid #ddd;border-radius:6px;padding:16px;}}
.gene-card h3{{color:#2c3e50;font-size:.95rem;margin-bottom:10px;}}
.ngenes{{font-weight:normal;color:#888;font-size:.82em;}}
.gene-table-wrap{{max-height:420px;overflow-y:auto;}}
/* ── Tables ── */
table{{border-collapse:collapse;width:100%;font-size:12.5px;}}
th,td{{border:1px solid #e0e0e0;padding:5px 9px;text-align:left;}}
th{{background:#2c3e50;color:#fff;position:sticky;top:0;z-index:1;}}
tr:nth-child(even){{background:#f7f7f7;}}
/* ── Section heading ── */
.section-title{{font-size:1.05rem;font-weight:600;color:#2c3e50;
margin-bottom:16px;border-bottom:2px solid #3498db;
padding-bottom:6px;}}
/* ── Warning box ── */
.warn{{color:#c0392b;background:#fef0ec;border:1px solid #f5b7b1;
padding:10px 16px;border-radius:4px;margin:8px 0;}}
/* ── Heatmap zoom controls ── */
.zoom-controls{{display:flex;align-items:center;gap:8px;margin:10px 0 4px;}}
.zoom-btn{{background:#34495e;color:#fff;border:none;border-radius:4px;
width:34px;height:34px;font-size:22px;line-height:1;cursor:pointer;
transition:background .15s;}}
.zoom-btn:hover{{background:#2c3e50;color:#3498db;}}
.zoom-label{{color:#777;font-size:13px;user-select:none;}}
</style>
<script>
function resizeImg(imgId, delta) {{
var img = document.getElementById(imgId);
if (!img) return;
var w = img.offsetWidth || img.naturalWidth || 800;
img.style.width = Math.max(200, w + delta) + 'px';
img.style.maxWidth = 'none';
}}
function openTab(tabId){{
document.querySelectorAll('.tabcontent').forEach(function(t){{
t.classList.remove('active');
}});
document.querySelectorAll('.tablinks').forEach(function(b){{
b.classList.remove('active');
}});
var el=document.getElementById(tabId);
if(el) el.classList.add('active');
var btn=document.querySelector('[data-tab="'+tabId+'"]');
if(btn) btn.classList.add('active');
}}
</script>
</head>
<body>
<div class="page-header">
<div class="page-header-title">
<strong>kaleido<span>Cell</span></strong>
Results Report
</div>
{logo_tag}
</div>
<div class="tabbar">{tab_buttons}</div>
{tab_contents}
</body>
</html>
"""
def _build_page(tabs: list[tuple[str, str, str]]) -> str:
"""
Parameters
----------
tabs : list of (tab_id, label, html_content)
Returns
-------
str — complete HTML page string
"""
# Logo: load once per report build; silently omit if file not found.
logo_b64 = _load_logo_b64()
if logo_b64:
logo_tag = (
f'<img class="page-header-logo" '
f'src="data:image/png;base64,{logo_b64}" '
f'alt="KaleidoCell" />'
)
else:
logo_tag = ""
buttons = []
contents = []
for i, (tid, label, content) in enumerate(tabs):
active = " active" if i == 0 else ""
buttons.append(
f'<button class="tablinks{active}" '
f'data-tab="{tid}" onclick="openTab(\'{tid}\')">'
f'{_esc(label)}</button>'
)
contents.append(
f'<div id="{tid}" class="tabcontent{active}">{content}</div>'
)
return _PAGE_TEMPLATE.format(
logo_tag=logo_tag,
tab_buttons="\n".join(buttons),
tab_contents="\n".join(contents),
)
# ---------------------------------------------------------------------------
# Per-section rendering
# ---------------------------------------------------------------------------
def _render_heatmap(results_mp: dict, save_dir: str | None = None) -> str:
from unittest.mock import patch
from .visualization import plot_heatmap
figs_before = set(plt.get_fignums())
with patch("matplotlib.pyplot.show", lambda: None), \
patch("matplotlib.pyplot.close", lambda *_: None):
plot_heatmap(results_mp)
b64s = _drain_new_figures(figs_before, pdf_dir=save_dir, pdf_prefix="heatmap", scale=1.5)
if not b64s:
return _warn_box("Heatmap could not be generated.")
blocks = []
for i, b in enumerate(b64s):
img_id = f"heatmap-img-{i}"
controls = (
f'<div class="zoom-controls">'
f'<button class="zoom-btn" onclick="resizeImg(\'{img_id}\',-100)" title="Shrink">−</button>'
f'<span class="zoom-label">Resize</span>'
f'<button class="zoom-btn" onclick="resizeImg(\'{img_id}\',100)" title="Enlarge">+</button>'
f'</div>'
)
img = f'<img id="{img_id}" class="plot" src="data:image/png;base64,{b}" alt="heatmap" />'
blocks.append(controls + img)
return _section_title("Cosine-similarity matrix") + "".join(blocks)
def _render_umap(
mp_scores: pd.DataFrame, adata, save_dir: str | None = None
) -> str:
try:
import scanpy as sc
except ImportError:
return _warn_box("scanpy is not installed — UMAP section skipped.")
from .visualization import recompute_pca_umap
adata_plot = adata.copy()
scores_aligned = mp_scores.loc[adata.obs_names]
for col in scores_aligned.columns:
adata_plot.obs[col] = scores_aligned[col].values
if "X_umap" not in adata_plot.obsm:
adata_plot = recompute_pca_umap(adata_plot)
figs_before = set(plt.get_fignums())
sc.pl.umap(adata_plot, color=list(scores_aligned.columns), ncols=3, show=False)
b64s = _drain_new_figures(
figs_before, pdf_dir=save_dir, pdf_prefix="umap_scores"
)
if not b64s:
return _warn_box("UMAP plot could not be generated.")
return _section_title("MP scores on UMAP") + "".join(
_img_tag(b, "UMAP scores") for b in b64s
)
def _render_gsea(
results_mp: dict,
label: str,
gmt_path: str,
save_dir: str | None = None,
) -> "tuple[str, pd.DataFrame]":
"""Run GSEA for one GMT file.
Returns
-------
html : str
terms_df : pd.DataFrame
Significant terms table (MP, Term) — empty if nothing found.
"""
_empty_df = pd.DataFrame(columns=["MP", "Term"])
if not os.path.exists(gmt_path):
return (
_warn_box(
f"GMT file not found: <code>{gmt_path}</code>"
f" — {label} section skipped."
),
_empty_df,
)
try:
from .gsea import run_gsea_pipeline
terms_df, mp_plot_data = run_gsea_pipeline(
results_mp,
from_file=gmt_path,
plot=False,
save_csv=False,
top_n_plot=10_000, # all significant terms
)
except Exception as exc:
return _warn_box(f"GSEA failed for {label}: {exc}"), _empty_df
if not mp_plot_data:
return _warn_box(f"No significant GSEA terms found for {label}."), _empty_df
# Build one card per MP with a scrollable term table
cards = []
for mp_name, plot_df in mp_plot_data.items():
ranked = plot_df.sort_values("-log10(padj)", ascending=False)
rows = "".join(
f"<tr><td>{_esc(str(row['Term']).replace(chr(10), ' '))}</td>"
f"<td style='text-align:right;white-space:nowrap;'>{row['-log10(padj)']:.3f}</td></tr>"
for _, row in ranked.iterrows()
)
cards.append(
f'<div class="gene-card">'
f'<h3>{_esc(mp_name)}'
f' <span class="ngenes">({len(ranked)} terms)</span></h3>'
f'<div class="gene-table-wrap"><table>'
f'<thead><tr><th>Term</th><th style="text-align:right;">−log₁₀(padj)</th></tr></thead>'
f'<tbody>{rows}</tbody>'
f'</table></div></div>'
)
html = (
_section_title(f"GSEA — {label}")
+ f'<div class="gsea-grid">{"".join(cards)}</div>'
)
return html, terms_df
def _render_violins(
mp_scores: pd.DataFrame,
adata,
obs_key: str,
save_dir: str | None = None,
) -> str:
from unittest.mock import patch
from .visualization import show_distribution_over_obs
safe_key = obs_key.replace(" ", "_").replace("/", "_")
figs_before = set(plt.get_fignums())
with patch("matplotlib.pyplot.show", lambda: None):
show_distribution_over_obs(
mp_scores, adata, batch_key=obs_key, save=False, figsize=(5, 3)
)
b64s = _drain_new_figures(
figs_before,
pdf_dir=save_dir,
pdf_prefix=f"violins_{safe_key}",
scale=1.5,
)
if not b64s:
return _warn_box(f"Violin plots could not be generated for '{obs_key}'.")
return (
_section_title(f"MP score distributions — {obs_key}")
+ "".join(_img_tag(b, f"violin {obs_key}") for b in b64s)
)
def _render_metrics(results_mp: dict) -> str:
"""Build an HTML section for the MP quality-metrics table."""
metrics: pd.DataFrame = results_mp.get("metrics")
if metrics is None or metrics.empty:
return _warn_box("No metrics found in results_mp.")
# ── column definitions shown below the table ──────────────────────────────
_DEFS = [
(
"sampleCoverage",
"Fraction of input samples that contributed at least one NMF program to this "
"meta-program's cluster. A value of 1.0 means every sample is represented; "
"values below ~0.5 may indicate a sample-specific program.",
),
(
"silhouette",
"Mean silhouette score of the NMF programs inside this cluster, computed on "
"the cosine-distance matrix. Ranges from −1 (mis-clustered) to +1 (well-"
"separated). Values above 0.2–0.3 are generally considered good cohesion; "
"values below 0.1 suggest the cluster boundary is weak.",
),
(
"meanSimilarity",
"Average pairwise cosine similarity between all NMF programs in the cluster. "
"Measures internal consistency: programs in a high-quality meta-program should "
"point in similar directions in gene space. Typical good values are ≥ 0.3.",
),
(
"nPrograms",
"Number of individual NMF programs (across all samples and ranks) assigned to "
"this meta-program's cluster. Higher counts indicate the signature was "
"reproducibly recovered.",
),
(
"nGenes",
"Number of genes in the consensus gene signature for this meta-program after "
"the top-N gene selection step.",
),
]
# ── table ─────────────────────────────────────────────────────────────────
col_order = [c for c in ["sampleCoverage", "silhouette", "meanSimilarity", "nPrograms", "nGenes"]
if c in metrics.columns]
# include any extra columns not listed above
extra = [c for c in metrics.columns if c not in col_order]
col_order += extra
def _fmt(val):
if isinstance(val, float):
return f"{val:.3f}"
return _esc(str(val))
header_cells = "".join(f"<th>{_esc(c)}</th>" for c in col_order)
rows_html = ""
for mp_name, row in metrics.iterrows():
cells = "".join(f"<td>{_fmt(row[c])}</td>" for c in col_order)
rows_html += f"<tr><td><strong>{_esc(str(mp_name))}</strong></td>{cells}</tr>"
table_html = (
f'<table style="margin-bottom:32px;">'
f'<thead><tr><th>MP</th>{header_cells}</tr></thead>'
f'<tbody>{rows_html}</tbody>'
f'</table>'
)
# ── definitions ───────────────────────────────────────────────────────────
def_items = "".join(
f'<dt style="font-weight:600;color:#2c3e50;margin-top:12px;">{_esc(col)}</dt>'
f'<dd style="margin-left:18px;color:#555;font-size:13px;line-height:1.5;">{_esc(defn)}</dd>'
for col, defn in _DEFS
if col in col_order
)
defs_html = (
f'<p class="section-title" style="margin-top:8px;">Column definitions</p>'
f'<dl>{def_items}</dl>'
)
return (
_section_title("Meta-program quality metrics")
+ table_html
+ defs_html
)
def _render_genes(results_mp: dict) -> str:
return (
_section_title("Gene signatures (all genes per MP)")
+ _gene_table_section(results_mp["mp_dict"], top_n=None)
)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
[docs]
def get_html(
results_mp: dict,
adata,
mp_scores: pd.DataFrame = None,
obs: list = None,
gsea_sets: dict = None,
output_path: str = "results/results.html",
gene_name_col: str = None,
gene_name_from_col: str = None,
verbose: bool = True,
) -> str:
"""Generate a self-contained tabbed HTML report of kaleidocell results.
Each section appears in its own tab so the user can navigate
easily between views. The report is written to a single HTML
file with all images embedded as base64 strings — no external
resources are required to view it.
Tabs generated
--------------
**Heatmap**
Cosine-similarity matrix coloured heatmap with cluster
boundaries (always included).
**UMAP scores** *(requires mp_scores)*
All MP module scores overlaid on the UMAP embedding. UMAP
is recomputed if not already present in *adata*.
**GSEA — GO Biological Process** *(skipped if GMT file absent)*
Enrichr bar plots per MP using the MSigDB C5 GO BP gene sets.
**Genes**
Scrollable per-MP gene tables (all genes with scores).
**Violin plots** *(one tab per key in obs)*
MP score distributions across the specified ``adata.obs``
column. Omitted when *obs* is ``None`` or empty.
Parameters
----------
results_mp : dict
Output of :func:`~kaleidocell.consensus.derive_nmf_metaprograms`.
adata : AnnData
Dataset used for UMAP and violin-plot obs annotations.
mp_scores : pd.DataFrame or None
Per-cell module scores from
:func:`~kaleidocell.consensus.compute_mp_scores`. Required for the
UMAP-scores and violin-plot tabs; those tabs are skipped when
*None*.
obs : list of str or None
``adata.obs`` columns for which violin-plot tabs are generated
(e.g. ``['Treatment', 'donor']``). Pass ``None`` or an empty
list to omit violin plots entirely.
gsea_sets : dict or None
Mapping ``{label: gmt_path}`` overriding the default GMT
files. Default:
.. code-block:: python
{
"GO Biological Process": "<pkg>/files/c5.go.bp.v2026.1.Hs.symbols.gmt",
}
Pass an empty dict ``{}`` to skip GSEA entirely.
output_path : str, default ``"results/results.html"``
Path for the generated HTML file. All other output files
(PDFs, CSVs) are written to the **same directory**.
Parent directories are created automatically.
gene_name_col : str or None
Column in ``adata.var`` containing the gene names to display in
the report (e.g. ``"gene_name"`` for HGNC symbols when the MP
gene names are Ensembl IDs). When *None* gene names are shown
as stored in *results_mp*.
gene_name_from_col : str or None
Column in ``adata.var`` whose values match the current gene names
in *results_mp*. Only used when *gene_name_col* is set. When
*None* the current names are matched against ``adata.var.index``.
verbose : bool, default True
Print progress messages and a summary of written files.
Returns
-------
str
Absolute path to the written HTML file.
Files written
-------------
``results.html``
Self-contained tabbed report (always).
``heatmap.pdf``
Cosine-similarity matrix (always).
``umap_scores.pdf``
MP scores on UMAP (*requires mp_scores*).
``gsea_{label}.csv``
Significant GSEA terms per GMT file (when GSEA runs successfully).
``gsea_{label}_{MP}.pdf``
GSEA bar plots, one PDF per MP per GMT file.
``genes.csv``
Long-format gene table: ``gene``, ``mp``, ``score`` (always).
``violins_{obs_key}.pdf`` (one per figure/MP)
Violin plots per obs key (*requires obs*).
Examples
--------
Minimal call — heatmap + genes only:
>>> path = kaleidocell.get_html(results_mp, adata)
Full report with UMAP, violins and GSEA:
>>> path = kaleidocell.get_html(
... results_mp, adata,
... mp_scores=mp_scores,
... obs=['Treatment', 'donor'],
... )
>>> print(f"Report written to {path}")
Custom GMT files:
>>> path = kaleidocell.get_html(
... results_mp, adata,
... gsea_sets={"KEGG": "/path/to/kegg.gmt"},
... )
"""
# Optionally translate gene names before rendering
if gene_name_col is not None:
from .consensus import translate_gene_names
results_mp = translate_gene_names(
results_mp, adata,
to_col=gene_name_col,
from_col=gene_name_from_col,
verbose=verbose,
)
# Resolve paths — if output_path has no .html extension, treat it as a
# directory and place results.html inside it.
gmt_map: dict[str, str] = gsea_sets if gsea_sets is not None else _DEFAULT_GMT
output_path = os.path.abspath(output_path)
if not output_path.endswith(".html"):
output_path = os.path.join(output_path, "results.html")
out_dir = os.path.dirname(output_path)
os.makedirs(out_dir, exist_ok=True)
tabs: list[tuple[str, str, str]] = []
saved_files: list[str] = []
# ── 1. Heatmap ─────────────────────────────────────────────────────────────
if verbose:
print("Rendering heatmap…")
try:
heatmap_html = _render_heatmap(results_mp, save_dir=out_dir)
except Exception as exc:
heatmap_html = _warn_box(f"Heatmap rendering failed: {exc}")
tabs.append(("heatmap", "Heatmap", heatmap_html))
# ── 2. UMAP scores ─────────────────────────────────────────────────────────
if mp_scores is not None:
if verbose:
print("Rendering UMAP scores…")
try:
umap_html = _render_umap(mp_scores, adata, save_dir=out_dir)
except Exception as exc:
umap_html = _warn_box(f"UMAP rendering failed: {exc}")
tabs.append(("umap_scores", "UMAP Scores", umap_html))
# ── 3. Metrics ─────────────────────────────────────────────────────────────
if verbose:
print("Building metrics table…")
try:
metrics_html = _render_metrics(results_mp)
except Exception as exc:
metrics_html = _warn_box(f"Metrics rendering failed: {exc}")
tabs.append(("metrics", "Metrics", metrics_html))
# ── 4. GSEA tabs (one per GMT file) ────────────────────────────────────────
for i, (label, gmt_path) in enumerate(gmt_map.items()):
if verbose:
print(f"Running GSEA — {label}…")
try:
gsea_html, terms_df = _render_gsea(
results_mp, label, gmt_path, save_dir=out_dir
)
except Exception as exc:
gsea_html = _warn_box(f"GSEA failed for '{label}': {exc}")
terms_df = pd.DataFrame(columns=["MP", "Term"])
# Save GSEA CSV
if not terms_df.empty:
label_safe = label.replace(" ", "_").replace("/", "_")
csv_path = os.path.join(out_dir, f"gsea_{label_safe}.csv")
terms_df.to_csv(csv_path, index=False)
saved_files.append(csv_path)
tab_id = f"gsea_{i}"
tabs.append((tab_id, f"GSEA — {label}", gsea_html))
# ── 5. Gene table + CSV ────────────────────────────────────────────────────
if verbose:
print("Building gene table…")
tabs.append(("genes", "Genes", _render_genes(results_mp)))
# Save genes as long-format CSV: gene, mp, score
gene_rows = [
{"gene": gene, "mp": mp_name, "score": float(score)}
for mp_name, series in results_mp["mp_dict"].items()
for gene, score in series.items()
]
genes_csv_path = os.path.join(out_dir, "genes.csv")
pd.DataFrame(gene_rows).to_csv(genes_csv_path, index=False)
saved_files.append(genes_csv_path)
# ── 6. Violin plots (one tab per obs key) ──────────────────────────────────
if obs and mp_scores is not None:
for obs_key in obs:
if obs_key not in adata.obs.columns:
if verbose:
print(f" Skipping violin plot: '{obs_key}' not in adata.obs")
continue
if verbose:
print(f"Rendering violin plots for '{obs_key}'…")
try:
violin_html = _render_violins(
mp_scores, adata, obs_key, save_dir=out_dir
)
except Exception as exc:
violin_html = _warn_box(
f"Violin plots failed for '{obs_key}': {exc}"
)
safe_id = obs_key.replace(" ", "_").replace("/", "_")
tabs.append((f"violin_{safe_id}", f"Violins — {obs_key}", violin_html))
# ── 7. Assemble and write HTML ─────────────────────────────────────────────
html = _build_page(tabs)
with open(output_path, "w", encoding="utf-8") as fh:
fh.write(html)
saved_files.insert(0, output_path)
if verbose:
print(f"\nFiles written to {out_dir}/")
for p in saved_files:
print(f" {os.path.basename(p)}")
return output_path