Spaces:
Running
Running
File size: 5,287 Bytes
bac1801 342fa92 5561093 bac1801 5561093 bac1801 342fa92 5561093 bac1801 5378a06 5561093 5378a06 bac1801 5378a06 bac1801 342fa92 bac1801 342fa92 bac1801 deb8696 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 | "use client";
import { useEffect, useState, useRef } from "react";
import { downloadTsv, exportSvgPng } from "@/lib/export-utils";
import { useAuditTrail } from "@/hooks/useAuditTrail";
type StructureMatch = {
pdb_id: string; chain: string; description: string;
tm_score: number; rmsd: number; seq_identity: number; aligned_length: number;
};
export function StructureComparison({ pdbId, chain = "A" }: { pdbId: string; chain?: string }) {
const audit = useAuditTrail();
const [matches, setMatches] = useState<StructureMatch[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const auditedRef = useRef(false);
useEffect(() => {
if (!pdbId) return;
auditedRef.current = false;
setLoading(true);
setError(null);
setMatches([]);
const controller = new AbortController();
fetch(`/api/backend/api/structure_analysis/compare/${pdbId}?chain=${chain}`, { signal: controller.signal })
.then(r => r.ok ? r.json() : r.json().then(d => { throw new Error(d.detail); }))
.then(d => {
if (!controller.signal.aborted) {
setMatches(d.matches);
if (!auditedRef.current) {
auditedRef.current = true;
audit.emitSuccess('struct_compare', 'PDBeFold', pdbId, `${d.matches?.length || 0} matches`);
}
}
})
.catch(e => {
if (!controller.signal.aborted) {
setError(e.message);
audit.emitFailed('struct_compare', 'PDBeFold', pdbId, e.message);
}
})
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [pdbId, chain]);
if (loading) return <div className="text-text-muted text-sm animate-pulse">Searching structural homologs (PDBeFold)…</div>;
if (error) return <div className="text-error text-sm">{error}</div>;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-text-primary font-semibold">Structural Homologs</h3>
<div className="flex items-center gap-2">
<button onClick={() => exportSvgPng(svgRef.current, `compare-${pdbId}.png`)}
className="btn-ghost text-xs px-2 py-1">Export PNG</button>
<button onClick={() => downloadTsv(
["PDB", "Chain", "RMSD", "Seq ID%", "Aligned", "TM-score", "Description"],
matches.map(m => [m.pdb_id, m.chain, m.rmsd.toFixed(2), (m.seq_identity * 100).toFixed(1), String(m.aligned_length), m.tm_score.toFixed(3), m.description]),
`compare-${pdbId}.tsv`
)} className="btn-ghost text-xs px-2 py-1">Export TSV</button>
<span className="text-text-muted text-xs">{matches.length} hits · PDBeFold</span>
</div>
</div>
<div className="space-y-1" ref={svgRef as any}>
<div className="flex justify-between text-xs text-text-muted mb-2">
<span>TM-score (0→1)</span>
<span>TM > 0.5 = same fold</span>
</div>
{matches.map((m, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xs font-mono text-accent-cyan w-16 flex-shrink-0">{m.pdb_id}:{m.chain}</span>
<div className="flex-1 h-4 bg-surface-1 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700"
style={{
width: `${m.tm_score * 100}%`,
background: m.tm_score > 0.5 ? "#4ADE80" : m.tm_score > 0.3 ? "#FBBF24" : "#EF4444",
opacity: 0.7,
}} />
</div>
<span className="text-xs text-text-muted w-10 text-right">{m.tm_score.toFixed(3)}</span>
</div>
))}
</div>
<div className="overflow-x-auto rounded-xl border border-glass-border">
<table className="w-full text-xs">
<thead className="bg-surface-1">
<tr>
{["PDB", "Chain", "RMSD", "Seq ID%", "Aligned", "Description"].map(h => (
<th key={h} className="px-3 py-2 text-left text-text-muted font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{matches.map((m, i) => (
<tr key={i} className="border-t border-glass-border hover:bg-surface-1">
<td className="px-3 py-2">
<a href={`https://www.rcsb.org/structure/${m.pdb_id}`} target="_blank" rel="noreferrer"
className="text-accent-cyan font-mono hover:underline">{m.pdb_id}</a>
</td>
<td className="px-3 py-2 text-text-muted">{m.chain}</td>
<td className="px-3 py-2 text-text-muted">{m.rmsd.toFixed(2)}Å</td>
<td className="px-3 py-2 text-text-muted">{(m.seq_identity * 100).toFixed(1)}%</td>
<td className="px-3 py-2 text-text-muted">{m.aligned_length} aa</td>
<td className="px-3 py-2 text-text-muted max-w-48 truncate">{m.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
|