GENOMICS-CDDBRG/Genome_Visualization_Tool
0
1"""2genomics_figures.py3===================4Every figure in the suite. Kept separate from the UI so figures can be5regression-tested without a browser, and separate from the maths so a plotting6change can never alter a reported number.7 8House rules for all panels:9 * 600 dpi raster / true-vector PDF-EPS with Type-42 fonts (journal default).10 * Colourblind-safe palette available and used by default.11 * Every axis carries units. Every heuristic panel says so in the panel itself,12 not only in a caption that may be dropped in review.13 * Empty input produces a labelled blank panel, never a misleading empty axis.14"""15 16from __future__ import annotations17 18from typing import Dict, List, Optional, Sequence, Tuple19 20import numpy as np21import pandas as pd22 23import matplotlib24matplotlib.use("Agg")25import matplotlib.pyplot as plt26from matplotlib.patches import Patch27 28import genomics_core as gc29 30__all__ = [31 "fig_composition_overview", "fig_skew_and_ori", "fig_rscu_heatmap",32 "fig_enc_plot", "fig_neutrality_plot", "fig_pr2_plot", "fig_codon_bar",33 "fig_amino_acid_usage", "fig_dinucleotide_oe", "fig_assembly_curve",34 "fig_distance_heatmap", "fig_pangenome_curves", "fig_pangenome_pie",35 "fig_presence_absence", "fig_feature_map", "fig_length_hist",36]37 38_ANNOT_KW = dict(fontsize=7.5, color="#8a4b08", style="italic")39 40 41def _finish(fig, tight=True):42 if tight:43 fig.tight_layout()44 return fig45 46 47def _blank(title: str, msg: str, figsize=(7, 4)):48 fig, ax = plt.subplots(figsize=figsize)49 gc.blank_panel(ax, msg)50 ax.set_title(title)51 return _finish(fig)52 53 54# ----------------------------------------------------------------------55# Composition56# ----------------------------------------------------------------------57 58def fig_composition_overview(records: pd.DataFrame, theme: str = "Ocean"):59 """4-panel: base composition, GC per record, length distribution, entropy."""60 if records is None or records.empty:61 return _blank("Composition overview", "No records to display")62 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])63 fig, axes = plt.subplots(2, 2, figsize=(11, 7.5))64 65 ax = axes[0, 0]66 bases = ["A", "T", "G", "C", "N"]67 vals = [records[f"count_{b}"].sum() for b in bases]68 total = sum(vals) or 169 cols = ["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#999999"]70 bars = ax.bar(bases, [100.0 * v / total for v in vals], color=cols,71 edgecolor="black", linewidth=0.5)72 for b, v in zip(bars, vals):73 ax.text(b.get_x() + b.get_width() / 2, b.get_height(),74 f"{100.0*v/total:.2f}%", ha="center", va="bottom", fontsize=8)75 ax.set_ylabel("Composition (% of all bases)")76 ax.set_title("Base composition")77 78 ax = axes[0, 1]79 ax.hist(records["gc_percent"], bins=min(30, max(5, len(records))),80 color=th["primary"], edgecolor="black", linewidth=0.5, alpha=0.85)81 mean_gc = float(records["gc_percent"].mean())82 ax.axvline(mean_gc, color=th["secondary"], ls="--", lw=1.5,83 label=f"mean = {mean_gc:.2f}%")84 ax.set_xlabel("GC content (%)")85 ax.set_ylabel("Number of records")86 ax.set_title("GC distribution across records")87 ax.legend()88 89 ax = axes[1, 0]90 L = np.sort(records["length_bp"].to_numpy())[::-1]91 ax.plot(np.arange(1, len(L) + 1), L, marker="o" if len(L) < 40 else None,92 color=th["primary"], lw=1.6, ms=3)93 ax.set_yscale("log" if L.max() / max(L.min(), 1) > 100 else "linear")94 ax.set_xlabel("Record rank (longest first)")95 ax.set_ylabel("Length (bp)")96 ax.set_title("Record length profile")97 98 ax = axes[1, 1]99 if "entropy_bits" in records:100 ax.scatter(records["gc_percent"], records["entropy_bits"], s=22,101 color=th["primary"], edgecolor="black", linewidth=0.3, alpha=0.85)102 ax.axhline(2.0, color="#888888", ls=":", lw=1.2, label="maximum (2 bits)")103 ax.set_xlabel("GC content (%)")104 ax.set_ylabel("Shannon entropy (bits)")105 ax.set_title("Sequence complexity")106 ax.legend()107 else:108 gc.blank_panel(ax, "Entropy not computed")109 return _finish(fig)110 111 112def fig_skew_and_ori(window_df: pd.DataFrame, ori_ter: Dict, record_name: str = "",113 theme: str = "Ocean"):114 """GC/AT skew and cumulative skew with predicted ori/ter marked."""115 if window_df is None or window_df.empty:116 return _blank("Strand skew", "No windows to display")117 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])118 fig, axes = plt.subplots(2, 1, figsize=(11, 6.4), sharex=True)119 x = window_df["midpoint"] / 1000.0120 121 ax = axes[0]122 ax.plot(x, window_df["GC_skew"], lw=0.9, color=th["primary"], label="GC skew")123 ax.plot(x, window_df["AT_skew"], lw=0.9, color=th["secondary"], alpha=0.75, label="AT skew")124 ax.axhline(0, color="#666666", lw=0.8)125 ax.set_ylabel("Skew per window")126 ax.set_title(f"Strand asymmetry{(' - ' + record_name) if record_name else ''}")127 ax.legend(ncol=2, loc="upper right")128 129 ax = axes[1]130 ax.plot(x, window_df["cumulative_GC_skew"], lw=1.8, color=th["primary"],131 label="cumulative GC skew")132 conf = str(ori_ter.get("ori_ter_confidence", ""))133 trustworthy = conf.startswith("clear")134 for key, colour, label in [("predicted_origin_bp", "#1a9850", "predicted origin"),135 ("predicted_terminus_bp", "#d73027", "predicted terminus")]:136 v = ori_ter.get(key)137 if v is not None:138 ax.axvline(v / 1000.0, color=colour, ls="--" if trustworthy else ":",139 lw=1.6, label=f"{label} ~{v:,} bp")140 ax.set_xlabel("Genome position (kb)")141 ax.set_ylabel("Cumulative GC skew")142 ax.legend(loc="best")143 # The honesty annotation lives inside the figure, so it survives being144 # lifted into a slide deck without its caption.145 ax.annotate(146 "Ori/ter are PREDICTED from cumulative GC skew (Grigoriev 1998), not measured."147 + ("" if trustworthy else " Signal is weak here - treat as unreliable."),148 xy=(0.005, -0.30), xycoords="axes fraction", **_ANNOT_KW)149 return _finish(fig)150 151 152def fig_dinucleotide_oe(df: pd.DataFrame, theme: str = "Ocean"):153 if df is None or df.empty:154 return _blank("Dinucleotide relative abundance", "No data")155 fig, ax = plt.subplots(figsize=(9, 4.2))156 colours = ["#c0392b" if m else "#4C72B0" for m in df["marked_deviation"]]157 ax.bar(df["dinucleotide"], df["rho"], color=colours, edgecolor="black", linewidth=0.5)158 ax.axhline(1.0, color="#333333", lw=1.0)159 ax.axhspan(0.78, 1.23, color="#999999", alpha=0.14,160 label="Karlin |rho-1| <= 0.23 (unremarkable)")161 ax.set_ylabel(r"$\rho$ = observed / expected")162 ax.set_xlabel("Dinucleotide")163 ax.set_title("Dinucleotide relative abundance (genomic signature)")164 ax.legend(loc="upper right")165 ax.annotate("Red bars mark |rho - 1| > 0.23 (Karlin & Burge 1995).",166 xy=(0.005, -0.22), xycoords="axes fraction", **_ANNOT_KW)167 return _finish(fig)168 169 170# ----------------------------------------------------------------------171# Codon usage172# ----------------------------------------------------------------------173 174def fig_rscu_heatmap(usage: pd.DataFrame):175 if usage is None or usage.empty:176 return _blank("RSCU", "No codon usage")177 d = usage[(usage["amino_acid_1"] != "*") & usage["RSCU"].notna()].copy()178 if d.empty:179 return _blank("RSCU", "No sense codons")180 aas = sorted(d["amino_acid_1"].unique())181 maxfam = int(d.groupby("amino_acid_1").size().max())182 M = np.full((len(aas), maxfam), np.nan)183 labels = np.full((len(aas), maxfam), "", dtype=object)184 for i, aa in enumerate(aas):185 sub = d[d["amino_acid_1"] == aa].sort_values("codon").reset_index(drop=True)186 for j, r in sub.iterrows():187 M[i, j] = r["RSCU"]188 labels[i, j] = f"{r['codon']}\n{r['RSCU']:.2f}"189 fig, ax = plt.subplots(figsize=(1.15 * maxfam + 3.2, 0.46 * len(aas) + 2.2))190 im = ax.imshow(M, cmap=gc.RSCU_CMAP, aspect="auto", vmin=0,191 vmax=float(np.nanmax(M)) if np.isfinite(M).any() else 1)192 for i in range(len(aas)):193 for j in range(maxfam):194 if labels[i, j]:195 v = M[i, j]196 tc = "white" if v > np.nanmax(M) * 0.6 else "#1a1a1a"197 ax.text(j, i, labels[i, j], ha="center", va="center", fontsize=6.8, color=tc)198 ax.set_yticks(range(len(aas)))199 ax.set_yticklabels([f"{a} ({gc.AA_THREE_LETTER.get(a,a)})" for a in aas])200 ax.set_xticks(range(maxfam))201 ax.set_xticklabels([f"codon {i+1}" for i in range(maxfam)])202 ax.set_title("Relative synonymous codon usage (RSCU)")203 ax.grid(False)204 cb = fig.colorbar(im, ax=ax, fraction=0.025, pad=0.02)205 cb.set_label("RSCU (1.0 = no bias)")206 return _finish(fig)207 208 209def fig_codon_bar(usage: pd.DataFrame, theme: str = "Ocean"):210 if usage is None or usage.empty:211 return _blank("Codon usage", "No data")212 d = usage[usage["amino_acid_1"] != "*"].sort_values(["amino_acid_1", "codon"])213 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])214 fig, ax = plt.subplots(figsize=(13, 4.4))215 aas = list(dict.fromkeys(d["amino_acid_1"]))216 cmap = plt.get_cmap("tab20")217 colour = {aa: cmap(i % 20) for i, aa in enumerate(aas)}218 ax.bar(range(len(d)), d["frequency_per_1000"],219 color=[colour[a] for a in d["amino_acid_1"]], edgecolor="black", linewidth=0.3)220 ax.set_xticks(range(len(d)))221 ax.set_xticklabels(d["codon"], rotation=90, fontsize=5.6, family="monospace")222 ax.set_ylabel("Frequency (per 1000 codons)")223 ax.set_title("Genome-wide codon usage")224 ax.margins(x=0.005)225 return _finish(fig)226 227 228def fig_enc_plot(per_gene: pd.DataFrame, theme: str = "Ocean"):229 """Wright's ENC vs GC3s with the mutation-drift expectation curve."""230 if per_gene is None or per_gene.empty:231 return _blank("ENC plot", "No genes passed the length filter")232 d = per_gene.dropna(subset=["ENC", "GC3s"])233 if d.empty:234 return _blank("ENC plot", "No genes with computable ENC")235 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])236 fig, ax = plt.subplots(figsize=(7.2, 5.6))237 x = d["GC3s"].to_numpy() / 100.0238 ax.scatter(d["GC3s"], d["ENC"], s=16, alpha=0.6, color=th["primary"],239 edgecolor="none", label=f"genes (n = {len(d):,})")240 xs = np.linspace(0.005, 0.995, 400)241 ax.plot(xs * 100, gc.wright_expected_enc(xs), color="#c0392b", lw=2.0,242 label="Wright expectation (mutation drift only)")243 exp = gc.wright_expected_enc(x)244 below = int(np.sum(d["ENC"].to_numpy() < exp - 0.10 * exp))245 ax.set_xlabel("GC3s (%) - GC at synonymous third positions")246 ax.set_ylabel("ENC (effective number of codons)")247 ax.set_xlim(0, 100)248 ax.set_ylim(18, 64)249 ax.set_title("ENC plot (Wright 1990)")250 ax.legend(loc="lower center")251 ax.annotate(252 f"{below:,}/{len(d):,} genes lie >10% below the curve, i.e. show codon bias "253 f"beyond what GC mutation pressure alone explains.",254 xy=(0.005, -0.155), xycoords="axes fraction", **_ANNOT_KW)255 return _finish(fig)256 257 258def fig_neutrality_plot(per_gene: pd.DataFrame, reg: Dict, theme: str = "Ocean"):259 if per_gene is None or per_gene.empty:260 return _blank("Neutrality plot", "No genes")261 d = per_gene.dropna(subset=["GC3", "GC12"])262 if len(d) < 3:263 return _blank("Neutrality plot", "Need at least 3 genes")264 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])265 fig, ax = plt.subplots(figsize=(7.2, 5.6))266 ax.scatter(d["GC3"], d["GC12"], s=16, alpha=0.6, color=th["primary"], edgecolor="none")267 xs = np.linspace(d["GC3"].min(), d["GC3"].max(), 100)268 if np.isfinite(reg.get("slope", np.nan)):269 ax.plot(xs, reg["slope"] * xs + reg["intercept"], color="#c0392b", lw=2.0,270 label=(f"GC12 = {reg['slope']:.3f}·GC3 + {reg['intercept']:.2f}\n"271 f"r² = {reg['r_squared']:.3f}, n = {reg['n_genes']:,}"))272 ax.plot(xs, xs, color="#666666", ls="--", lw=1.2, label="slope = 1 (pure mutation pressure)")273 ax.set_xlabel("GC3 (%)")274 ax.set_ylabel("GC12 (%)")275 ax.set_title("Neutrality plot (Sueoka 1988)")276 ax.legend(loc="best")277 s = reg.get("slope", np.nan)278 if np.isfinite(s):279 interp = ("mutation pressure dominates" if s > 0.5 else280 "selection dominates codon usage" if s < 0.2 else281 "mixed mutation and selection")282 ax.annotate(f"Slope {s:.3f}: {interp}. Slope approximates the neutral "283 f"(mutation-driven) fraction of GC variation.",284 xy=(0.005, -0.155), xycoords="axes fraction", **_ANNOT_KW)285 return _finish(fig)286 287 288def fig_pr2_plot(per_gene: pd.DataFrame, theme: str = "Ocean"):289 if per_gene is None or per_gene.empty:290 return _blank("PR2 plot", "No genes")291 d = per_gene.dropna(subset=["PR2_G3_over_GC3", "PR2_A3_over_AT3"])292 if d.empty:293 return _blank("PR2 plot", "No four-fold degenerate codons found")294 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])295 fig, ax = plt.subplots(figsize=(6.4, 6.2))296 ax.scatter(d["PR2_G3_over_GC3"], d["PR2_A3_over_AT3"], s=16, alpha=0.6,297 color=th["primary"], edgecolor="none", label=f"genes (n = {len(d):,})")298 ax.axhline(0.5, color="#666666", lw=1.0)299 ax.axvline(0.5, color="#666666", lw=1.0)300 ax.plot(0.5, 0.5, marker="*", ms=17, color="#c0392b", ls="none",301 label="PR2 centre (no bias)")302 mx, my = float(d["PR2_G3_over_GC3"].mean()), float(d["PR2_A3_over_AT3"].mean())303 ax.plot(mx, my, marker="D", ms=8, color="#1a9850", ls="none",304 label=f"observed mean ({mx:.3f}, {my:.3f})")305 ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.set_aspect("equal")306 ax.set_xlabel("G3 / (G3 + C3)")307 ax.set_ylabel("A3 / (A3 + T3)")308 ax.set_title("PR2 bias plot, four-fold degenerate sites (Sueoka 1995)")309 ax.legend(loc="upper right", fontsize=7.5)310 ax.annotate("Displacement from the centre indicates strand-asymmetric mutation "311 "or selection at synonymous sites.",312 xy=(0.005, -0.13), xycoords="axes fraction", **_ANNOT_KW)313 return _finish(fig)314 315 316def fig_amino_acid_usage(df: pd.DataFrame, theme: str = "Ocean"):317 if df is None or df.empty:318 return _blank("Amino acid usage", "No data")319 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])320 d = df.sort_values("frequency_percent", ascending=False)321 fig, ax = plt.subplots(figsize=(9, 4.2))322 ax.bar(d["amino_acid_3"], d["frequency_percent"], color=th["primary"],323 edgecolor="black", linewidth=0.4)324 ax.set_ylabel("Frequency (% of residues)")325 ax.set_xlabel("Amino acid")326 ax.set_title("Genome-wide amino acid usage")327 plt.setp(ax.get_xticklabels(), rotation=45, ha="right")328 return _finish(fig)329 330 331# ----------------------------------------------------------------------332# Assembly / features333# ----------------------------------------------------------------------334 335def fig_assembly_curve(lengths: Sequence[int], stats: Dict, theme: str = "Ocean"):336 """Cumulative assembly (Nx) curve with N50/N75/N90 marked."""337 if not len(lengths):338 return _blank("Assembly curve", "No records")339 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])340 L = np.sort(np.asarray(lengths, dtype=float))[::-1]341 cum = np.cumsum(L)342 pct = 100.0 * cum / cum[-1]343 fig, ax = plt.subplots(figsize=(7.6, 5.0))344 ax.step(pct, L, where="post", lw=2.0, color=th["primary"])345 for key, colour in [("N50_bp", "#1a9850"), ("N75_bp", "#E69F00"), ("N90_bp", "#d73027")]:346 v = stats.get(key)347 if v:348 ax.axhline(v, color=colour, ls="--", lw=1.3, label=f"{key.replace('_bp','')} = {v:,} bp")349 ax.set_yscale("log" if L.max() / max(L.min(), 1) > 100 else "linear")350 ax.set_xlabel("Cumulative assembly length (%)")351 ax.set_ylabel("Record length (bp)")352 ax.set_title("Assembly contiguity (Nx curve)")353 ax.legend(loc="upper right")354 if stats.get("auN"):355 ax.annotate(f"auN (area under Nx) = {stats['auN']:,.0f} bp - a single-value "356 f"contiguity summary robust to the N50 threshold artefact.",357 xy=(0.005, -0.145), xycoords="axes fraction", **_ANNOT_KW)358 return _finish(fig)359 360 361def fig_length_hist(df: pd.DataFrame, col: str, title: str, xlabel: str, theme: str = "Ocean"):362 if df is None or df.empty or col not in df:363 return _blank(title, "No data")364 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])365 v = pd.to_numeric(df[col], errors="coerce").dropna()366 if v.empty:367 return _blank(title, "No numeric data")368 fig, ax = plt.subplots(figsize=(7.6, 4.4))369 ax.hist(v, bins=min(60, max(6, int(np.sqrt(len(v))))), color=th["primary"],370 edgecolor="black", linewidth=0.4, alpha=0.88)371 ax.axvline(v.median(), color=th["secondary"], ls="--", lw=1.5,372 label=f"median = {v.median():,.0f}")373 ax.set_xlabel(xlabel); ax.set_ylabel("Count"); ax.set_title(title); ax.legend()374 return _finish(fig)375 376 377def fig_feature_map(features: pd.DataFrame, seq_len: int, record_name: str = "",378 palette: str = "Colourblind safe (Okabe-Ito)", max_features: int = 4000):379 """Linear feature map, strand-separated."""380 if features is None or features.empty:381 return _blank("Feature map", "No features to display", figsize=(12, 3))382 pal = gc.PALETTES.get(palette, gc.PALETTES["Colourblind safe (Okabe-Ito)"])383 d = features.copy()384 if len(d) > max_features:385 d = d.reindex(d["end"].astype(int).sub(d["start"].astype(int)).abs()386 .sort_values(ascending=False).index[:max_features])387 fig, ax = plt.subplots(figsize=(13, 3.4))388 ax.axhline(0, color="#999999", lw=1.0, zorder=1)389 seen = {}390 for _, r in d.iterrows():391 t = str(r.get("type", "feature"))392 col = pal.get(t, pal["default"])393 seen[t] = col394 s, e = int(r["start"]), int(r["end"])395 strand = int(r.get("strand", 0) or 0)396 y = 0.16 if strand >= 0 else -0.16 - 0.30397 ax.add_patch(plt.Rectangle((s, y), max(e - s, max(1, seq_len // 4000)), 0.30,398 facecolor=col, edgecolor="none", alpha=0.9, zorder=2))399 ax.set_xlim(0, seq_len)400 ax.set_ylim(-0.75, 0.65)401 ax.set_yticks([0.31, -0.31])402 ax.set_yticklabels(["+ strand", "− strand"])403 ax.set_xlabel("Position (bp)")404 ax.set_title(f"Feature map{(' - ' + record_name) if record_name else ''}"405 f" ({len(d):,} features shown)")406 ax.grid(axis="x")407 if seen:408 ax.legend(handles=[Patch(facecolor=c, label=t) for t, c in sorted(seen.items())],409 loc="upper center", bbox_to_anchor=(0.5, -0.30), ncol=min(8, len(seen)),410 fontsize=7)411 return _finish(fig)412 413 414# ----------------------------------------------------------------------415# Comparative / pan-genome416# ----------------------------------------------------------------------417 418def fig_distance_heatmap(mat: pd.DataFrame, title: str, cbar_label: str,419 cmap: str = "viridis", fmt: str = "{:.3f}"):420 if mat is None or mat.empty:421 return _blank(title, "No matrix")422 n = len(mat)423 fig, ax = plt.subplots(figsize=(max(5.0, 0.68 * n + 3.2), max(4.2, 0.58 * n + 2.6)))424 im = ax.imshow(mat.to_numpy(dtype=float), cmap=cmap, aspect="auto")425 ax.set_xticks(range(n)); ax.set_yticks(range(n))426 ax.set_xticklabels(mat.columns, rotation=45, ha="right", fontsize=7.5)427 ax.set_yticklabels(mat.index, fontsize=7.5)428 if n <= 14:429 A = mat.to_numpy(dtype=float)430 lo, hi = np.nanmin(A), np.nanmax(A)431 for i in range(n):432 for j in range(n):433 v = A[i, j]434 rel = (v - lo) / (hi - lo) if hi > lo else 0.5435 ax.text(j, i, fmt.format(v), ha="center", va="center", fontsize=6.6,436 color="white" if rel > 0.55 else "#1a1a1a")437 ax.set_title(title)438 ax.grid(False)439 cb = fig.colorbar(im, ax=ax, fraction=0.035, pad=0.02)440 cb.set_label(cbar_label)441 return _finish(fig)442 443 444def fig_pangenome_curves(acc: pd.DataFrame, heaps: Dict, theme: str = "Ocean"):445 """Pan/core accumulation curves with the Heaps' law fit overlaid."""446 if acc is None or acc.empty:447 return _blank("Pan-genome accumulation", "No data")448 th = gc.THEMES.get(theme, gc.THEMES["Ocean"])449 fig, ax = plt.subplots(figsize=(7.8, 5.2))450 x = acc["n_genomes"]451 ax.errorbar(x, acc["pan_mean"], yerr=acc["pan_sd"], marker="o", ms=4.5, lw=1.8,452 capsize=3, color=th["primary"], label="pan-genome")453 ax.errorbar(x, acc["core_mean"], yerr=acc["core_sd"], marker="s", ms=4.5, lw=1.8,454 capsize=3, color=th["secondary"], label="core genome")455 g = heaps.get("gamma", np.nan)456 if np.isfinite(g) and np.isfinite(heaps.get("kappa", np.nan)):457 xs = np.linspace(1, x.max(), 200)458 ax.plot(xs, heaps["kappa"] * xs ** g, ls="--", lw=1.6, color="#c0392b",459 label=f"Heaps fit: n = {heaps['kappa']:.0f}·N^{g:.3f}")460 ax.set_xlabel("Number of genomes sampled")461 ax.set_ylabel("Number of gene clusters")462 ax.set_title("Pan-genome and core-genome accumulation")463 ax.legend(loc="center right")464 note = heaps.get("interpretation", "")465 if note:466 ax.annotate(f"Heaps' law (Tettelin 2008): {note}",467 xy=(0.005, -0.145), xycoords="axes fraction", **_ANNOT_KW)468 return _finish(fig)469 470 471def fig_pangenome_pie(counts: Dict[str, int], theme: str = "Ocean"):472 labels = [k for k, v in counts.items() if v > 0]473 vals = [counts[k] for k in labels]474 if not vals:475 return _blank("Pan-genome partition", "No clusters")476 fig, ax = plt.subplots(figsize=(6.6, 5.2))477 cols = {"Core": "#2166ac", "Soft-core": "#67a9cf", "Shell": "#fdae61", "Cloud": "#d73027"}478 total = sum(vals)479 w, *_ = ax.pie(vals, labels=None, colors=[cols.get(l, "#999999") for l in labels],480 autopct=lambda p: f"{p:.1f}%\n({int(round(p*total/100)):,})",481 textprops=dict(fontsize=8, color="white"), startangle=90,482 wedgeprops=dict(edgecolor="white", linewidth=1.6))483 ax.legend(w, [f"{l} (n = {counts[l]:,})" for l in labels],484 loc="center left", bbox_to_anchor=(1.0, 0.5))485 ax.set_title(f"Pan-genome partition (total {total:,} clusters)")486 return _finish(fig)487 488 489def fig_presence_absence(presence: np.ndarray, genome_names: List[str],490 max_clusters: int = 3000):491 if presence is None or presence.size == 0:492 return _blank("Presence/absence", "No matrix")493 M = presence494 if M.shape[0] > max_clusters:495 # Order by prevalence, then evenly subsample so the core/shell/cloud496 # structure is preserved rather than truncated to the core block.497 order = np.argsort(-M.sum(axis=1))498 M = M[order][np.linspace(0, len(order) - 1, max_clusters).astype(int)]499 else:500 M = M[np.argsort(-M.sum(axis=1))]501 fig, ax = plt.subplots(figsize=(max(5.5, 0.42 * len(genome_names) + 3.0), 6.2))502 ax.imshow(M, cmap="Blues", aspect="auto", interpolation="nearest", vmin=0, vmax=1)503 ax.set_xticks(range(len(genome_names)))504 ax.set_xticklabels(genome_names, rotation=45, ha="right", fontsize=7.5)505 ax.set_ylabel(f"Gene clusters (n = {presence.shape[0]:,}, sorted by prevalence)")506 ax.set_title("Gene presence / absence")507 ax.grid(False)508 if presence.shape[0] > max_clusters:509 ax.annotate(f"Showing {max_clusters:,} of {presence.shape[0]:,} clusters "510 f"(evenly sampled across prevalence). Full matrix is in the CSV export.",511 xy=(0.005, -0.12), xycoords="axes fraction", **_ANNOT_KW)512 return _finish(fig)513 