"""Multi-track Interactive Residue Inspector (formerly sequence_viewer)."""
import streamlit as st
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Amino acid property classification
AA_PROPS = {
"A": "hydrophobic", "I": "hydrophobic", "L": "hydrophobic", "M": "hydrophobic",
"F": "hydrophobic", "W": "hydrophobic", "V": "hydrophobic", "P": "hydrophobic",
"D": "charged", "E": "charged", "K": "charged", "R": "charged", "H": "charged",
"S": "polar", "T": "polar", "N": "polar", "Q": "polar", "C": "polar", "Y": "polar",
"G": "special", "X": "unknown",
}
PROP_COLORS = {
"hydrophobic": "#f59e0b",
"charged": "#ef4444",
"polar": "#10b981",
"special": "#94a3b8",
"unknown": "#64748b",
}
def parse_b_factors(pdb_text: str) -> np.ndarray:
"""Extract CA B-factors from PDB text."""
b_factors = []
if not pdb_text:
return None
for line in pdb_text.splitlines():
if line.startswith("ATOM ") and line[12:16].strip() == "CA":
try:
bf = float(line[60:66].strip())
b_factors.append(bf)
except ValueError:
b_factors.append(0.0)
return np.array(b_factors) if b_factors else None
def render_sequence_viewer(seq: str, displacements: np.ndarray, coverage: np.ndarray = None, mode_label: str = "Mode 0"):
"""Legacy HTML sequence viewer kept for compatibility, but enhanced."""
n = len(seq)
if coverage is None:
coverage = np.ones(n)
max_d = displacements.max() + 1e-8
html = f"""
"""
for row_start in range(0, n, 80):
row_end = min(row_start + 80, n)
html += '
'
for i in range(row_start, row_end):
if (i + 1) % 10 == 0: html += f'
{i + 1}
'
elif (i + 1) % 5 == 0: html += '
·
'
else: html += '
'
html += "
"
html += '
'
for i in range(row_start, row_end):
aa = seq[i] if i < len(seq) else "X"
d = displacements[i]
c = coverage[i] if i < len(coverage) else 1.0
t = d / max_d
r, g, b = int(30 + 225 * t), int(27 + 20 * (1 - t)), int(75 - 50 * t)
txt_color = "white" if t > 0.3 else "#a5b4fc"
border_w = max(0, int(3 * (1 - c)))
border = f"{border_w}px solid #ef4444" if border_w > 0 else "none"
tooltip = f"{aa}{i+1} | {d:.3f}Å | cov={c:.2f} | {AA_PROPS.get(aa, 'unknown')}"
html += f'
{aa}
'
html += "
"
html += "
"
st.markdown(html, unsafe_allow_html=True)
def render_residue_inspector(displacements: dict, seq: str = "", coverage: np.ndarray = None, pdb_text: str = None, conservation: np.ndarray = None):
"""
Render powerful multi-track Residue Inspector.
Tracks:
1. Sequence / Coverage
2. Displacement by Mode
3. Structural B-Factors (from PDB)
4. Conservation (Evolutionary) -> Placeholder/Mock if None
"""
n_res = len(list(displacements.values())[0])
residues = np.arange(1, n_res + 1)
hover_text = [f"{seq[i] if i < len(seq) else '?'}{i+1}" for i in range(n_res)]
# Parse B-factors if pdb is provided
b_factors = parse_b_factors(pdb_text) if pdb_text else None
# Determine how many rows we need
rows = 2
titles = ["Predicted Mobility (Å)", "Coverage"]
if b_factors is not None and len(b_factors) == n_res:
rows += 1
titles.append("Experimental B-Factors")
if conservation is not None:
rows += 1
titles.append("Known Natural Variants (Mutations)")
row_heights = [0.5] + [(0.5 / (rows - 1))] * (rows - 1)
fig = make_subplots(
rows=rows, cols=1, row_heights=row_heights,
shared_xaxes=True, vertical_spacing=0.06,
subplot_titles=titles
)
colors = ["#6366f1", "#ef4444", "#10b981", "#f59e0b", "#ec4899", "#8b5cf6"]
# ── Track 1: Displacement modes ──
for k, d in displacements.items():
mags = np.linalg.norm(d, axis=1) if d.ndim == 2 else d
fig.add_trace(go.Scatter(
x=residues, y=mags, mode="lines",
name=f"Mode {k}",
line=dict(color=colors[k % len(colors)], width=2),
fill="tozeroy",
fillcolor=colors[k % len(colors)].replace(")", ",0.05)").replace("rgb", "rgba"),
text=hover_text, hovertemplate="%{text}
Mobility: %{y:.3f}ÅMode " + str(k) + "",
), row=1, col=1)
# ── Track 2: Coverage ──
if coverage is not None:
fig.add_trace(go.Scatter(
x=residues, y=coverage[:n_res], mode="lines",
name="Coverage",
line=dict(color="#94a3b8", width=1.5),
fill="tozeroy", fillcolor="rgba(148,163,184,0.15)",
hovertemplate="%{x}
Coverage: %{y:.3f}",
), row=2, col=1)
current_row = 3
# ── Track 3: B-Factors ──
if b_factors is not None and len(b_factors) == n_res:
fig.add_trace(go.Scatter(
x=residues, y=b_factors, mode="lines",
name="B-Factor", line=dict(color="#ec4899", width=1.5),
fill="tozeroy", fillcolor="rgba(236,72,153,0.15)",
hovertemplate="%{x}
B-Factor: %{y:.1f}",
), row=current_row, col=1)
current_row += 1
# ── Track 4: Mutations (Conservation arg) ──
if conservation is not None:
fig.add_trace(go.Scatter(
x=residues, y=conservation, mode="lines",
name="Variants", line=dict(color="#14b8a6", width=1.5),
fill="tozeroy", fillcolor="rgba(20,184,166,0.15)",
hovertemplate="%{x}
Variants: %{y:.0f}",
), row=current_row, col=1)
# Decorate X-axis with sequence for zoom
# We add rug plot at bottom if sequence is provided
fig.update_layout(
template="plotly_dark", height=200 + (rows * 150),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(30,27,75,0.4)",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
margin=dict(l=50, r=20, t=40, b=30),
hovermode="x unified",
)
fig.update_xaxes(title_text="Residue Index", row=rows, col=1)
st.plotly_chart(fig, use_container_width=True, key=f"residue_inspector")
# Aliasing the old function name so we don't break existing imports,
# but calling the new powerful multi-track one.
def render_displacement_chart(displacements: dict, seq: str = "", coverage: np.ndarray = None, pdb_text: str = None, conservation: np.ndarray = None):
render_residue_inspector(displacements, seq, coverage, pdb_text, conservation)