"""
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('
Insufficient sequence length for GC plot.
')
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}
GC: %{y:.1f}%",
))
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('No CDS available for CAI.
')
pct = int(cai * 100)
color = "#059669" if cai >= 0.7 else "#D97706" if cai >= 0.4 else "#DC2626"
bar = f''
org = report.cai_organism or "unknown"
return pn.pane.HTML(f"""
{cai:.3f}
CAI ({org})
{bar}
0.7+ = high adaptation · 0.4–0.7 = moderate · <0.4 = poor
""")
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''
f'{icon}'
f'{label}
'
)
return pn.pane.HTML(rows)
def _homopolymer_card(report: AnalysisReport) -> pn.pane.HTML:
if report.homopolymer_count == 0:
return pn.pane.HTML(
'No homopolymers detected '
f'(min run ≥ 5 nt)
'
)
runs = report.homopolymer_runs
rows = "".join(
f'| '
f'{r.nucleotide}×{r.length} | '
f'pos {r.start} |
'
for r in sorted(runs, key=lambda r: -r.length)[:10]
)
return pn.pane.HTML(
f''
f'{report.homopolymer_count} homopolymer run(s) — '
f'longest: {report.longest_homopolymer} nt
'
f''
)
def _restriction_card(report: AnalysisReport) -> pn.pane.HTML:
present = report.restriction_enzymes_present
if not present:
return pn.pane.HTML(
'No common restriction sites found.
'
)
chips = " ".join(
f'{e}'
for e in sorted(present)
)
return pn.pane.HTML(f'Sites present: {chips}
')
def _kozak_card(report: AnalysisReport) -> pn.pane.HTML:
kz = report.kozak
if kz is None:
return pn.pane.HTML('No Kozak context available.
')
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"""
{kz.strength.upper()}
score {kz.score:.2f}
{kz.context}
-3 purine (R): {r3_ok} · ATG pos: {kz.atg_position}
""")
def _structure_card(report: AnalysisReport) -> pn.pane.HTML:
s = report.structure
if s is None or s.is_stub:
return pn.pane.HTML(
''
'ViennaRNA not installed. Install with: conda install -c bioconda viennarna
'
)
color = "#059669" if s.mfe > -50 else "#D97706" if s.mfe > -200 else "#DC2626"
return pn.pane.HTML(f"""
{s.mfe:.1f} kcal/mol
MFE
{s.structure[:300]}{'…' if len(s.structure) > 300 else ''}
""")
def _uridine_card(report: AnalysisReport) -> pn.pane.HTML:
u = report.uridine
if u is None:
return pn.pane.HTML('No uridine data.
')
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"""
{u.u_percent:.1f}%
uridine
{stretches} high-U stretch(es) · U/A ratio {u.ua_ratio:.2f}
High U is immunostimulatory — modified nucleotides mitigate it.
""")
def _motif_card(report: AnalysisReport) -> pn.pane.HTML:
hits = report.motif_hits
if not hits:
return pn.pane.HTML(
'No liability motifs detected.
'
)
sev_color = {"critical": "#DC2626", "warning": "#D97706", "info": "#64748B"}
rows = ""
for h in hits[:12]:
c = sev_color.get(h.severity, "#64748B")
rows += (
f''
f'{h.severity}'
f'{h.label}'
f''
f'{h.region}:{h.start}
'
)
more = f'+{len(hits)-12} more
' 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('No liability assessment.
')
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'{lia.n_critical} critical · '
f'{lia.n_warning} warning · '
f'{lia.n_info} info'
)
if not lia.flags:
flag_html = (
''
f'No liabilities flagged across {lia.checks_run} checks.
'
)
else:
items = ""
for f in lia.sorted_flags():
sc, sl = _SEV_STYLE.get(f.severity, ("#64748B", f.severity.title()))
loc = f'{f.location}' if f.location else ""
rec = f'↳ {f.recommendation}
' if f.recommendation else ""
items += f"""
{sl}
{f.title}
{loc}
{f.detail}
{rec}
"""
flag_html = items
return pn.pane.HTML(f"""
{vlabel}
{counts}
{lia.checks_run} checks
{flag_html}
""")
def _metric_panel(title: str, content: pn.viewable.Viewable) -> pn.Column:
return pn.Column(
pn.pane.HTML(
f'{title}
'
),
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(
'Select a sequence first.
'
)
)
# Run analysis (cached on sequence object)
try:
report = _analyzer.run_full_analysis(seq)
self._report = report
except Exception as e:
return pn.Column(
pn.pane.HTML(
f'Analysis error: {e}
'
)
)
warnings_html = ""
if report.warnings:
warn_items = "".join(f"{w}" for w in report.warnings)
warnings_html = (
f''
)
# Summary bar
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"""
LENGTH
{report.sequence_length} nt
"""
return pn.Column(
pn.pane.HTML(
f''
f'Analysis: {seq.name}
'
),
pn.pane.HTML(warnings_html) if warnings_html else pn.pane.HTML(""),
pn.pane.HTML(summary_html),
pn.pane.HTML(
'Liability / QC assessment
'
),
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"},
)