Spaces:
Running
Running
File size: 6,204 Bytes
bac1801 deb8696 bac1801 deb8696 bac1801 14dd32c bac1801 9fc8479 bac1801 14dd32c 9fc8479 bac1801 5b7a343 bac1801 9fc8479 bac1801 14dd32c bac1801 9fc8479 bac1801 5e98b5a bac1801 d54a751 bac1801 deb8696 bac1801 e83084c bac1801 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | "use client";
import { useEffect, useState } from "react";
type RPoint = { residue: string; chain: string; resnum: number; phi: number; psi: number; region: string };
const REGION_COLOR: Record<string, string> = {
core_alpha: "#4ADE80",
core_beta: "#7C3AED",
allowed: "#FBBF24",
outlier: "#EF4444",
};
export function RamachandranPlot({ pdbId, chain = "A" }: { pdbId: string | null; chain?: string }) {
const [points, setPoints] = useState<RPoint[]>([]);
const [loading, setLoading] = useState(true);
const [hovered, setHovered] = useState<RPoint | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!pdbId) { setLoading(false); return; }
setLoading(true);
setError(null);
fetch(`/api/backend/api/structure_analysis/ramachandran/${pdbId}?chain=${chain}`)
.then(r => { if (!r.ok) return r.json().then(e => Promise.reject(new Error(e.detail || `Status ${r.status}`))); return r.json(); })
.then(setPoints)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [pdbId, chain]);
if (!pdbId) return <div className="text-text-muted text-sm">No PDB structure available for this protein. Ramachandran analysis requires a 3D structure.</div>;
if (loading) return <div className="text-text-muted text-sm animate-pulse">Calculating φ/ψ angles…</div>;
if (error) return <div className="text-error text-sm">{error}</div>;
const W = 400, H = 400, PAD = 40;
const toX = (phi: number) => PAD + ((phi + 180) / 360) * (W - PAD * 2);
const toY = (psi: number) => PAD + ((180 - psi) / 360) * (H - PAD * 2);
const counts = {
core_alpha: points.filter(p => p.region === "core_alpha").length,
core_beta: points.filter(p => p.region === "core_beta").length,
allowed: points.filter(p => p.region === "allowed").length,
outlier: points.filter(p => p.region === "outlier").length,
};
const outlierPct = points.length ? ((counts.outlier / points.length) * 100).toFixed(1) : "0";
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-text-primary font-semibold">Ramachandran Plot</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${
+outlierPct < 2 ? "text-good bg-good/10"
: +outlierPct < 5 ? "text-warn bg-warn/10"
: "text-error bg-error/10"
}`}>
{outlierPct}% outliers
</span>
</div>
<div className="flex flex-col lg:flex-row gap-6">
<div className="data-card relative">
<svg viewBox={`0 0 ${W} ${H}`} className="w-80 h-80">
<ellipse cx={toX(-57)} cy={toY(-47)} rx={30} ry={30}
fill="#4ADE80" fillOpacity={0.06} stroke="#4ADE80" strokeOpacity={0.15} />
<ellipse cx={toX(-119)} cy={toY(113)} rx={30} ry={30}
fill="#7C3AED" fillOpacity={0.06} stroke="#7C3AED" strokeOpacity={0.15} />
<line x1={PAD} y1={toY(0)} x2={W - PAD} y2={toY(0)} stroke="rgba(255,255,255,0.1)" />
<line x1={toX(0)} y1={PAD} x2={toX(0)} y2={H - PAD} stroke="rgba(255,255,255,0.1)" />
<text x={W / 2} y={H - 8} textAnchor="middle" fill="rgba(255,255,255,0.4)" fontSize={11}>φ (phi)</text>
<text x={12} y={H / 2} textAnchor="middle" fill="rgba(255,255,255,0.4)" fontSize={11}
transform={`rotate(-90, 12, ${H / 2})`}>ψ (psi)</text>
{[-180, -90, 0, 90, 180].map(v => (
<g key={v}>
<text x={toX(v)} y={H - PAD + 14} textAnchor="middle" fill="rgba(255,255,255,0.25)" fontSize={8}>{v}°</text>
<text x={PAD - 10} y={toY(v) + 3} textAnchor="end" fill="rgba(255,255,255,0.25)" fontSize={8}>{v}°</text>
</g>
))}
{points.map((p, i) => (
<circle key={i} cx={toX(p.phi)} cy={toY(p.psi)} r={3}
fill={REGION_COLOR[p.region] ?? "#888"} fillOpacity={0.7}
className="cursor-pointer"
onMouseEnter={() => setHovered(p)}
onMouseLeave={() => setHovered(null)} />
))}
</svg>
{hovered && (
<div className="absolute top-2 right-2 bg-viewer/90 border border-glass-border rounded-lg p-2 text-xs text-text-secondary pointer-events-none">
<p className="text-accent-cyan font-bold">{hovered.residue}{hovered.resnum}</p>
<p>φ {hovered.phi.toFixed(1)}° · ψ {hovered.psi.toFixed(1)}°</p>
<p className="capitalize">{hovered.region.replace("_", " ")}</p>
</div>
)}
</div>
<div className="space-y-3 flex-1">
<p className="text-text-muted text-xs">n = {points.length} residues</p>
{Object.entries(counts).map(([region, count]) => (
<div key={region}>
<div className="flex justify-between text-xs mb-1">
<span className="capitalize" style={{ color: REGION_COLOR[region] }}>{region.replace("_", " ")}</span>
<span className="text-text-muted">{points.length > 0 ? ((count / points.length) * 100).toFixed(1) : "0"}%</span>
</div>
<div className="w-full h-1.5 bg-surface-1 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{
width: `${points.length > 0 ? (count / points.length) * 100 : 0}%`,
background: REGION_COLOR[region],
opacity: 0.7,
}} />
</div>
</div>
))}
<p className="text-text-muted text-xs mt-4">>98% in favoured regions = high quality model</p>
</div>
</div>
<div className="flex flex-wrap gap-4">
{Object.entries(REGION_COLOR).map(([region, color]) => (
<div key={region} className="flex items-center gap-1.5 text-xs text-text-muted">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: color }} />
<span className="capitalize">{region.replace("_", " ")}</span>
</div>
))}
</div>
</div>
);
}
|