"""Interactive HTML explorer for BAE results.
Generates a self-contained HTML file with Plotly.js for interactive
exploration of UMAP embeddings, spatial tissue plots, gene expression
overlays, encoder coefficient bar charts, and dimension annotations.
"""
from __future__ import annotations
import json
import warnings
from pathlib import Path
def _subsample_adata(adata, *, max_cells: int, seed: int):
"""Subsample adata if it exceeds max_cells.
Parameters
----------
adata
AnnData object.
max_cells
Maximum number of cells. If n_obs <= max_cells, returns adata unchanged.
seed
Random seed for reproducible subsampling.
Returns
-------
AnnData, possibly subsampled.
"""
import numpy as np
if adata.n_obs <= max_cells:
return adata
warnings.warn(
f"Subsampling from {adata.n_obs} to {max_cells} cells for HTML explorer. "
f"Set max_cells to increase the limit.",
UserWarning,
stacklevel=3,
)
rng = np.random.default_rng(seed)
indices = rng.choice(adata.n_obs, size=max_cells, replace=False)
indices.sort()
return adata[indices].copy()
def _build_explorer_payload(
adata,
*,
model_key: str,
embedding_key: str,
spatial_key: str | None,
latent_key: str,
layers: list[str] | None,
obs_keys: list[str] | None,
top_k: int,
annotations_key: str | None,
) -> dict:
"""Extract all data needed for the interactive explorer.
Parameters
----------
adata
AnnData object with fitted BAE results.
model_key
Key in adata.varm for encoder weights.
embedding_key
Key in adata.obsm for 2D embedding coordinates.
spatial_key
Key in adata.obsm for spatial coordinates, or None.
latent_key
Key in adata.obsm for latent representation.
layers
Layer names to include. None = adata.X + all adata.layers.
obs_keys
Categorical obs column names. None = auto-detect.
top_k
Max genes per sign group per dimension.
annotations_key
Key in adata.uns for dimension annotations, or None.
Returns
-------
dict
JSON-serializable payload for the HTML template.
"""
import numpy as np
# --- Embedding coordinates ---
umap = np.asarray(adata.obsm[embedding_key], dtype=np.float64)
umap_list = umap.tolist()
# --- Spatial coordinates ---
spatial_list = None
if spatial_key is not None and spatial_key in adata.obsm:
spatial = np.asarray(adata.obsm[spatial_key], dtype=np.float64)
spatial_list = spatial.tolist()
# --- Latent representation ---
latent = np.asarray(adata.obsm[latent_key], dtype=np.float64)
latent_list = latent.tolist()
# --- Encoder weights and gene rankings per dimension ---
W = np.asarray(adata.varm[model_key], dtype=np.float64)
gene_names_all = list(adata.var_names)
_n_genes, latent_dim = W.shape
all_top_genes: set[str] = set()
dimensions_data: list[dict] = []
for dim_idx in range(latent_dim):
w = W[:, dim_idx]
# Positive genes
pos_mask = w > 0
pos_indices = np.where(pos_mask)[0]
pos_order = np.argsort(-w[pos_indices])
pos_indices = pos_indices[pos_order][:top_k]
pos_genes = [
{"name": gene_names_all[i], "weight": round(float(w[i]), 6)} for i in pos_indices
]
# Negative genes
neg_mask = w < 0
neg_indices = np.where(neg_mask)[0]
neg_order = np.argsort(w[neg_indices])
neg_indices = neg_indices[neg_order][:top_k]
neg_genes = [
{"name": gene_names_all[i], "weight": round(float(w[i]), 6)} for i in neg_indices
]
# Collect gene names for expression extraction
for g in pos_genes:
all_top_genes.add(g["name"])
for g in neg_genes:
all_top_genes.add(g["name"])
# Annotation for this dimension
annotation = None
if annotations_key and annotations_key in adata.uns:
ann_dict = adata.uns[annotations_key]
dim_key = str(dim_idx)
if dim_key in ann_dict:
a = ann_dict[dim_key]
annotation = {
"positive": a.get("positive_annotation", ""),
"negative": a.get("negative_annotation", ""),
"overall": a.get("overall_annotation", ""),
}
dimensions_data.append(
{
"index": dim_idx,
"positive_genes": pos_genes,
"negative_genes": neg_genes,
"annotation": annotation,
}
)
# --- Expression data for top genes only ---
sorted_top_genes = sorted(all_top_genes)
gene_to_idx = {name: i for i, name in enumerate(gene_names_all)}
# Determine layers
layer_names: list[str] = []
if layers is not None:
layer_names = list(layers)
else:
# anndata >= 0.13 exposes ``.X`` as ``layers[None]``, so the mapping's
# keys include ``None``. Taking them verbatim would emit ``.X`` twice,
# once as "X" and again under a ``None`` key that `json.dumps` writes as
# "null" -- a duplicated matrix rather than a crash.
layer_names = ["X"] + [name for name in adata.layers if name is not None]
expression: dict[str, dict[str, list[float]]] = {}
for layer_name in layer_names:
layer_expr: dict[str, list[float]] = {}
matrix = adata.X if layer_name == "X" else adata.layers[layer_name]
for gene in sorted_top_genes:
idx = gene_to_idx[gene]
col = matrix[:, idx]
if hasattr(col, "toarray"):
col = col.toarray()
col = np.asarray(col, dtype=np.float64).ravel()
layer_expr[gene] = [round(float(v), 6) for v in col]
expression[layer_name] = layer_expr
# --- Obs columns ---
obs_data: dict[str, list[str]] = {}
if obs_keys is not None:
keys_to_use = obs_keys
else:
keys_to_use = [col for col in adata.obs.columns if hasattr(adata.obs[col], "cat")]
for key in keys_to_use:
obs_data[key] = [str(v) for v in adata.obs[key]]
return {
"umap": umap_list,
"spatial": spatial_list,
"latent": latent_list,
"dimensions": dimensions_data,
"expression": expression,
"obs": obs_data,
"gene_names": sorted_top_genes,
}
def _escape_html(text: str) -> str:
"""Escape HTML special characters."""
return (
text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
)
def _render_html(payload: dict, *, title: str) -> str:
"""Render the interactive HTML explorer from payload data.
Parameters
----------
payload
Data payload from _build_explorer_payload.
title
HTML page title.
Returns
-------
str
Complete HTML document as a string.
"""
data_json = json.dumps(payload, separators=(",", ":"))
has_spatial = payload["spatial"] is not None
has_annotations = any(d["annotation"] is not None for d in payload["dimensions"])
# Build the spatial plot div and JS conditionally
spatial_div = ""
spatial_col_style = ""
if has_spatial:
spatial_div = '<div id="tissue-plot" style="width:100%;height:100%;"></div>'
spatial_col_style = "flex:1;min-width:300px;"
# Layout widths depend on spatial mode
left_col_style = "flex:1;min-width:350px;" if has_spatial else "flex:1.2;min-width:400px;"
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_escape_html(title)}</title>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<style>
:root {{ --pos-color: #f2994a; --neg-color: #2b6cb0; }}
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #fafafa; color: #333; }}
h1 {{ padding: 12px 20px; background: #fff; border-bottom: 1px solid #ddd;
font-size: 1.3em; font-weight: 600; }}
.container {{ display: flex; flex-wrap: wrap; padding: 10px; gap: 10px;
height: calc(100vh - 52px); }}
.left-col {{ {left_col_style} display:flex; flex-direction:column; gap:10px; }}
.left-col > div {{ background:#fff; border:1px solid #ddd; border-radius:6px;
overflow:hidden; }}
.umap-box {{ flex:1; min-height:280px; }}
.coeff-box {{ flex:1; min-height:220px; }}
{
"".join(
[
".mid-col { "
+ spatial_col_style
+ " display:flex; flex-direction:column; gap:10px; }",
".mid-col > div { background:#fff; border:1px solid #ddd; border-radius:6px; overflow:hidden; flex:1; min-height:300px; }",
]
)
if has_spatial
else ""
}
.right-col {{ flex:0.7; min-width:280px; max-width:360px; display:flex;
flex-direction:column; gap:10px; }}
.controls {{ background:#fff; border:1px solid #ddd; border-radius:6px;
padding:14px; }}
.controls label {{ display:block; font-size:0.8em; font-weight:600;
color:#666; margin-bottom:3px; margin-top:10px; }}
.controls label:first-child {{ margin-top:0; }}
.controls select {{ width:100%; padding:6px 8px; border:1px solid #ccc;
border-radius:4px; font-size:0.9em; }}
.controls input[type="range"] {{ width:100%; accent-color:#1f77b4; }}
.controls .range-value {{ display:block; margin-top:2px; font-size:0.8em; color:#555; }}
.annotation-box {{ background:#fff; border:1px solid #ddd; border-radius:6px;
padding:14px; font-size:0.82em; line-height:1.5;
{"display:none;" if not has_annotations else ""} }}
.annotation-box h3 {{ font-size:0.85em; margin-bottom:6px; }}
.annotation-box .ann-label {{ font-weight:600; color:#555; }}
.gene-list {{ background:#fff; border:1px solid #ddd; border-radius:6px;
padding:10px; flex:1; overflow-y:auto; min-height:100px; }}
.gene-list h3 {{ font-size:0.85em; margin-bottom:8px; color:#444; }}
.gene-item {{ display:flex; justify-content:space-between; padding:3px 6px;
cursor:pointer; border-radius:3px; font-size:0.82em;
font-family:monospace; }}
.gene-item:hover {{ background:#e8f0fe; }}
.gene-item.selected {{ background:#c8ddf8; font-weight:bold; }}
.gene-pos {{ color: var(--pos-color); }}
.gene-neg {{ color: var(--neg-color); }}
</style>
</head>
<body>
<h1>{_escape_html(title)}</h1>
<div class="container">
<div class="left-col">
<div class="umap-box"><div id="umap-plot" style="width:100%;height:100%;"></div></div>
<div class="coeff-box"><div id="coeff-plot" style="width:100%;height:100%;"></div></div>
</div>
{"<div class='mid-col'><div>" + spatial_div + "</div></div>" if has_spatial else ""}
<div class="right-col">
<div class="controls">
<label for="color-select">Color by</label>
<select id="color-select"></select>
<label for="obs-select">Obs column</label>
<select id="obs-select"></select>
<label for="dim-select">Dimension</label>
<select id="dim-select"></select>
<label for="layer-select">Expression layer</label>
<select id="layer-select"></select>
<label for="colorscale-select">Continuous colors</label>
<select id="colorscale-select">
<option value="blueorange" selected>Blue-Orange</option>
<option value="redblue">Red-Blue</option>
<option value="viridis">Viridis</option>
</select>
<label for="point-size">Dot size</label>
<input id="point-size" type="range" min="1" max="12" step="0.5" value="3">
<span class="range-value" id="point-size-value">3.0</span>
</div>
<div class="annotation-box" id="annotation-box">
<h3>Dimension Annotation</h3>
<div id="annotation-content"></div>
</div>
<div class="gene-list" id="gene-list-box">
<h3>Top Genes</h3>
<div id="gene-list"></div>
</div>
</div>
</div>
<script>
const DATA = {data_json};
const HAS_SPATIAL = {"true" if has_spatial else "false"};
const OBS_KEYS = Object.keys(DATA.obs);
const LAYER_KEYS = Object.keys(DATA.expression);
const HAS_OBS_OPTIONS = OBS_KEYS.length > 0;
const HAS_LAYER_OPTIONS = LAYER_KEYS.length > 0;
// --- Initialize controls ---
const colorSel = document.getElementById("color-select");
const obsSel = document.getElementById("obs-select");
const dimSel = document.getElementById("dim-select");
const layerSel = document.getElementById("layer-select");
const colorscaleSel = document.getElementById("colorscale-select");
const pointSizeInput = document.getElementById("point-size");
const pointSizeValue = document.getElementById("point-size-value");
// Color-by options: latent dims, then obs selector mode, then gene expression
DATA.dimensions.forEach(d => {{
const o = document.createElement("option");
o.value = "latent_" + d.index;
o.textContent = "Latent dim " + d.index;
colorSel.appendChild(o);
}});
if (HAS_OBS_OPTIONS) {{
const o = document.createElement("option");
o.value = "obs";
o.textContent = "Obs column";
colorSel.appendChild(o);
}}
if (HAS_LAYER_OPTIONS) {{
const o = document.createElement("option");
o.value = "gene";
o.textContent = "Gene expression";
colorSel.appendChild(o);
}}
// Obs dropdown
if (HAS_OBS_OPTIONS) {{
OBS_KEYS.forEach(k => {{
const o = document.createElement("option");
o.value = k;
o.textContent = k;
obsSel.appendChild(o);
}});
}} else {{
const o = document.createElement("option");
o.value = "";
o.textContent = "No obs columns available";
obsSel.appendChild(o);
}}
// Dimension dropdown
DATA.dimensions.forEach(d => {{
const o = document.createElement("option");
o.value = d.index;
o.textContent = "Dimension " + d.index;
dimSel.appendChild(o);
}});
// Layer dropdown
if (HAS_LAYER_OPTIONS) {{
LAYER_KEYS.forEach(k => {{
const o = document.createElement("option");
o.value = k;
o.textContent = k;
layerSel.appendChild(o);
}});
}} else {{
const o = document.createElement("option");
o.value = "";
o.textContent = "No layers available";
layerSel.appendChild(o);
}}
// --- State ---
let currentGene = null;
let currentDim = 0;
let currentPointSize = parseFloat(pointSizeInput.value);
// --- Plotting ---
const COLOR_SCALES = {{
blueorange: [
[0.0, "#2b6cb0"],
[0.5, "#f7f7f7"],
[1.0, "#f2994a"],
],
redblue: [
[0.0, "#2166ac"],
[0.5, "#f7f7f7"],
[1.0, "#b2182b"],
],
viridis: "Viridis",
}};
const SIGN_COLORS = {{
blueorange: {{ neg: "#2b6cb0", pos: "#f2994a" }},
redblue: {{ neg: "#2166ac", pos: "#b2182b" }},
viridis: {{ neg: "#440154", pos: "#fde725" }},
}};
function getActiveColorscale() {{
return COLOR_SCALES[colorscaleSel.value] || COLOR_SCALES.blueorange;
}}
function updateGeneSignColors() {{
const palette = SIGN_COLORS[colorscaleSel.value] || SIGN_COLORS.blueorange;
document.documentElement.style.setProperty("--pos-color", palette.pos);
document.documentElement.style.setProperty("--neg-color", palette.neg);
}}
function getColorRange(values) {{
const nums = values.filter(v => Number.isFinite(v));
if (!nums.length) return [0, 1];
const vmin = Math.min(...nums);
const vmax = Math.max(...nums);
if (colorscaleSel.value === "viridis") {{
if (vmin === vmax) return [vmin - 1, vmax + 1];
return [vmin, vmax];
}}
const absMax = Math.max(Math.abs(vmin), Math.abs(vmax), 1e-9);
return [-absMax, absMax];
}}
function makeScatter(divId, coords, color, showscale, isDiscrete, cmin, cmax) {{
const trace = {{
x: coords.map(c => c[0]),
y: coords.map(c => c[1]),
mode: "markers",
type: "scattergl",
marker: {{
size: currentPointSize,
showscale: showscale && !isDiscrete,
}},
hovertemplate: "%{{x:.2f}}, %{{y:.2f}}<br>%{{text}}<extra></extra>",
}};
if (isDiscrete) {{
trace.marker.color = color.map(v => _categoryColor(v));
trace.text = color;
}} else {{
trace.marker.color = color;
trace.marker.colorscale = getActiveColorscale();
trace.marker.cmin = cmin;
trace.marker.cmax = cmax;
trace.text = color.map(v => typeof v === "number" ? v.toFixed(3) : v);
}}
const layout = {{
margin: {{ t: 8, b: 30, l: 35, r: 10 }},
xaxis: {{ zeroline: false, showgrid: false }},
yaxis: {{
zeroline: false,
showgrid: false,
scaleanchor: divId === "tissue-plot" ? "x" : undefined,
}},
dragmode: "pan",
}};
Plotly.react(divId, [trace], layout, {{ responsive: true, scrollZoom: true }});
}}
function makeCoeffBar(dimIdx) {{
const dim = DATA.dimensions[dimIdx];
const genes = [...dim.positive_genes, ...dim.negative_genes];
const names = genes.map(g => g.name);
const weights = genes.map(g => g.weight);
const [cmin, cmax] = getColorRange(weights);
const trace = {{
y: names,
x: weights,
type: "bar",
orientation: "h",
marker: {{
color: weights,
colorscale: getActiveColorscale(),
cmin: cmin,
cmax: cmax,
}},
hovertemplate: "%{{y}}: %{{x:.4f}}<extra></extra>",
}};
const layout = {{
margin: {{ t: 8, b: 30, l: 80, r: 10 }},
xaxis: {{ title: "Encoder weight", zeroline: true }},
yaxis: {{ autorange: "reversed" }},
dragmode: false,
}};
Plotly.react("coeff-plot", [trace], layout, {{ responsive: true }});
// Click handler on bars
document.getElementById("coeff-plot").removeAllListeners?.("plotly_click");
document.getElementById("coeff-plot").on("plotly_click", function(data) {{
const geneName = data.points[0].y;
selectGene(geneName);
}});
}}
// Category colors (up to 20 distinct)
const CAT_COLORS = [
"#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd",
"#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf",
"#aec7e8","#ffbb78","#98df8a","#ff9896","#c5b0d5",
"#c49c94","#f7b6d2","#c7c7c7","#dbdb8d","#9edae5",
];
const _catMap = {{}};
let _catIdx = 0;
function _categoryColor(val) {{
if (!(val in _catMap)) {{
_catMap[val] = CAT_COLORS[_catIdx % CAT_COLORS.length];
_catIdx++;
}}
return _catMap[val];
}}
function resetCatColors() {{
for (const k in _catMap) delete _catMap[k];
_catIdx = 0;
}}
function updateControlStates() {{
const isObsMode = colorSel.value === "obs";
const isGeneMode = colorSel.value === "gene";
obsSel.disabled = !HAS_OBS_OPTIONS || !isObsMode;
layerSel.disabled = !HAS_LAYER_OPTIONS || !isGeneMode;
}}
// --- Update functions ---
function updatePlots() {{
const colorVal = colorSel.value;
let color, isDiscrete = false;
let cmin;
let cmax;
if (colorVal.startsWith("latent_")) {{
const idx = parseInt(colorVal.split("_")[1]);
color = DATA.latent.map(row => row[idx]);
[cmin, cmax] = getColorRange(color);
}} else if (colorVal === "obs" && HAS_OBS_OPTIONS) {{
const key = obsSel.value || OBS_KEYS[0];
color = DATA.obs[key];
isDiscrete = true;
resetCatColors();
}} else if (colorVal.startsWith("obs_")) {{
const key = colorVal.substring(4);
if (key in DATA.obs) {{
color = DATA.obs[key];
isDiscrete = true;
resetCatColors();
}} else {{
color = DATA.latent.map(row => row[0]);
[cmin, cmax] = getColorRange(color);
}}
}} else if (colorVal === "gene" && currentGene) {{
const layer = layerSel.value || LAYER_KEYS[0];
const layerExpr = DATA.expression[layer];
if (layerExpr && currentGene in layerExpr) {{
color = layerExpr[currentGene];
}} else {{
color = DATA.latent.map(row => row[0]);
[cmin, cmax] = getColorRange(color);
}}
}} else {{
// Default: first latent dim
color = DATA.latent.map(row => row[0]);
[cmin, cmax] = getColorRange(color);
}}
if (!isDiscrete && (cmin === undefined || cmax === undefined)) {{
[cmin, cmax] = getColorRange(color);
}}
makeScatter("umap-plot", DATA.umap, color, true, isDiscrete, cmin, cmax);
if (HAS_SPATIAL) {{
makeScatter("tissue-plot", DATA.spatial, color, false, isDiscrete, cmin, cmax);
}}
}}
function updateDimension() {{
currentDim = parseInt(dimSel.value);
makeCoeffBar(currentDim);
updateGeneList();
updateAnnotation();
// If in gene expression mode, select first gene automatically
if (colorSel.value === "gene" && HAS_LAYER_OPTIONS) {{
const dim = DATA.dimensions[currentDim];
const firstGene = dim.positive_genes.length > 0
? dim.positive_genes[0].name
: (dim.negative_genes.length > 0 ? dim.negative_genes[0].name : null);
if (firstGene) selectGene(firstGene);
}}
}}
function updateGeneList() {{
const dim = DATA.dimensions[currentDim];
const container = document.getElementById("gene-list");
container.innerHTML = "";
dim.positive_genes.forEach(g => {{
const div = document.createElement("div");
div.className = "gene-item" + (g.name === currentGene ? " selected" : "");
div.innerHTML = '<span class="gene-pos">+ ' + g.name + '</span><span>' + g.weight.toFixed(4) + '</span>';
div.onclick = () => selectGene(g.name);
container.appendChild(div);
}});
dim.negative_genes.forEach(g => {{
const div = document.createElement("div");
div.className = "gene-item" + (g.name === currentGene ? " selected" : "");
div.innerHTML = '<span class="gene-neg">\u2212 ' + g.name + '</span><span>' + g.weight.toFixed(4) + '</span>';
div.onclick = () => selectGene(g.name);
container.appendChild(div);
}});
}}
function updateAnnotation() {{
const dim = DATA.dimensions[currentDim];
const box = document.getElementById("annotation-box");
const content = document.getElementById("annotation-content");
if (dim.annotation) {{
box.style.display = "block";
content.innerHTML =
'<p><span class="ann-label">Positive:</span> ' + dim.annotation.positive + '</p>' +
'<p><span class="ann-label">Negative:</span> ' + dim.annotation.negative + '</p>' +
'<p><span class="ann-label">Overall:</span> ' + dim.annotation.overall + '</p>';
}} else {{
box.style.display = "none";
content.innerHTML = "";
}}
}}
function selectGene(geneName) {{
if (!HAS_LAYER_OPTIONS) return;
currentGene = geneName;
colorSel.value = "gene";
updateControlStates();
updatePlots();
updateGeneList();
}}
// --- Event listeners ---
colorSel.addEventListener("change", () => {{
updateControlStates();
updatePlots();
}});
obsSel.addEventListener("change", () => {{
if (colorSel.value === "obs") updatePlots();
}});
dimSel.addEventListener("change", updateDimension);
layerSel.addEventListener("change", () => {{
if (colorSel.value === "gene" && currentGene && HAS_LAYER_OPTIONS) updatePlots();
}});
colorscaleSel.addEventListener("change", () => {{
updateGeneSignColors();
updatePlots();
makeCoeffBar(currentDim);
}});
pointSizeInput.addEventListener("input", () => {{
currentPointSize = parseFloat(pointSizeInput.value);
pointSizeValue.textContent = currentPointSize.toFixed(1);
updatePlots();
}});
// --- Initialize ---
updateGeneSignColors();
updateControlStates();
pointSizeValue.textContent = currentPointSize.toFixed(1);
updateDimension();
updatePlots();
</script>
</body>
</html>"""
return html
[docs]
def export_interactive_html(
adata,
output_path: str | Path,
*,
model_key: str = "BAE_encoder_weights",
embedding_key: str = "X_umap",
spatial_key: str | None = "spatial",
latent_key: str | None = None,
layers: list[str] | None = None,
obs_keys: list[str] | None = None,
top_k: int = 20,
max_cells: int = 50_000,
annotations_key: str | None = None,
title: str = "BAE Explorer",
seed: int = 42,
) -> Path:
"""Export interactive HTML explorer for BAE results.
Generates a self-contained HTML file with Plotly.js for interactive
exploration of UMAP embeddings, gene expression overlays, encoder
coefficient bar charts, and (optionally) spatial tissue plots and
dimension annotations.
Parameters
----------
adata
AnnData object with fitted BAE results. Must contain
encoder weights in ``adata.varm[model_key]`` and a 2D embedding
in ``adata.obsm[embedding_key]``.
output_path
Path where the HTML file will be written.
model_key
Key in ``adata.varm`` for encoder weight matrix.
embedding_key
Key in ``adata.obsm`` for 2D embedding (e.g. UMAP). Must be
pre-computed.
spatial_key
Key in ``adata.obsm`` for spatial coordinates. Set to ``None``
to disable the spatial tissue plot. If the key is not found in
obsm, the spatial panel is silently omitted.
latent_key
Key in ``adata.obsm`` for latent representation. If ``None``,
defaults to ``"X_bae"``.
layers
Which expression layers to include. ``None`` includes
``adata.X`` (as ``"X"``) plus all keys in ``adata.layers``.
obs_keys
Categorical obs columns to include as color-by options. ``None``
auto-detects all categorical columns.
top_k
Number of top genes per sign group (positive/negative) per
latent dimension.
max_cells
If ``adata.n_obs`` exceeds this, randomly subsample with a
warning.
annotations_key
Key in ``adata.uns`` for dimension annotations. If ``None``,
auto-detected as ``"bae_dimension_annotations"``.
title
HTML page title.
seed
Random seed for reproducible subsampling.
Returns
-------
Path
The output file path.
Raises
------
KeyError
If ``embedding_key`` or ``model_key`` are not found.
"""
output_path = Path(output_path)
# --- Validate required keys ---
if embedding_key not in adata.obsm:
raise KeyError(
f"{embedding_key!r} not found in adata.obsm. Available keys: {list(adata.obsm.keys())}"
)
if model_key not in adata.varm:
raise KeyError(
f"{model_key!r} not found in adata.varm. Available keys: {list(adata.varm.keys())}"
)
# --- Auto-detect latent_key ---
if latent_key is None:
latent_key = "X_bae"
if latent_key not in adata.obsm:
raise KeyError(
f"Auto-detected latent_key {latent_key!r} not found in adata.obsm. "
f"Available keys: {list(adata.obsm.keys())}"
)
# --- Auto-detect spatial_key ---
if spatial_key is not None and spatial_key not in adata.obsm:
spatial_key = None
# --- Auto-detect annotations_key ---
if annotations_key is None:
candidate = "bae_dimension_annotations"
if candidate in adata.uns:
annotations_key = candidate
# --- Subsample ---
adata = _subsample_adata(adata, max_cells=max_cells, seed=seed)
# --- Build payload and render ---
payload = _build_explorer_payload(
adata,
model_key=model_key,
embedding_key=embedding_key,
spatial_key=spatial_key,
latent_key=latent_key,
layers=layers,
obs_keys=obs_keys,
top_k=top_k,
annotations_key=annotations_key,
)
html = _render_html(payload, title=title)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(html, encoding="utf-8")
return output_path