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)&hellip;</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 &middot; 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&rarr;1)</span>
          <span>TM &gt; 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)}&Aring;</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>
  );
}