structboost 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,842 @@
1
+ """Interactive HTML explorer for BAE results.
2
+
3
+ Generates a self-contained HTML file with Plotly.js for interactive
4
+ exploration of UMAP embeddings, spatial tissue plots, gene expression
5
+ overlays, encoder coefficient bar charts, and dimension annotations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import warnings
12
+ from pathlib import Path
13
+
14
+
15
+ def _subsample_adata(adata, *, max_cells: int, seed: int):
16
+ """Subsample adata if it exceeds max_cells.
17
+
18
+ Parameters
19
+ ----------
20
+ adata
21
+ AnnData object.
22
+ max_cells
23
+ Maximum number of cells. If n_obs <= max_cells, returns adata unchanged.
24
+ seed
25
+ Random seed for reproducible subsampling.
26
+
27
+ Returns
28
+ -------
29
+ AnnData, possibly subsampled.
30
+ """
31
+ import numpy as np
32
+
33
+ if adata.n_obs <= max_cells:
34
+ return adata
35
+
36
+ warnings.warn(
37
+ f"Subsampling from {adata.n_obs} to {max_cells} cells for HTML explorer. "
38
+ f"Set max_cells to increase the limit.",
39
+ UserWarning,
40
+ stacklevel=3,
41
+ )
42
+ rng = np.random.default_rng(seed)
43
+ indices = rng.choice(adata.n_obs, size=max_cells, replace=False)
44
+ indices.sort()
45
+ return adata[indices].copy()
46
+
47
+
48
+ def _build_explorer_payload(
49
+ adata,
50
+ *,
51
+ model_key: str,
52
+ embedding_key: str,
53
+ spatial_key: str | None,
54
+ latent_key: str,
55
+ layers: list[str] | None,
56
+ obs_keys: list[str] | None,
57
+ top_k: int,
58
+ annotations_key: str | None,
59
+ ) -> dict:
60
+ """Extract all data needed for the interactive explorer.
61
+
62
+ Parameters
63
+ ----------
64
+ adata
65
+ AnnData object with fitted BAE results.
66
+ model_key
67
+ Key in adata.varm for encoder weights.
68
+ embedding_key
69
+ Key in adata.obsm for 2D embedding coordinates.
70
+ spatial_key
71
+ Key in adata.obsm for spatial coordinates, or None.
72
+ latent_key
73
+ Key in adata.obsm for latent representation.
74
+ layers
75
+ Layer names to include. None = adata.X + all adata.layers.
76
+ obs_keys
77
+ Categorical obs column names. None = auto-detect.
78
+ top_k
79
+ Max genes per sign group per dimension.
80
+ annotations_key
81
+ Key in adata.uns for dimension annotations, or None.
82
+
83
+ Returns
84
+ -------
85
+ dict
86
+ JSON-serializable payload for the HTML template.
87
+ """
88
+ import numpy as np
89
+
90
+ # --- Embedding coordinates ---
91
+ umap = np.asarray(adata.obsm[embedding_key], dtype=np.float64)
92
+ umap_list = umap.tolist()
93
+
94
+ # --- Spatial coordinates ---
95
+ spatial_list = None
96
+ if spatial_key is not None and spatial_key in adata.obsm:
97
+ spatial = np.asarray(adata.obsm[spatial_key], dtype=np.float64)
98
+ spatial_list = spatial.tolist()
99
+
100
+ # --- Latent representation ---
101
+ latent = np.asarray(adata.obsm[latent_key], dtype=np.float64)
102
+ latent_list = latent.tolist()
103
+
104
+ # --- Encoder weights and gene rankings per dimension ---
105
+ W = np.asarray(adata.varm[model_key], dtype=np.float64)
106
+ gene_names_all = list(adata.var_names)
107
+ _n_genes, latent_dim = W.shape
108
+
109
+ all_top_genes: set[str] = set()
110
+ dimensions_data: list[dict] = []
111
+
112
+ for dim_idx in range(latent_dim):
113
+ w = W[:, dim_idx]
114
+
115
+ # Positive genes
116
+ pos_mask = w > 0
117
+ pos_indices = np.where(pos_mask)[0]
118
+ pos_order = np.argsort(-w[pos_indices])
119
+ pos_indices = pos_indices[pos_order][:top_k]
120
+ pos_genes = [
121
+ {"name": gene_names_all[i], "weight": round(float(w[i]), 6)} for i in pos_indices
122
+ ]
123
+
124
+ # Negative genes
125
+ neg_mask = w < 0
126
+ neg_indices = np.where(neg_mask)[0]
127
+ neg_order = np.argsort(w[neg_indices])
128
+ neg_indices = neg_indices[neg_order][:top_k]
129
+ neg_genes = [
130
+ {"name": gene_names_all[i], "weight": round(float(w[i]), 6)} for i in neg_indices
131
+ ]
132
+
133
+ # Collect gene names for expression extraction
134
+ for g in pos_genes:
135
+ all_top_genes.add(g["name"])
136
+ for g in neg_genes:
137
+ all_top_genes.add(g["name"])
138
+
139
+ # Annotation for this dimension
140
+ annotation = None
141
+ if annotations_key and annotations_key in adata.uns:
142
+ ann_dict = adata.uns[annotations_key]
143
+ dim_key = str(dim_idx)
144
+ if dim_key in ann_dict:
145
+ a = ann_dict[dim_key]
146
+ annotation = {
147
+ "positive": a.get("positive_annotation", ""),
148
+ "negative": a.get("negative_annotation", ""),
149
+ "overall": a.get("overall_annotation", ""),
150
+ }
151
+
152
+ dimensions_data.append(
153
+ {
154
+ "index": dim_idx,
155
+ "positive_genes": pos_genes,
156
+ "negative_genes": neg_genes,
157
+ "annotation": annotation,
158
+ }
159
+ )
160
+
161
+ # --- Expression data for top genes only ---
162
+ sorted_top_genes = sorted(all_top_genes)
163
+ gene_to_idx = {name: i for i, name in enumerate(gene_names_all)}
164
+
165
+ # Determine layers
166
+ layer_names: list[str] = []
167
+ if layers is not None:
168
+ layer_names = list(layers)
169
+ else:
170
+ # anndata >= 0.13 exposes ``.X`` as ``layers[None]``, so the mapping's
171
+ # keys include ``None``. Taking them verbatim would emit ``.X`` twice,
172
+ # once as "X" and again under a ``None`` key that `json.dumps` writes as
173
+ # "null" -- a duplicated matrix rather than a crash.
174
+ layer_names = ["X"] + [name for name in adata.layers if name is not None]
175
+
176
+ expression: dict[str, dict[str, list[float]]] = {}
177
+ for layer_name in layer_names:
178
+ layer_expr: dict[str, list[float]] = {}
179
+ matrix = adata.X if layer_name == "X" else adata.layers[layer_name]
180
+ for gene in sorted_top_genes:
181
+ idx = gene_to_idx[gene]
182
+ col = matrix[:, idx]
183
+ if hasattr(col, "toarray"):
184
+ col = col.toarray()
185
+ col = np.asarray(col, dtype=np.float64).ravel()
186
+ layer_expr[gene] = [round(float(v), 6) for v in col]
187
+ expression[layer_name] = layer_expr
188
+
189
+ # --- Obs columns ---
190
+ obs_data: dict[str, list[str]] = {}
191
+ if obs_keys is not None:
192
+ keys_to_use = obs_keys
193
+ else:
194
+ keys_to_use = [col for col in adata.obs.columns if hasattr(adata.obs[col], "cat")]
195
+ for key in keys_to_use:
196
+ obs_data[key] = [str(v) for v in adata.obs[key]]
197
+
198
+ return {
199
+ "umap": umap_list,
200
+ "spatial": spatial_list,
201
+ "latent": latent_list,
202
+ "dimensions": dimensions_data,
203
+ "expression": expression,
204
+ "obs": obs_data,
205
+ "gene_names": sorted_top_genes,
206
+ }
207
+
208
+
209
+ def _escape_html(text: str) -> str:
210
+ """Escape HTML special characters."""
211
+ return (
212
+ text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
213
+ )
214
+
215
+
216
+ def _render_html(payload: dict, *, title: str) -> str:
217
+ """Render the interactive HTML explorer from payload data.
218
+
219
+ Parameters
220
+ ----------
221
+ payload
222
+ Data payload from _build_explorer_payload.
223
+ title
224
+ HTML page title.
225
+
226
+ Returns
227
+ -------
228
+ str
229
+ Complete HTML document as a string.
230
+ """
231
+ data_json = json.dumps(payload, separators=(",", ":"))
232
+ has_spatial = payload["spatial"] is not None
233
+ has_annotations = any(d["annotation"] is not None for d in payload["dimensions"])
234
+
235
+ # Build the spatial plot div and JS conditionally
236
+ spatial_div = ""
237
+ spatial_col_style = ""
238
+ if has_spatial:
239
+ spatial_div = '<div id="tissue-plot" style="width:100%;height:100%;"></div>'
240
+ spatial_col_style = "flex:1;min-width:300px;"
241
+
242
+ # Layout widths depend on spatial mode
243
+ left_col_style = "flex:1;min-width:350px;" if has_spatial else "flex:1.2;min-width:400px;"
244
+
245
+ html = f"""<!DOCTYPE html>
246
+ <html lang="en">
247
+ <head>
248
+ <meta charset="UTF-8">
249
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
250
+ <title>{_escape_html(title)}</title>
251
+ <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
252
+ <style>
253
+ :root {{ --pos-color: #f2994a; --neg-color: #2b6cb0; }}
254
+ * {{ margin:0; padding:0; box-sizing:border-box; }}
255
+ body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
256
+ background: #fafafa; color: #333; }}
257
+ h1 {{ padding: 12px 20px; background: #fff; border-bottom: 1px solid #ddd;
258
+ font-size: 1.3em; font-weight: 600; }}
259
+ .container {{ display: flex; flex-wrap: wrap; padding: 10px; gap: 10px;
260
+ height: calc(100vh - 52px); }}
261
+ .left-col {{ {left_col_style} display:flex; flex-direction:column; gap:10px; }}
262
+ .left-col > div {{ background:#fff; border:1px solid #ddd; border-radius:6px;
263
+ overflow:hidden; }}
264
+ .umap-box {{ flex:1; min-height:280px; }}
265
+ .coeff-box {{ flex:1; min-height:220px; }}
266
+ {
267
+ "".join(
268
+ [
269
+ ".mid-col { "
270
+ + spatial_col_style
271
+ + " display:flex; flex-direction:column; gap:10px; }",
272
+ ".mid-col > div { background:#fff; border:1px solid #ddd; border-radius:6px; overflow:hidden; flex:1; min-height:300px; }",
273
+ ]
274
+ )
275
+ if has_spatial
276
+ else ""
277
+ }
278
+ .right-col {{ flex:0.7; min-width:280px; max-width:360px; display:flex;
279
+ flex-direction:column; gap:10px; }}
280
+ .controls {{ background:#fff; border:1px solid #ddd; border-radius:6px;
281
+ padding:14px; }}
282
+ .controls label {{ display:block; font-size:0.8em; font-weight:600;
283
+ color:#666; margin-bottom:3px; margin-top:10px; }}
284
+ .controls label:first-child {{ margin-top:0; }}
285
+ .controls select {{ width:100%; padding:6px 8px; border:1px solid #ccc;
286
+ border-radius:4px; font-size:0.9em; }}
287
+ .controls input[type="range"] {{ width:100%; accent-color:#1f77b4; }}
288
+ .controls .range-value {{ display:block; margin-top:2px; font-size:0.8em; color:#555; }}
289
+ .annotation-box {{ background:#fff; border:1px solid #ddd; border-radius:6px;
290
+ padding:14px; font-size:0.82em; line-height:1.5;
291
+ {"display:none;" if not has_annotations else ""} }}
292
+ .annotation-box h3 {{ font-size:0.85em; margin-bottom:6px; }}
293
+ .annotation-box .ann-label {{ font-weight:600; color:#555; }}
294
+ .gene-list {{ background:#fff; border:1px solid #ddd; border-radius:6px;
295
+ padding:10px; flex:1; overflow-y:auto; min-height:100px; }}
296
+ .gene-list h3 {{ font-size:0.85em; margin-bottom:8px; color:#444; }}
297
+ .gene-item {{ display:flex; justify-content:space-between; padding:3px 6px;
298
+ cursor:pointer; border-radius:3px; font-size:0.82em;
299
+ font-family:monospace; }}
300
+ .gene-item:hover {{ background:#e8f0fe; }}
301
+ .gene-item.selected {{ background:#c8ddf8; font-weight:bold; }}
302
+ .gene-pos {{ color: var(--pos-color); }}
303
+ .gene-neg {{ color: var(--neg-color); }}
304
+ </style>
305
+ </head>
306
+ <body>
307
+ <h1>{_escape_html(title)}</h1>
308
+ <div class="container">
309
+ <div class="left-col">
310
+ <div class="umap-box"><div id="umap-plot" style="width:100%;height:100%;"></div></div>
311
+ <div class="coeff-box"><div id="coeff-plot" style="width:100%;height:100%;"></div></div>
312
+ </div>
313
+ {"<div class='mid-col'><div>" + spatial_div + "</div></div>" if has_spatial else ""}
314
+ <div class="right-col">
315
+ <div class="controls">
316
+ <label for="color-select">Color by</label>
317
+ <select id="color-select"></select>
318
+ <label for="obs-select">Obs column</label>
319
+ <select id="obs-select"></select>
320
+ <label for="dim-select">Dimension</label>
321
+ <select id="dim-select"></select>
322
+ <label for="layer-select">Expression layer</label>
323
+ <select id="layer-select"></select>
324
+ <label for="colorscale-select">Continuous colors</label>
325
+ <select id="colorscale-select">
326
+ <option value="blueorange" selected>Blue-Orange</option>
327
+ <option value="redblue">Red-Blue</option>
328
+ <option value="viridis">Viridis</option>
329
+ </select>
330
+ <label for="point-size">Dot size</label>
331
+ <input id="point-size" type="range" min="1" max="12" step="0.5" value="3">
332
+ <span class="range-value" id="point-size-value">3.0</span>
333
+ </div>
334
+ <div class="annotation-box" id="annotation-box">
335
+ <h3>Dimension Annotation</h3>
336
+ <div id="annotation-content"></div>
337
+ </div>
338
+ <div class="gene-list" id="gene-list-box">
339
+ <h3>Top Genes</h3>
340
+ <div id="gene-list"></div>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ <script>
345
+ const DATA = {data_json};
346
+ const HAS_SPATIAL = {"true" if has_spatial else "false"};
347
+ const OBS_KEYS = Object.keys(DATA.obs);
348
+ const LAYER_KEYS = Object.keys(DATA.expression);
349
+ const HAS_OBS_OPTIONS = OBS_KEYS.length > 0;
350
+ const HAS_LAYER_OPTIONS = LAYER_KEYS.length > 0;
351
+
352
+ // --- Initialize controls ---
353
+ const colorSel = document.getElementById("color-select");
354
+ const obsSel = document.getElementById("obs-select");
355
+ const dimSel = document.getElementById("dim-select");
356
+ const layerSel = document.getElementById("layer-select");
357
+ const colorscaleSel = document.getElementById("colorscale-select");
358
+ const pointSizeInput = document.getElementById("point-size");
359
+ const pointSizeValue = document.getElementById("point-size-value");
360
+
361
+ // Color-by options: latent dims, then obs selector mode, then gene expression
362
+ DATA.dimensions.forEach(d => {{
363
+ const o = document.createElement("option");
364
+ o.value = "latent_" + d.index;
365
+ o.textContent = "Latent dim " + d.index;
366
+ colorSel.appendChild(o);
367
+ }});
368
+ if (HAS_OBS_OPTIONS) {{
369
+ const o = document.createElement("option");
370
+ o.value = "obs";
371
+ o.textContent = "Obs column";
372
+ colorSel.appendChild(o);
373
+ }}
374
+ if (HAS_LAYER_OPTIONS) {{
375
+ const o = document.createElement("option");
376
+ o.value = "gene";
377
+ o.textContent = "Gene expression";
378
+ colorSel.appendChild(o);
379
+ }}
380
+
381
+ // Obs dropdown
382
+ if (HAS_OBS_OPTIONS) {{
383
+ OBS_KEYS.forEach(k => {{
384
+ const o = document.createElement("option");
385
+ o.value = k;
386
+ o.textContent = k;
387
+ obsSel.appendChild(o);
388
+ }});
389
+ }} else {{
390
+ const o = document.createElement("option");
391
+ o.value = "";
392
+ o.textContent = "No obs columns available";
393
+ obsSel.appendChild(o);
394
+ }}
395
+
396
+ // Dimension dropdown
397
+ DATA.dimensions.forEach(d => {{
398
+ const o = document.createElement("option");
399
+ o.value = d.index;
400
+ o.textContent = "Dimension " + d.index;
401
+ dimSel.appendChild(o);
402
+ }});
403
+
404
+ // Layer dropdown
405
+ if (HAS_LAYER_OPTIONS) {{
406
+ LAYER_KEYS.forEach(k => {{
407
+ const o = document.createElement("option");
408
+ o.value = k;
409
+ o.textContent = k;
410
+ layerSel.appendChild(o);
411
+ }});
412
+ }} else {{
413
+ const o = document.createElement("option");
414
+ o.value = "";
415
+ o.textContent = "No layers available";
416
+ layerSel.appendChild(o);
417
+ }}
418
+
419
+ // --- State ---
420
+ let currentGene = null;
421
+ let currentDim = 0;
422
+ let currentPointSize = parseFloat(pointSizeInput.value);
423
+
424
+ // --- Plotting ---
425
+ const COLOR_SCALES = {{
426
+ blueorange: [
427
+ [0.0, "#2b6cb0"],
428
+ [0.5, "#f7f7f7"],
429
+ [1.0, "#f2994a"],
430
+ ],
431
+ redblue: [
432
+ [0.0, "#2166ac"],
433
+ [0.5, "#f7f7f7"],
434
+ [1.0, "#b2182b"],
435
+ ],
436
+ viridis: "Viridis",
437
+ }};
438
+ const SIGN_COLORS = {{
439
+ blueorange: {{ neg: "#2b6cb0", pos: "#f2994a" }},
440
+ redblue: {{ neg: "#2166ac", pos: "#b2182b" }},
441
+ viridis: {{ neg: "#440154", pos: "#fde725" }},
442
+ }};
443
+
444
+ function getActiveColorscale() {{
445
+ return COLOR_SCALES[colorscaleSel.value] || COLOR_SCALES.blueorange;
446
+ }}
447
+
448
+ function updateGeneSignColors() {{
449
+ const palette = SIGN_COLORS[colorscaleSel.value] || SIGN_COLORS.blueorange;
450
+ document.documentElement.style.setProperty("--pos-color", palette.pos);
451
+ document.documentElement.style.setProperty("--neg-color", palette.neg);
452
+ }}
453
+
454
+ function getColorRange(values) {{
455
+ const nums = values.filter(v => Number.isFinite(v));
456
+ if (!nums.length) return [0, 1];
457
+
458
+ const vmin = Math.min(...nums);
459
+ const vmax = Math.max(...nums);
460
+ if (colorscaleSel.value === "viridis") {{
461
+ if (vmin === vmax) return [vmin - 1, vmax + 1];
462
+ return [vmin, vmax];
463
+ }}
464
+
465
+ const absMax = Math.max(Math.abs(vmin), Math.abs(vmax), 1e-9);
466
+ return [-absMax, absMax];
467
+ }}
468
+
469
+ function makeScatter(divId, coords, color, showscale, isDiscrete, cmin, cmax) {{
470
+ const trace = {{
471
+ x: coords.map(c => c[0]),
472
+ y: coords.map(c => c[1]),
473
+ mode: "markers",
474
+ type: "scattergl",
475
+ marker: {{
476
+ size: currentPointSize,
477
+ showscale: showscale && !isDiscrete,
478
+ }},
479
+ hovertemplate: "%{{x:.2f}}, %{{y:.2f}}<br>%{{text}}<extra></extra>",
480
+ }};
481
+ if (isDiscrete) {{
482
+ trace.marker.color = color.map(v => _categoryColor(v));
483
+ trace.text = color;
484
+ }} else {{
485
+ trace.marker.color = color;
486
+ trace.marker.colorscale = getActiveColorscale();
487
+ trace.marker.cmin = cmin;
488
+ trace.marker.cmax = cmax;
489
+ trace.text = color.map(v => typeof v === "number" ? v.toFixed(3) : v);
490
+ }}
491
+ const layout = {{
492
+ margin: {{ t: 8, b: 30, l: 35, r: 10 }},
493
+ xaxis: {{ zeroline: false, showgrid: false }},
494
+ yaxis: {{
495
+ zeroline: false,
496
+ showgrid: false,
497
+ scaleanchor: divId === "tissue-plot" ? "x" : undefined,
498
+ }},
499
+ dragmode: "pan",
500
+ }};
501
+ Plotly.react(divId, [trace], layout, {{ responsive: true, scrollZoom: true }});
502
+ }}
503
+
504
+ function makeCoeffBar(dimIdx) {{
505
+ const dim = DATA.dimensions[dimIdx];
506
+ const genes = [...dim.positive_genes, ...dim.negative_genes];
507
+ const names = genes.map(g => g.name);
508
+ const weights = genes.map(g => g.weight);
509
+ const [cmin, cmax] = getColorRange(weights);
510
+
511
+ const trace = {{
512
+ y: names,
513
+ x: weights,
514
+ type: "bar",
515
+ orientation: "h",
516
+ marker: {{
517
+ color: weights,
518
+ colorscale: getActiveColorscale(),
519
+ cmin: cmin,
520
+ cmax: cmax,
521
+ }},
522
+ hovertemplate: "%{{y}}: %{{x:.4f}}<extra></extra>",
523
+ }};
524
+ const layout = {{
525
+ margin: {{ t: 8, b: 30, l: 80, r: 10 }},
526
+ xaxis: {{ title: "Encoder weight", zeroline: true }},
527
+ yaxis: {{ autorange: "reversed" }},
528
+ dragmode: false,
529
+ }};
530
+ Plotly.react("coeff-plot", [trace], layout, {{ responsive: true }});
531
+
532
+ // Click handler on bars
533
+ document.getElementById("coeff-plot").removeAllListeners?.("plotly_click");
534
+ document.getElementById("coeff-plot").on("plotly_click", function(data) {{
535
+ const geneName = data.points[0].y;
536
+ selectGene(geneName);
537
+ }});
538
+ }}
539
+
540
+ // Category colors (up to 20 distinct)
541
+ const CAT_COLORS = [
542
+ "#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd",
543
+ "#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf",
544
+ "#aec7e8","#ffbb78","#98df8a","#ff9896","#c5b0d5",
545
+ "#c49c94","#f7b6d2","#c7c7c7","#dbdb8d","#9edae5",
546
+ ];
547
+ const _catMap = {{}};
548
+ let _catIdx = 0;
549
+ function _categoryColor(val) {{
550
+ if (!(val in _catMap)) {{
551
+ _catMap[val] = CAT_COLORS[_catIdx % CAT_COLORS.length];
552
+ _catIdx++;
553
+ }}
554
+ return _catMap[val];
555
+ }}
556
+
557
+ function resetCatColors() {{
558
+ for (const k in _catMap) delete _catMap[k];
559
+ _catIdx = 0;
560
+ }}
561
+
562
+ function updateControlStates() {{
563
+ const isObsMode = colorSel.value === "obs";
564
+ const isGeneMode = colorSel.value === "gene";
565
+ obsSel.disabled = !HAS_OBS_OPTIONS || !isObsMode;
566
+ layerSel.disabled = !HAS_LAYER_OPTIONS || !isGeneMode;
567
+ }}
568
+
569
+ // --- Update functions ---
570
+ function updatePlots() {{
571
+ const colorVal = colorSel.value;
572
+ let color, isDiscrete = false;
573
+ let cmin;
574
+ let cmax;
575
+
576
+ if (colorVal.startsWith("latent_")) {{
577
+ const idx = parseInt(colorVal.split("_")[1]);
578
+ color = DATA.latent.map(row => row[idx]);
579
+ [cmin, cmax] = getColorRange(color);
580
+ }} else if (colorVal === "obs" && HAS_OBS_OPTIONS) {{
581
+ const key = obsSel.value || OBS_KEYS[0];
582
+ color = DATA.obs[key];
583
+ isDiscrete = true;
584
+ resetCatColors();
585
+ }} else if (colorVal.startsWith("obs_")) {{
586
+ const key = colorVal.substring(4);
587
+ if (key in DATA.obs) {{
588
+ color = DATA.obs[key];
589
+ isDiscrete = true;
590
+ resetCatColors();
591
+ }} else {{
592
+ color = DATA.latent.map(row => row[0]);
593
+ [cmin, cmax] = getColorRange(color);
594
+ }}
595
+ }} else if (colorVal === "gene" && currentGene) {{
596
+ const layer = layerSel.value || LAYER_KEYS[0];
597
+ const layerExpr = DATA.expression[layer];
598
+ if (layerExpr && currentGene in layerExpr) {{
599
+ color = layerExpr[currentGene];
600
+ }} else {{
601
+ color = DATA.latent.map(row => row[0]);
602
+ [cmin, cmax] = getColorRange(color);
603
+ }}
604
+ }} else {{
605
+ // Default: first latent dim
606
+ color = DATA.latent.map(row => row[0]);
607
+ [cmin, cmax] = getColorRange(color);
608
+ }}
609
+
610
+ if (!isDiscrete && (cmin === undefined || cmax === undefined)) {{
611
+ [cmin, cmax] = getColorRange(color);
612
+ }}
613
+
614
+ makeScatter("umap-plot", DATA.umap, color, true, isDiscrete, cmin, cmax);
615
+ if (HAS_SPATIAL) {{
616
+ makeScatter("tissue-plot", DATA.spatial, color, false, isDiscrete, cmin, cmax);
617
+ }}
618
+ }}
619
+
620
+ function updateDimension() {{
621
+ currentDim = parseInt(dimSel.value);
622
+ makeCoeffBar(currentDim);
623
+ updateGeneList();
624
+ updateAnnotation();
625
+
626
+ // If in gene expression mode, select first gene automatically
627
+ if (colorSel.value === "gene" && HAS_LAYER_OPTIONS) {{
628
+ const dim = DATA.dimensions[currentDim];
629
+ const firstGene = dim.positive_genes.length > 0
630
+ ? dim.positive_genes[0].name
631
+ : (dim.negative_genes.length > 0 ? dim.negative_genes[0].name : null);
632
+ if (firstGene) selectGene(firstGene);
633
+ }}
634
+ }}
635
+
636
+ function updateGeneList() {{
637
+ const dim = DATA.dimensions[currentDim];
638
+ const container = document.getElementById("gene-list");
639
+ container.innerHTML = "";
640
+
641
+ dim.positive_genes.forEach(g => {{
642
+ const div = document.createElement("div");
643
+ div.className = "gene-item" + (g.name === currentGene ? " selected" : "");
644
+ div.innerHTML = '<span class="gene-pos">+ ' + g.name + '</span><span>' + g.weight.toFixed(4) + '</span>';
645
+ div.onclick = () => selectGene(g.name);
646
+ container.appendChild(div);
647
+ }});
648
+ dim.negative_genes.forEach(g => {{
649
+ const div = document.createElement("div");
650
+ div.className = "gene-item" + (g.name === currentGene ? " selected" : "");
651
+ div.innerHTML = '<span class="gene-neg">\u2212 ' + g.name + '</span><span>' + g.weight.toFixed(4) + '</span>';
652
+ div.onclick = () => selectGene(g.name);
653
+ container.appendChild(div);
654
+ }});
655
+ }}
656
+
657
+ function updateAnnotation() {{
658
+ const dim = DATA.dimensions[currentDim];
659
+ const box = document.getElementById("annotation-box");
660
+ const content = document.getElementById("annotation-content");
661
+ if (dim.annotation) {{
662
+ box.style.display = "block";
663
+ content.innerHTML =
664
+ '<p><span class="ann-label">Positive:</span> ' + dim.annotation.positive + '</p>' +
665
+ '<p><span class="ann-label">Negative:</span> ' + dim.annotation.negative + '</p>' +
666
+ '<p><span class="ann-label">Overall:</span> ' + dim.annotation.overall + '</p>';
667
+ }} else {{
668
+ box.style.display = "none";
669
+ content.innerHTML = "";
670
+ }}
671
+ }}
672
+
673
+ function selectGene(geneName) {{
674
+ if (!HAS_LAYER_OPTIONS) return;
675
+ currentGene = geneName;
676
+ colorSel.value = "gene";
677
+ updateControlStates();
678
+ updatePlots();
679
+ updateGeneList();
680
+ }}
681
+
682
+ // --- Event listeners ---
683
+ colorSel.addEventListener("change", () => {{
684
+ updateControlStates();
685
+ updatePlots();
686
+ }});
687
+ obsSel.addEventListener("change", () => {{
688
+ if (colorSel.value === "obs") updatePlots();
689
+ }});
690
+ dimSel.addEventListener("change", updateDimension);
691
+ layerSel.addEventListener("change", () => {{
692
+ if (colorSel.value === "gene" && currentGene && HAS_LAYER_OPTIONS) updatePlots();
693
+ }});
694
+ colorscaleSel.addEventListener("change", () => {{
695
+ updateGeneSignColors();
696
+ updatePlots();
697
+ makeCoeffBar(currentDim);
698
+ }});
699
+ pointSizeInput.addEventListener("input", () => {{
700
+ currentPointSize = parseFloat(pointSizeInput.value);
701
+ pointSizeValue.textContent = currentPointSize.toFixed(1);
702
+ updatePlots();
703
+ }});
704
+
705
+ // --- Initialize ---
706
+ updateGeneSignColors();
707
+ updateControlStates();
708
+ pointSizeValue.textContent = currentPointSize.toFixed(1);
709
+ updateDimension();
710
+ updatePlots();
711
+ </script>
712
+ </body>
713
+ </html>"""
714
+ return html
715
+
716
+
717
+ def export_interactive_html(
718
+ adata,
719
+ output_path: str | Path,
720
+ *,
721
+ model_key: str = "BAE_encoder_weights",
722
+ embedding_key: str = "X_umap",
723
+ spatial_key: str | None = "spatial",
724
+ latent_key: str | None = None,
725
+ layers: list[str] | None = None,
726
+ obs_keys: list[str] | None = None,
727
+ top_k: int = 20,
728
+ max_cells: int = 50_000,
729
+ annotations_key: str | None = None,
730
+ title: str = "BAE Explorer",
731
+ seed: int = 42,
732
+ ) -> Path:
733
+ """Export interactive HTML explorer for BAE results.
734
+
735
+ Generates a self-contained HTML file with Plotly.js for interactive
736
+ exploration of UMAP embeddings, gene expression overlays, encoder
737
+ coefficient bar charts, and (optionally) spatial tissue plots and
738
+ dimension annotations.
739
+
740
+ Parameters
741
+ ----------
742
+ adata
743
+ AnnData object with fitted BAE results. Must contain
744
+ encoder weights in ``adata.varm[model_key]`` and a 2D embedding
745
+ in ``adata.obsm[embedding_key]``.
746
+ output_path
747
+ Path where the HTML file will be written.
748
+ model_key
749
+ Key in ``adata.varm`` for encoder weight matrix.
750
+ embedding_key
751
+ Key in ``adata.obsm`` for 2D embedding (e.g. UMAP). Must be
752
+ pre-computed.
753
+ spatial_key
754
+ Key in ``adata.obsm`` for spatial coordinates. Set to ``None``
755
+ to disable the spatial tissue plot. If the key is not found in
756
+ obsm, the spatial panel is silently omitted.
757
+ latent_key
758
+ Key in ``adata.obsm`` for latent representation. If ``None``,
759
+ defaults to ``"X_bae"``.
760
+ layers
761
+ Which expression layers to include. ``None`` includes
762
+ ``adata.X`` (as ``"X"``) plus all keys in ``adata.layers``.
763
+ obs_keys
764
+ Categorical obs columns to include as color-by options. ``None``
765
+ auto-detects all categorical columns.
766
+ top_k
767
+ Number of top genes per sign group (positive/negative) per
768
+ latent dimension.
769
+ max_cells
770
+ If ``adata.n_obs`` exceeds this, randomly subsample with a
771
+ warning.
772
+ annotations_key
773
+ Key in ``adata.uns`` for dimension annotations. If ``None``,
774
+ auto-detected as ``"bae_dimension_annotations"``.
775
+ title
776
+ HTML page title.
777
+ seed
778
+ Random seed for reproducible subsampling.
779
+
780
+ Returns
781
+ -------
782
+ Path
783
+ The output file path.
784
+
785
+ Raises
786
+ ------
787
+ KeyError
788
+ If ``embedding_key`` or ``model_key`` are not found.
789
+ """
790
+ output_path = Path(output_path)
791
+
792
+ # --- Validate required keys ---
793
+ if embedding_key not in adata.obsm:
794
+ raise KeyError(
795
+ f"{embedding_key!r} not found in adata.obsm. Available keys: {list(adata.obsm.keys())}"
796
+ )
797
+ if model_key not in adata.varm:
798
+ raise KeyError(
799
+ f"{model_key!r} not found in adata.varm. Available keys: {list(adata.varm.keys())}"
800
+ )
801
+
802
+ # --- Auto-detect latent_key ---
803
+ if latent_key is None:
804
+ latent_key = "X_bae"
805
+ if latent_key not in adata.obsm:
806
+ raise KeyError(
807
+ f"Auto-detected latent_key {latent_key!r} not found in adata.obsm. "
808
+ f"Available keys: {list(adata.obsm.keys())}"
809
+ )
810
+
811
+ # --- Auto-detect spatial_key ---
812
+ if spatial_key is not None and spatial_key not in adata.obsm:
813
+ spatial_key = None
814
+
815
+ # --- Auto-detect annotations_key ---
816
+ if annotations_key is None:
817
+ candidate = "bae_dimension_annotations"
818
+ if candidate in adata.uns:
819
+ annotations_key = candidate
820
+
821
+ # --- Subsample ---
822
+ adata = _subsample_adata(adata, max_cells=max_cells, seed=seed)
823
+
824
+ # --- Build payload and render ---
825
+ payload = _build_explorer_payload(
826
+ adata,
827
+ model_key=model_key,
828
+ embedding_key=embedding_key,
829
+ spatial_key=spatial_key,
830
+ latent_key=latent_key,
831
+ layers=layers,
832
+ obs_keys=obs_keys,
833
+ top_k=top_k,
834
+ annotations_key=annotations_key,
835
+ )
836
+
837
+ html = _render_html(payload, title=title)
838
+
839
+ output_path.parent.mkdir(parents=True, exist_ok=True)
840
+ output_path.write_text(html, encoding="utf-8")
841
+
842
+ return output_path