Spaces:
Running
Running
File size: 9,978 Bytes
bac1801 5561093 d2ea003 e83084c bac1801 d2ea003 5561093 9fc8479 c1c07f1 d2ea003 5561093 c1c07f1 bac1801 5561093 9fc8479 5561093 bac1801 5b7a343 5561093 bac1801 5561093 bac1801 deb8696 d2ea003 deb8696 d2ea003 bac1801 c1c07f1 d2ea003 c1c07f1 e83084c c1c07f1 d2ea003 c1c07f1 d2ea003 c1c07f1 bac1801 c1c07f1 d2ea003 c1c07f1 d2ea003 c1c07f1 bac1801 e83084c c1c07f1 bac1801 d2ea003 bac1801 d2ea003 bac1801 d2ea003 bac1801 d2ea003 bac1801 c1c07f1 | 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 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | "use client";
import { useEffect, useState, useRef } from "react";
import { useAuditTrail } from "@/hooks/useAuditTrail";
import { LearnPopover } from "@/components/LearnPopover";
import { viewerBg } from "@/lib/theme-canvas";
type Interaction = {
partner_gene: string;
combined_score: number;
escore: number;
dscore: number;
tscore: number;
ascore: number;
};
type EvidenceFilter = "all" | "experimental" | "database" | "coexpression" | "textmining";
const EVIDENCE_THRESHOLD = 0.3;
export function StringDBViewer({ geneName, initialData }: { geneName: string; initialData?: { interactions: Interaction[] } | null }) {
const audit = useAuditTrail();
const [data, setData] = useState<{ interactions: Interaction[] } | null>(initialData ?? null);
const [loading, setLoading] = useState(!initialData);
const [error, setError] = useState<string | null>(null);
const [imgError, setImgError] = useState(false);
const [copied, setCopied] = useState(false);
const [filter, setFilter] = useState<EvidenceFilter>("all");
const auditedRef = useRef(false);
const species = 9606;
useEffect(() => {
if (initialData) return;
setError(null);
auditedRef.current = false;
fetch(`/api/backend/api/interactions/${encodeURIComponent(geneName)}?limit=12`)
.then(r => { if (!r.ok) return r.json().then(e => Promise.reject(new Error(e.detail || `Status ${r.status}`))); return r.json(); })
.then(d => { setData(d); if (!auditedRef.current) { auditedRef.current = true; audit.emitSuccess('interactions_view', 'STRING-DB', geneName, `${d.interactions?.length || 0} partners`); } })
.catch(e => { setError(e.message); audit.emitFailed('interactions_view', 'STRING-DB', geneName, e.message); })
.finally(() => setLoading(false));
}, [geneName, initialData, audit]);
const scoreChannels = [
{ key: "escore" as const, label: "Experimental", color: "#4ADE80", filter: "experimental" as const, explain: "Support from physical interaction experiments (yeast two-hybrid, affinity capture, co-crystallisation)." },
{ key: "dscore" as const, label: "Database", color: "#7C3AED", filter: "database" as const, explain: "Support from curated interaction databases that collect evidence from published literature." },
{ key: "ascore" as const, label: "Co-expression", color: "#FBBF24", filter: "coexpression" as const, explain: "Support from correlated mRNA expression across many experiments — proteins that move together often work together." },
{ key: "tscore" as const, label: "Text mining", color: "#FBBF24", filter: "textmining" as const, explain: "Support from automated scanning of the scientific literature for co-occurrence of the two genes." },
];
const stringDbUrl = `https://string-db.org/api/image/network?identifiers=${geneName}&species=${species}`;
const visibleInteractions = (data?.interactions ?? []).filter(i => {
if (filter === "all") return true;
const channel = scoreChannels.find(c => c.filter === filter);
if (!channel) return true;
return i[channel.key] >= EVIDENCE_THRESHOLD;
});
const exportPng = () => {
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = viewerBg('#06060B');
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const a = document.createElement("a");
a.download = `${geneName}_stringdb_network.png`;
a.href = canvas.toDataURL("image/png");
a.click();
};
img.src = stringDbUrl;
};
const exportTsv = () => {
if (!data?.interactions) return;
const rows = data.interactions.map(i =>
`${i.partner_gene}\t${i.combined_score.toFixed(3)}\t${i.escore.toFixed(3)}\t${i.dscore.toFixed(3)}\t${i.ascore.toFixed(3)}\t${i.tscore.toFixed(3)}`
).join("\n");
const tsv = `Partner\tCombined\tExperimental\tDatabase\tCo-expression\tText mining\n${rows}`;
const a = document.createElement("a");
a.download = `${geneName}_stringdb_scores.tsv`;
a.href = "data:text/tab-separated-values;charset=utf-8," + encodeURIComponent(tsv);
a.click();
};
const exportJson = () => {
if (!data?.interactions) return;
const a = document.createElement("a");
a.download = `${geneName}_stringdb_interactions.json`;
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify({
gene: geneName,
species,
source: "STRING-DB",
interactions: data.interactions,
}, null, 2));
a.click();
};
const copyGeneList = () => {
if (!data?.interactions) return;
const genes = data.interactions.map(i => i.partner_gene).join("\n");
navigator.clipboard.writeText(genes).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (loading) return <div className="text-text-muted text-sm animate-pulse">Fetching STRING-DB interactions…</div>;
if (error) return <div className="text-error text-sm">{error}</div>;
if (!data?.interactions?.length) return <div className="text-text-muted text-sm">No interactions found.</div>;
const { interactions } = data;
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-text-primary font-semibold">STRING-DB Interactions — {geneName}</h3>
<div className="flex items-center gap-2">
<button onClick={exportPng}
className="text-xs px-2.5 py-1 rounded bg-surface-1 border border-glass-border text-text-secondary hover:text-accent-cyan transition-colors">
Export PNG
</button>
<button onClick={exportTsv}
className="text-xs px-2.5 py-1 rounded bg-surface-1 border border-glass-border text-text-secondary hover:text-accent-cyan transition-colors">
Export TSV
</button>
<button onClick={exportJson}
className="text-xs px-2.5 py-1 rounded bg-surface-1 border border-glass-border text-text-secondary hover:text-accent-cyan transition-colors">
Export JSON
</button>
<button onClick={copyGeneList}
className="text-xs px-2.5 py-1 rounded bg-surface-1 border border-glass-border text-text-secondary hover:text-accent-cyan transition-colors">
{copied ? "Copied!" : "Copy genes"}
</button>
<a href={`https://string-db.org/network/${geneName}`} target="_blank" rel="noreferrer"
className="text-xs text-accent-cyan hover:underline">View on STRING-DB ↗</a>
</div>
</div>
<div className="data-card p-2 flex items-center justify-center bg-viewer min-h-[300px]">
{!imgError ? (
<img
src={stringDbUrl}
alt={`STRING-DB network for ${geneName}`}
className="w-full max-h-[400px] object-contain"
onError={() => setImgError(true)}
/>
) : (
<div className="text-text-muted text-sm p-4 text-center">
STRING-DB image unavailable.
<a href={`https://string-db.org/network/${geneName}`} target="_blank" rel="noreferrer"
className="block text-accent-cyan hover:underline mt-1">Open on STRING-DB ↗</a>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">Evidence filter:</span>
{[{ key: "all" as const, label: "All" }, ...scoreChannels.map(c => ({ key: c.filter, label: c.label }))].map(opt => (
<button
key={opt.key}
onClick={() => setFilter(opt.key)}
className={`text-xs px-2.5 py-1 rounded border transition-colors ${
filter === opt.key
? "bg-accent-cyan/10 border-accent-cyan/40 text-accent-cyan"
: "bg-surface-1 border-glass-border text-text-secondary hover:text-text-primary"
}`}
>
{opt.label}
</button>
))}
<span className="text-xs text-text-muted ml-auto">Showing {visibleInteractions.length} of {interactions.length} partners</span>
</div>
<div className="overflow-x-auto rounded-xl border border-glass-border">
<table className="w-full text-xs">
<thead className="bg-surface-1">
<tr>
<th className="px-3 py-2 text-left text-text-muted">Partner</th>
<th className="px-3 py-2 text-left text-text-muted">
<LearnPopover term="Combined score" topic="interactions"
explanation="STRING's confidence (0-1) that the interaction is real, integrating all evidence channels below.">
Combined
</LearnPopover>
</th>
{scoreChannels.map(c => (
<th key={c.key} className="px-3 py-2 text-left" style={{ color: c.color + "99" }}>
<LearnPopover term={`${c.label} evidence`} topic="interactions" explanation={c.explain}>
{c.label}
</LearnPopover>
</th>
))}
</tr>
</thead>
<tbody>
{visibleInteractions.map((inter, i) => (
<tr key={i} className="border-t border-glass-border hover:bg-surface-1">
<td className="px-3 py-2 text-accent-cyan font-mono font-bold">{inter.partner_gene}</td>
<td className="px-3 py-2 text-text-secondary">{inter.combined_score.toFixed(3)}</td>
{scoreChannels.map(c => (
<td key={c.key} className="px-3 py-2 text-text-muted">{inter[c.key].toFixed(3)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
} |