| """ |
| Analysis dashboard. |
| |
| Runs SequenceAnalyzer against the active sequence and displays: |
| - GC content sliding window (Plotly) |
| - CAI score + codon usage heatmap |
| - Homopolymer summary |
| - Restriction site hits |
| - Kozak context evaluation |
| - ViennaRNA secondary structure (MFE) |
| """ |
| from __future__ import annotations |
|
|
| from typing import TYPE_CHECKING, Optional |
|
|
| import panel as pn |
| import param |
| import plotly.graph_objects as go |
|
|
| from core.analysis.analyzer import SequenceAnalyzer, AnalysisReport |
|
|
| if TYPE_CHECKING: |
| from ui.state import AppState |
|
|
|
|
| _analyzer = SequenceAnalyzer() |
|
|
|
|
| def _gc_plot(report: AnalysisReport) -> pn.pane.Plotly: |
| """Interactive GC% sliding window Plotly chart.""" |
| pos = report.gc_sliding_positions |
| vals = report.gc_sliding_values |
|
|
| if pos is None or len(pos) == 0: |
| return pn.pane.HTML('<div style="color:#64748B;">Insufficient sequence length for GC plot.</div>') |
|
|
| fig = go.Figure() |
| fig.add_trace(go.Scatter( |
| x=pos, |
| y=vals, |
| mode="lines", |
| line={"color": "#0F766E", "width": 1.5}, |
| name="GC%", |
| hovertemplate="Position: %{x}<br>GC: %{y:.1f}%<extra></extra>", |
| )) |
| fig.add_hline(y=50, line_dash="dash", line_color="#DC2626", opacity=0.5, |
| annotation_text="50%", annotation_position="bottom right") |
| fig.update_layout( |
| title=dict(text="GC Content (sliding window)", font=dict(size=13)), |
| xaxis_title="Position (nt)", |
| yaxis_title="GC (%)", |
| yaxis=dict(range=[0, 100]), |
| height=260, |
| margin=dict(l=50, r=20, t=40, b=40), |
| plot_bgcolor="#F8FAFC", |
| paper_bgcolor="white", |
| ) |
| return pn.pane.Plotly(fig, sizing_mode="stretch_width") |
|
|
|
|
| def _cai_card(report: AnalysisReport) -> pn.pane.HTML: |
| cai = report.cai |
| if cai is None: |
| return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No CDS available for CAI.</div>') |
| pct = int(cai * 100) |
| color = "#059669" if cai >= 0.7 else "#D97706" if cai >= 0.4 else "#DC2626" |
| bar = f'<div style="height:8px;background:#E2E8F0;border-radius:4px;">' \ |
| f'<div style="height:8px;width:{pct}%;background:{color};border-radius:4px;"></div></div>' |
| org = report.cai_organism or "unknown" |
| return pn.pane.HTML(f""" |
| <div style="margin-bottom:8px;"> |
| <span style="font-size:22px;font-weight:800;color:{color};">{cai:.3f}</span> |
| <span style="font-size:11px;color:#64748B;margin-left:6px;"> |
| CAI ({org})</span> |
| </div> |
| {bar} |
| <div style="font-size:10px;color:#94A3B8;margin-top:3px;"> |
| 0.7+ = high adaptation · 0.4–0.7 = moderate · <0.4 = poor</div> |
| """) |
|
|
|
|
| def _validation_card(report: AnalysisReport) -> pn.pane.HTML: |
| checks = [ |
| ("Start codon (ATG)", report.has_start_codon), |
| ("Stop codon present", report.has_stop_codon), |
| ("In-frame CDS (÷3)", report.in_frame), |
| ] |
| rows = "" |
| for label, ok in checks: |
| if ok is None: |
| icon, color = "—", "#94A3B8" |
| elif ok: |
| icon, color = "Pass", "#059669" |
| else: |
| icon, color = "Fail", "#DC2626" |
| rows += ( |
| f'<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">' |
| f'<span style="font-weight:700;color:{color};width:32px;font-size:11px;">{icon}</span>' |
| f'<span style="font-size:12px;">{label}</span></div>' |
| ) |
| return pn.pane.HTML(rows) |
|
|
|
|
| def _homopolymer_card(report: AnalysisReport) -> pn.pane.HTML: |
| if report.homopolymer_count == 0: |
| return pn.pane.HTML( |
| '<div style="color:#059669;font-size:12px;">No homopolymers detected ' |
| f'(min run ≥ 5 nt)</div>' |
| ) |
| runs = report.homopolymer_runs |
| rows = "".join( |
| f'<tr><td style="font-family:monospace;font-size:11px;padding:2px 8px;">' |
| f'{r.nucleotide}×{r.length}</td>' |
| f'<td style="font-size:11px;padding:2px 8px;color:#64748B;">pos {r.start}</td></tr>' |
| for r in sorted(runs, key=lambda r: -r.length)[:10] |
| ) |
| return pn.pane.HTML( |
| f'<div style="color:#DC2626;font-size:12px;margin-bottom:6px;">' |
| f'{report.homopolymer_count} homopolymer run(s) — ' |
| f'longest: {report.longest_homopolymer} nt</div>' |
| f'<table style="border-collapse:collapse;">{rows}</table>' |
| ) |
|
|
|
|
| def _restriction_card(report: AnalysisReport) -> pn.pane.HTML: |
| present = report.restriction_enzymes_present |
| if not present: |
| return pn.pane.HTML( |
| '<div style="color:#059669;font-size:12px;">No common restriction sites found.</div>' |
| ) |
| chips = " ".join( |
| f'<span style="background:#DC2626;color:white;border-radius:3px;' |
| f'padding:2px 6px;font-size:10px;margin:2px;">{e}</span>' |
| for e in sorted(present) |
| ) |
| return pn.pane.HTML(f'<div>Sites present: {chips}</div>') |
|
|
|
|
| def _kozak_card(report: AnalysisReport) -> pn.pane.HTML: |
| kz = report.kozak |
| if kz is None: |
| return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No Kozak context available.</div>') |
| color = {"strong": "#059669", "adequate": "#D97706", "weak": "#DC2626"}.get(kz.strength, "#94A3B8") |
| r3_ok = "Yes" if kz.has_optimal_r3 else "No" |
| return pn.pane.HTML(f""" |
| <div> |
| <span style="font-weight:700;color:{color};">{kz.strength.upper()}</span> |
| <span style="font-size:11px;color:#64748B;margin-left:6px;"> |
| score {kz.score:.2f}</span> |
| </div> |
| <div style="font-family:monospace;font-size:12px;margin:4px 0; |
| background:#F1F5F9;padding:4px 6px;border-radius:3px;"> |
| {kz.context} |
| </div> |
| <div style="font-size:10px;color:#64748B;"> |
| -3 purine (R): {r3_ok} · ATG pos: {kz.atg_position} |
| </div> |
| """) |
|
|
|
|
| def _structure_card(report: AnalysisReport) -> pn.pane.HTML: |
| s = report.structure |
| if s is None or s.is_stub: |
| return pn.pane.HTML( |
| '<div style="color:#64748B;font-size:12px;">' |
| 'ViennaRNA not installed. Install with: conda install -c bioconda viennarna</div>' |
| ) |
| color = "#059669" if s.mfe > -50 else "#D97706" if s.mfe > -200 else "#DC2626" |
| return pn.pane.HTML(f""" |
| <div> |
| <span style="font-size:18px;font-weight:700;color:{color};"> |
| {s.mfe:.1f} kcal/mol</span> |
| <span style="font-size:11px;color:#64748B;margin-left:6px;">MFE</span> |
| </div> |
| <div style="font-family:monospace;font-size:10px; |
| word-break:break-all;color:#0F172A; |
| background:#F1F5F9;padding:6px;border-radius:3px; |
| max-height:80px;overflow-y:auto;margin-top:4px;"> |
| {s.structure[:300]}{'…' if len(s.structure) > 300 else ''} |
| </div> |
| """) |
|
|
|
|
| def _uridine_card(report: AnalysisReport) -> pn.pane.HTML: |
| u = report.uridine |
| if u is None: |
| return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No uridine data.</div>') |
| color = "#059669" if u.u_percent < 35 else "#D97706" if u.u_percent < 45 else "#DC2626" |
| stretches = len(u.high_u_stretches) |
| return pn.pane.HTML(f""" |
| <div> |
| <span style="font-size:18px;font-weight:700;color:{color};">{u.u_percent:.1f}%</span> |
| <span style="font-size:11px;color:#64748B;margin-left:6px;">uridine</span> |
| </div> |
| <div style="font-size:11px;color:#64748B;margin-top:4px;"> |
| {stretches} high-U stretch(es) · U/A ratio {u.ua_ratio:.2f}</div> |
| <div style="font-size:10px;color:#94A3B8;margin-top:3px;"> |
| High U is immunostimulatory — modified nucleotides mitigate it.</div> |
| """) |
|
|
|
|
| def _motif_card(report: AnalysisReport) -> pn.pane.HTML: |
| hits = report.motif_hits |
| if not hits: |
| return pn.pane.HTML( |
| '<div style="color:#059669;font-size:12px;">No liability motifs detected.</div>' |
| ) |
| sev_color = {"critical": "#DC2626", "warning": "#D97706", "info": "#64748B"} |
| rows = "" |
| for h in hits[:12]: |
| c = sev_color.get(h.severity, "#64748B") |
| rows += ( |
| f'<div style="display:flex;gap:8px;align-items:baseline;margin-bottom:3px;">' |
| f'<span style="background:{c};color:white;border-radius:3px;padding:1px 5px;' |
| f'font-size:9px;text-transform:uppercase;">{h.severity}</span>' |
| f'<span style="font-size:12px;">{h.label}</span>' |
| f'<span style="font-size:10px;color:#94A3B8;font-family:monospace;">' |
| f'{h.region}:{h.start}</span></div>' |
| ) |
| more = f'<div style="font-size:10px;color:#94A3B8;">+{len(hits)-12} more</div>' if len(hits) > 12 else "" |
| return pn.pane.HTML(rows + more) |
|
|
|
|
| _VERDICT_STYLE = { |
| "pass": ("#059669", "PASS"), |
| "review": ("#D97706", "REVIEW"), |
| "fail": ("#DC2626", "FAIL"), |
| } |
| _SEV_STYLE = { |
| "critical": ("#DC2626", "Critical"), |
| "warning": ("#D97706", "Warning"), |
| "info": ("#64748B", "Info"), |
| } |
|
|
|
|
| def render_liability_panel(report: AnalysisReport) -> pn.pane.HTML: |
| """Reusable liability / QC scorecard + ranked flag list (pure HTML).""" |
| lia = getattr(report, "liability", None) |
| if lia is None: |
| return pn.pane.HTML('<div style="color:#64748B;font-size:12px;">No liability assessment.</div>') |
|
|
| vcolor, vlabel = _VERDICT_STYLE.get(lia.verdict, ("#64748B", lia.verdict.upper())) |
| score_color = "#059669" if lia.score >= 85 else "#D97706" if lia.score >= 60 else "#DC2626" |
|
|
| counts = ( |
| f'<span style="color:#DC2626;font-weight:700;">{lia.n_critical}</span> critical · ' |
| f'<span style="color:#D97706;font-weight:700;">{lia.n_warning}</span> warning · ' |
| f'<span style="color:#64748B;font-weight:700;">{lia.n_info}</span> info' |
| ) |
|
|
| if not lia.flags: |
| flag_html = ( |
| '<div style="color:#059669;font-size:12px;padding:6px 0;">' |
| f'No liabilities flagged across {lia.checks_run} checks.</div>' |
| ) |
| else: |
| items = "" |
| for f in lia.sorted_flags(): |
| sc, sl = _SEV_STYLE.get(f.severity, ("#64748B", f.severity.title())) |
| loc = f'<span style="font-family:monospace;color:#94A3B8;font-size:10px;margin-left:6px;">{f.location}</span>' if f.location else "" |
| rec = f'<div style="font-size:11px;color:#64748B;margin-top:2px;">↳ {f.recommendation}</div>' if f.recommendation else "" |
| items += f""" |
| <div style="border-left:3px solid {sc};padding:6px 0 6px 10px;margin-bottom:8px;"> |
| <div style="display:flex;align-items:baseline;gap:8px;"> |
| <span style="background:{sc};color:white;border-radius:3px;padding:1px 6px; |
| font-size:9px;text-transform:uppercase;font-weight:700;">{sl}</span> |
| <span style="font-size:13px;font-weight:600;color:#0F172A;">{f.title}</span> |
| {loc} |
| </div> |
| <div style="font-size:12px;color:#334155;margin-top:2px;">{f.detail}</div> |
| {rec} |
| </div>""" |
| flag_html = items |
|
|
| return pn.pane.HTML(f""" |
| <div style="border:1px solid #CBD5E1;border-radius:8px;padding:14px 16px;background:white;"> |
| <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap; |
| border-bottom:1px solid #E2E8F0;padding-bottom:10px;margin-bottom:10px;"> |
| <div> |
| <div style="font-size:10px;color:#64748B;letter-spacing:.05em;">QC SCORE</div> |
| <div style="font-size:30px;font-weight:800;color:{score_color};line-height:1;">{lia.score}</div> |
| </div> |
| <div style="background:{vcolor};color:white;border-radius:6px;padding:6px 14px; |
| font-size:14px;font-weight:800;letter-spacing:.05em;">{vlabel}</div> |
| <div style="font-size:12px;color:#475569;">{counts}</div> |
| <div style="font-size:11px;color:#94A3B8;margin-left:auto;">{lia.checks_run} checks</div> |
| </div> |
| {flag_html} |
| </div> |
| """) |
|
|
|
|
| def _metric_panel(title: str, content: pn.viewable.Viewable) -> pn.Column: |
| return pn.Column( |
| pn.pane.HTML( |
| f'<div style="font-size:12px;font-weight:700;color:#0F172A;' |
| f'margin-bottom:6px;">{title}</div>' |
| ), |
| content, |
| styles={ |
| "background": "white", |
| "border": "1px solid #CBD5E1", |
| "border-radius": "6px", |
| "padding": "12px 14px", |
| }, |
| sizing_mode="stretch_width", |
| margin=(0, 0, 10, 0), |
| ) |
|
|
|
|
| class AnalysisDashboard(param.Parameterized): |
| """Analysis dashboard panel.""" |
|
|
| def __init__(self, state: "AppState", **params: object) -> None: |
| super().__init__(**params) |
| self._state = state |
| self._report: Optional[AnalysisReport] = None |
|
|
| @param.depends("_state.active_sequence", "_state.active_tab") |
| def panel(self) -> pn.Column: |
| seq = self._state.active_sequence |
| if seq is None: |
| return pn.Column( |
| pn.pane.HTML( |
| '<div style="color:#64748B;padding:40px;">Select a sequence first.</div>' |
| ) |
| ) |
|
|
| |
| try: |
| report = _analyzer.run_full_analysis(seq) |
| self._report = report |
| except Exception as e: |
| return pn.Column( |
| pn.pane.HTML( |
| f'<div style="color:#DC2626;padding:20px;">Analysis error: {e}</div>' |
| ) |
| ) |
|
|
| warnings_html = "" |
| if report.warnings: |
| warn_items = "".join(f"<li>{w}</li>" for w in report.warnings) |
| warnings_html = ( |
| f'<div style="background:#FEF3C7;border:1px solid #FDE68A;' |
| f'border-radius:4px;padding:8px 12px;margin-bottom:10px;">' |
| f'<b>Warnings:</b><ul style="margin:4px 0 0 16px;font-size:12px;">' |
| f'{warn_items}</ul></div>' |
| ) |
|
|
| |
| gc_str = f"{report.gc_percent_global:.1f}%" |
| cai_str = f"{report.cai:.3f}" if report.cai is not None else "N/A" |
| mfe_str = ( |
| f"{report.structure.mfe:.1f} kcal/mol" |
| if report.structure and not report.structure.is_stub |
| else "N/A" |
| ) |
| summary_html = f""" |
| <div style="display:flex;gap:20px;padding:12px 0;flex-wrap:wrap;"> |
| <div><div style="font-size:10px;color:#64748B;">LENGTH</div> |
| <div style="font-size:16px;font-weight:700;">{report.sequence_length} nt</div></div> |
| <div><div style="font-size:10px;color:#64748B;">GC%</div> |
| <div style="font-size:16px;font-weight:700;">{gc_str}</div></div> |
| <div><div style="font-size:10px;color:#64748B;">CAI</div> |
| <div style="font-size:16px;font-weight:700;">{cai_str}</div></div> |
| <div><div style="font-size:10px;color:#64748B;">MFE</div> |
| <div style="font-size:16px;font-weight:700;">{mfe_str}</div></div> |
| </div> |
| """ |
|
|
| return pn.Column( |
| pn.pane.HTML( |
| f'<div style="font-size:16px;font-weight:800;padding:8px 0 4px 0;">' |
| f'Analysis: {seq.name}</div>' |
| ), |
| pn.pane.HTML(warnings_html) if warnings_html else pn.pane.HTML(""), |
| pn.pane.HTML(summary_html), |
| pn.pane.HTML( |
| '<div style="font-size:13px;font-weight:700;color:#0F172A;' |
| 'margin:6px 0 6px 0;">Liability / QC assessment</div>' |
| ), |
| render_liability_panel(report), |
| pn.layout.Divider(), |
| _gc_plot(report), |
| pn.GridBox( |
| _metric_panel("Codon Adaptation Index", _cai_card(report)), |
| _metric_panel("CDS Validation", _validation_card(report)), |
| _metric_panel("Kozak Context", _kozak_card(report)), |
| _metric_panel("Homopolymers", _homopolymer_card(report)), |
| _metric_panel("Restriction Sites", _restriction_card(report)), |
| _metric_panel("Uridine Content", _uridine_card(report)), |
| _metric_panel("Liability Motifs", _motif_card(report)), |
| _metric_panel("Secondary Structure (MFE)", _structure_card(report)), |
| ncols=2, |
| sizing_mode="stretch_width", |
| ), |
| sizing_mode="stretch_width", |
| styles={"padding": "8px 16px"}, |
| ) |
|
|