Spaces:
Running
Running
Download bioai-platform/frontend/src/components/domains/DomainArchitecture.tsx from Samad14/bio-nexus-api: direct link, hf CLI and curl.
- Browser
- Download file 30.1 kB
-
https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/bioai-platform/frontend/src/components/domains/DomainArchitecture.tsx
- Command line
-
hf download hf://spaces/Samad14/bio-nexus-api/bioai-platform/frontend/src/components/domains/DomainArchitecture.tsx
-
curl -L -o DomainArchitecture.tsx https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/bioai-platform/frontend/src/components/domains/DomainArchitecture.tsx
30.1 kB
| "use client"; | |
| import { useEffect, useState, useRef, useCallback } from "react"; | |
| import { downloadTsv, exportSvgPng } from "@/lib/export-utils"; | |
| import { useAuditTrail } from "@/hooks/useAuditTrail"; | |
| const DB_COLORS: Record<string, string> = { | |
| PFAM: "#4ADE80", | |
| PANTHER: "#7C3AED", | |
| PRINTS: "#FBBF24", | |
| PROSITE: "#FBBF24", | |
| SMART: "#3B82F6", | |
| CDD: "#10B981", | |
| }; | |
| const SITE_COLORS: Record<string, string> = { | |
| "Active site": "#FBBF24", | |
| "Catalytic residue": "#FB923C", | |
| "Binding site": "#FFB84D", | |
| "Metal ion-binding site": "#FFD700", | |
| }; | |
| const PTM_COLORS: Record<string, string> = { | |
| "Modified residue": "#A78BFA", | |
| "Phosphorylation": "#A78BFA", | |
| "Glycosylation": "#06B6D4", | |
| "Acetylation": "#10B981", | |
| "Ubiquitination": "#FBBF24", | |
| "Methylation": "#6366F1", | |
| "Sumoylation": "#EC4899", | |
| }; | |
| const MOTIF_COLORS: Record<string, string> = { | |
| "Zinc finger": "#FBBF24", | |
| "Coiled-coil": "#60A5FA", | |
| "Leucine-rich repeat": "#34D399", | |
| "Immunoglobulin-like domain": "#F472B6", | |
| "SH2 domain": "#FB923C", | |
| "SH3 domain": "#A78BFA", | |
| "EGF-like domain": "#4ADE80", | |
| "PH domain": "#F87171", | |
| "Bromodomain": "#818CF8", | |
| "default": "#9CA3AF", | |
| }; | |
| type Domain = { accession: string; name: string; source_db: string; start: number; end: number; score?: number | null }; | |
| type DomainsResponse = { uniprot_accession: string; sequence_length: number; domains: Domain[] }; | |
| type FeatureItem = { type: string; description: string; begin: number | null; end: number | null; amino_acid?: string[] }; | |
| type FunctionalSite = FeatureItem; | |
| type PTMItem = FeatureItem; | |
| type TopologyItem = FeatureItem; | |
| type MotifItem = FeatureItem; | |
| type VariantItem = FeatureItem; | |
| type DisulfideBond = { begin: number | null; end: number | null; description: string }; | |
| type CompositionBias = { type: string; description: string; begin: number | null; end: number | null }; | |
| type GOTerm = { id: string; term: string; category: string }; | |
| type PathwayAnnotation = { database: string; id: string; name: string }; | |
| type FullAnalysis = { | |
| accession: string; | |
| protein_name: string; | |
| organism: string; | |
| sequence_length: number; | |
| sequence: string; | |
| domains: Domain[]; | |
| active_sites: FunctionalSite[]; | |
| ptms: PTMItem[]; | |
| topology: TopologyItem[]; | |
| structural_motifs: MotifItem[]; | |
| variants: VariantItem[]; | |
| disulfide_bonds: DisulfideBond[]; | |
| composition_bias: CompositionBias[]; | |
| go_terms: GOTerm[]; | |
| pathways: PathwayAnnotation[]; | |
| feature_summary: Record<string, number>; | |
| }; | |
| const TABS = [ | |
| { id: "domains", label: "Domains", icon: "🧩" }, | |
| { id: "sites", label: "Sites", icon: "⚡" }, | |
| { id: "ptm", label: "PTMs", icon: "🔧" }, | |
| { id: "motifs", label: "Motifs", icon: "🎯" }, | |
| { id: "topology", label: "Topology", icon: "📐" }, | |
| { id: "variants", label: "Variants", icon: "🔀" }, | |
| { id: "go", label: "GO Terms", icon: "🏷️" }, | |
| { id: "pathways", label: "Pathways", icon: "🛤️" }, | |
| { id: "structure", label: "Structure", icon: "💎" }, | |
| ] as const; | |
| type TabId = typeof TABS[number]["id"]; | |
| export function DomainArchitecture({ accession }: { accession: string }) { | |
| const audit = useAuditTrail(); | |
| const [data, setData] = useState<FullAnalysis | null>(null); | |
| const [domainsOnly, setDomainsOnly] = useState<DomainsResponse | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [activeTab, setActiveTab] = useState<TabId>("domains"); | |
| const [tooltip, setTooltip] = useState<{ text: string; sub: string; x: number; y: number } | null>(null); | |
| const svgRef = useRef<SVGSVGElement>(null); | |
| const auditedRef = useRef(false); | |
| const fetchAll = useCallback(async () => { | |
| setError(null); | |
| auditedRef.current = false; | |
| try { | |
| const [fullResp, domainsResp] = await Promise.allSettled([ | |
| fetch(`/api/backend/api/domains/${accession}/all`), | |
| fetch(`/api/backend/api/domains/${accession}`), | |
| ]); | |
| if (fullResp.status === "fulfilled" && fullResp.value.ok) { | |
| const full = await fullResp.value.json(); | |
| setData(full); | |
| setDomainsOnly(null); | |
| if (!auditedRef.current) { | |
| auditedRef.current = true; | |
| audit.emitSuccess("domain_view", "InterPro+UniProt", accession, | |
| `${full.domains?.length || 0} domains, ${full.active_sites?.length || 0} sites, ${full.ptms?.length || 0} PTMs`); | |
| } | |
| } else if (domainsResp.status === "fulfilled" && domainsResp.value.ok) { | |
| const d = await domainsResp.value.json(); | |
| setDomainsOnly(d); | |
| setData(null); | |
| if (!auditedRef.current) { | |
| auditedRef.current = true; | |
| audit.emitSuccess("domain_view", "InterPro", accession, `${d.domains?.length || 0} domains`); | |
| } | |
| } else { | |
| const detail = fullResp.status === "fulfilled" | |
| ? await fullResp.value.json().then(e => e.detail).catch(() => `Status ${fullResp.value.status}`) | |
| : "Network error"; | |
| throw new Error(detail); | |
| } | |
| } catch (e: any) { | |
| setError(e.message); | |
| audit.emitFailed("domain_view", "InterPro", accession, e.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [accession, audit]); | |
| useEffect(() => { setLoading(true); fetchAll(); }, [fetchAll]); | |
| const seqLen = data?.sequence_length ?? domainsOnly?.sequence_length ?? 0; | |
| const domains = data?.domains ?? domainsOnly?.domains ?? []; | |
| if (loading) return <div className="text-text-muted text-sm animate-pulse">Loading domain annotations…</div>; | |
| if (error) return <div className="text-error text-sm">{error}</div>; | |
| if (!data && !domainsOnly) return <div className="text-error text-sm">No data returned.</div>; | |
| return ( | |
| <div className="space-y-4 relative"> | |
| <div className="flex items-center justify-between flex-wrap gap-2"> | |
| <div> | |
| <h3 className="text-text-primary font-semibold">Domain & Motif Analysis</h3> | |
| {data && ( | |
| <p className="text-xs text-text-muted mt-0.5"> | |
| {data.protein_name} · {data.organism} · {seqLen} aa | |
| </p> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <ExportMenu accession={accession} data={data} domains={domains} svgRef={svgRef} /> | |
| <span className="text-text-muted text-xs"> | |
| {domains.length} domains{data ? `, ${data.active_sites?.length || 0} sites, ${data.ptms?.length || 0} PTMs` : ""} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap gap-3"> | |
| {Array.from(new Set(domains.map(d => d.source_db))).map(db => ( | |
| <div key={db} className="flex items-center gap-1.5 text-xs text-text-muted"> | |
| <div className="w-3 h-3 rounded-sm" style={{ background: DB_COLORS[db] ?? "#888" }} /> | |
| {db} | |
| </div> | |
| ))} | |
| </div> | |
| {data && ( | |
| <div className="flex flex-wrap gap-1 border-b border-glass-border pb-1"> | |
| {TABS.map(tab => { | |
| const count = getTabCount(tab.id, data); | |
| return ( | |
| <button key={tab.id} onClick={() => setActiveTab(tab.id)} | |
| className={`px-3 py-1.5 text-xs rounded-lg transition-colors ${ | |
| activeTab === tab.id | |
| ? "bg-accent-cyan/15 text-accent-cyan border border-accent-cyan/30" | |
| : "text-text-muted hover:text-text-primary hover:bg-surface-1 border border-transparent" | |
| }`}> | |
| <span className="mr-1">{tab.icon}</span> | |
| {tab.label} | |
| {count > 0 && <span className="ml-1 text-[10px] opacity-60">{count}</span>} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {activeTab === "domains" && <DomainsTrack domains={domains} seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip} accession={accession} />} | |
| {activeTab === "sites" && data && <SitesTrack sites={data.active_sites} seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip} />} | |
| {activeTab === "ptm" && data && <PTMTrack ptms={data.ptms} seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip} />} | |
| {activeTab === "motifs" && data && <MotifsTrack motifs={data.structural_motifs} seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip} />} | |
| {activeTab === "topology" && data && <TopologyView topology={data.topology} seqLen={seqLen} />} | |
| {activeTab === "variants" && data && <VariantsTable variants={data.variants} />} | |
| {activeTab === "go" && data && <GOTermsView terms={data.go_terms} />} | |
| {activeTab === "pathways" && data && <PathwaysView pathways={data.pathways} />} | |
| {activeTab === "structure" && data && <StructureInfoView data={data} />} | |
| {tooltip && ( | |
| <div className="fixed z-50 bg-viewer border border-glass-border rounded-xl p-3 text-xs text-text-primary shadow-2xl pointer-events-none max-w-xs" | |
| style={{ left: tooltip.x + 12, top: tooltip.y - 10 }}> | |
| <p className="font-bold text-accent-cyan">{tooltip.text}</p> | |
| <p className="text-text-muted">{tooltip.sub}</p> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /* ---------- helpers ---------- */ | |
| function getTabCount(tabId: TabId, data: FullAnalysis): number { | |
| switch (tabId) { | |
| case "domains": return data.domains?.length || 0; | |
| case "sites": return data.active_sites?.length || 0; | |
| case "ptm": return data.ptms?.length || 0; | |
| case "motifs": return data.structural_motifs?.length || 0; | |
| case "topology": return data.topology?.length || 0; | |
| case "variants": return data.variants?.length || 0; | |
| case "go": return data.go_terms?.length || 0; | |
| case "pathways": return data.pathways?.length || 0; | |
| case "structure": return (data.disulfide_bonds?.length || 0) + (data.composition_bias?.length || 0); | |
| default: return 0; | |
| } | |
| } | |
| /* ---------- SVG track components ---------- */ | |
| function MultiTrackSVG({ seqLen, svgRef, setTooltip, children }: { | |
| seqLen: number; | |
| svgRef: React.RefObject<SVGSVGElement>; | |
| setTooltip: (t: { text: string; sub: string; x: number; y: number } | null) => void; | |
| children: React.ReactNode; | |
| }) { | |
| const W = 700; | |
| const H = 60; | |
| return ( | |
| <div className="overflow-x-auto"> | |
| <svg ref={svgRef} viewBox={`0 0 ${W + 40} ${H + 20}`} className="w-full min-w-[400px]"> | |
| <rect x={20} y={H / 2 - 4} width={W} height={8} rx={4} fill="rgba(255,255,255,0.08)" /> | |
| {[0, 0.25, 0.5, 0.75, 1].map(t => ( | |
| <g key={t}> | |
| <line x1={20 + t * W} y1={H / 2 + 4} x2={20 + t * W} y2={H / 2 + 10} stroke="rgba(255,255,255,0.2)" /> | |
| <text x={20 + t * W} y={H / 2 + 20} textAnchor="middle" fill="rgba(255,255,255,0.3)" fontSize={9}> | |
| {Math.round(t * seqLen)} | |
| </text> | |
| </g> | |
| ))} | |
| {children} | |
| </svg> | |
| </div> | |
| ); | |
| } | |
| function DomainsTrack({ domains, seqLen, svgRef, setTooltip, accession }: { | |
| domains: Domain[]; | |
| seqLen: number; | |
| svgRef: React.RefObject<SVGSVGElement>; | |
| setTooltip: (t: { text: string; sub: string; x: number; y: number } | null) => void; | |
| accession: string; | |
| }) { | |
| const W = 700; | |
| const scale = (pos: number) => (pos / seqLen) * W; | |
| return ( | |
| <div className="space-y-4"> | |
| <MultiTrackSVG seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip}> | |
| {domains.map((d, i) => { | |
| const x = 20 + scale(d.start); | |
| const w = Math.max(scale(d.end - d.start), 6); | |
| const color = DB_COLORS[d.source_db] ?? "#888"; | |
| return ( | |
| <g key={`${d.accession}-${i}`} | |
| onMouseEnter={e => setTooltip({ text: d.name, sub: `${d.accession} \u00b7 ${d.source_db} \u00b7 ${d.start}\u2013${d.end}`, x: e.clientX, y: e.clientY })} | |
| onMouseLeave={() => setTooltip(null)} | |
| className="cursor-pointer"> | |
| <rect x={x} y={16} width={w} height={28} rx={4} | |
| fill={color} fillOpacity={0.3} stroke={color} strokeWidth={1.5} /> | |
| {w > 40 && ( | |
| <text x={x + w / 2} y={33} textAnchor="middle" fill={color} fontSize={8} fontWeight="bold"> | |
| {d.name.length > 12 ? d.name.slice(0, 11) + "\u2026" : d.name} | |
| </text> | |
| )} | |
| </g> | |
| ); | |
| })} | |
| </MultiTrackSVG> | |
| <div className="max-h-52 overflow-y-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Accession", "Name", "DB", "Start", "End"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {domains.map((d, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2 font-mono text-accent-cyan">{d.accession}</td> | |
| <td className="px-3 py-2 text-text-secondary">{d.name}</td> | |
| <td className="px-3 py-2 text-text-muted">{d.source_db}</td> | |
| <td className="px-3 py-2 text-text-muted">{d.start}</td> | |
| <td className="px-3 py-2 text-text-muted">{d.end}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SitesTrack({ sites, seqLen, svgRef, setTooltip }: { | |
| sites: FunctionalSite[]; | |
| seqLen: number; | |
| svgRef: React.RefObject<SVGSVGElement>; | |
| setTooltip: (t: { text: string; sub: string; x: number; y: number } | null) => void; | |
| }) { | |
| const W = 700; | |
| const scale = (pos: number) => (pos / seqLen) * W; | |
| if (!sites.length) return <p className="text-sm text-text-muted">No functional sites annotated.</p>; | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="flex flex-wrap gap-3"> | |
| {Object.entries(SITE_COLORS).map(([type, color]) => ( | |
| <div key={type} className="flex items-center gap-1.5 text-xs text-text-muted"> | |
| <div className="w-3 h-3 rounded-full" style={{ background: color }} /> | |
| {type} | |
| </div> | |
| ))} | |
| </div> | |
| <MultiTrackSVG seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip}> | |
| {sites.map((s, i) => { | |
| const pos = s.begin ?? 0; | |
| const x = 20 + scale(pos); | |
| const color = SITE_COLORS[s.type] ?? "#FF6B6B"; | |
| return ( | |
| <g key={i} | |
| onMouseEnter={e => setTooltip({ text: s.type, sub: `${s.description || ""} \u00b7 Position ${s.begin}`, x: e.clientX, y: e.clientY })} | |
| onMouseLeave={() => setTooltip(null)} | |
| className="cursor-pointer"> | |
| <line x1={x} y1={10} x2={x} y2={50} stroke={color} strokeWidth={2} /> | |
| <circle cx={x} cy={30} r={5} fill={color} fillOpacity={0.5} stroke={color} strokeWidth={1.5} /> | |
| </g> | |
| ); | |
| })} | |
| </MultiTrackSVG> | |
| <div className="max-h-52 overflow-y-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Type", "Description", "Position"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {sites.map((s, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2 text-text-primary font-medium">{s.type}</td> | |
| <td className="px-3 py-2 text-text-secondary">{s.description || "\u2014"}</td> | |
| <td className="px-3 py-2 text-text-muted">{s.begin}{s.end && s.end !== s.begin ? `\u2013${s.end}` : ""}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function PTMTrack({ ptms, seqLen, svgRef, setTooltip }: { | |
| ptms: PTMItem[]; | |
| seqLen: number; | |
| svgRef: React.RefObject<SVGSVGElement>; | |
| setTooltip: (t: { text: string; sub: string; x: number; y: number } | null) => void; | |
| }) { | |
| const W = 700; | |
| const scale = (pos: number) => (pos / seqLen) * W; | |
| if (!ptms.length) return <p className="text-sm text-text-muted">No post-translational modifications annotated.</p>; | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="flex flex-wrap gap-3"> | |
| {Object.entries(PTM_COLORS).map(([type, color]) => ( | |
| <div key={type} className="flex items-center gap-1.5 text-xs text-text-muted"> | |
| <div className="w-3 h-3 rounded-sm" style={{ background: color }} /> | |
| {type} | |
| </div> | |
| ))} | |
| </div> | |
| <MultiTrackSVG seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip}> | |
| {ptms.map((p, i) => { | |
| const pos = p.begin ?? 0; | |
| const x = 20 + scale(pos); | |
| const color = PTM_COLORS[p.type] ?? "#A78BFA"; | |
| return ( | |
| <g key={i} | |
| onMouseEnter={e => setTooltip({ text: p.type, sub: `${p.description || ""} \u00b7 Position ${p.begin}`, x: e.clientX, y: e.clientY })} | |
| onMouseLeave={() => setTooltip(null)} | |
| className="cursor-pointer"> | |
| <rect x={x - 3} y={18} width={6} height={24} rx={2} fill={color} fillOpacity={0.6} /> | |
| </g> | |
| ); | |
| })} | |
| </MultiTrackSVG> | |
| <div className="max-h-52 overflow-y-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Type", "Description", "Position"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {ptms.map((p, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2 text-text-primary font-medium">{p.type}</td> | |
| <td className="px-3 py-2 text-text-secondary">{p.description || "\u2014"}</td> | |
| <td className="px-3 py-2 text-text-muted">{p.begin}{p.end && p.end !== p.begin ? `\u2013${p.end}` : ""}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function MotifsTrack({ motifs, seqLen, svgRef, setTooltip }: { | |
| motifs: MotifItem[]; | |
| seqLen: number; | |
| svgRef: React.RefObject<SVGSVGElement>; | |
| setTooltip: (t: { text: string; sub: string; x: number; y: number } | null) => void; | |
| }) { | |
| const W = 700; | |
| const scale = (pos: number) => (pos / seqLen) * W; | |
| if (!motifs.length) return <p className="text-sm text-text-muted">No structural motifs annotated.</p>; | |
| return ( | |
| <div className="space-y-4"> | |
| <MultiTrackSVG seqLen={seqLen} svgRef={svgRef} setTooltip={setTooltip}> | |
| {motifs.map((m, i) => { | |
| const begin = m.begin ?? 0; | |
| const end = m.end ?? begin + 10; | |
| const x = 20 + scale(begin); | |
| const w = Math.max(scale(end - begin), 6); | |
| const color = MOTIF_COLORS[m.type] ?? MOTIF_COLORS.default; | |
| return ( | |
| <g key={i} | |
| onMouseEnter={e => setTooltip({ text: m.type, sub: `${m.description || ""} \u00b7 ${begin}\u2013${end}`, x: e.clientX, y: e.clientY })} | |
| onMouseLeave={() => setTooltip(null)} | |
| className="cursor-pointer"> | |
| <rect x={x} y={18} width={w} height={24} rx={4} | |
| fill={color} fillOpacity={0.25} stroke={color} strokeWidth={1.5} strokeDasharray="4 2" /> | |
| </g> | |
| ); | |
| })} | |
| </MultiTrackSVG> | |
| <div className="max-h-52 overflow-y-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Type", "Description", "Start", "End"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {motifs.map((m, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2 text-text-primary font-medium">{m.type}</td> | |
| <td className="px-3 py-2 text-text-secondary">{m.description || "\u2014"}</td> | |
| <td className="px-3 py-2 text-text-muted">{m.begin}</td> | |
| <td className="px-3 py-2 text-text-muted">{m.end}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /* ---------- list / table components ---------- */ | |
| function TopologyView({ topology, seqLen }: { topology: TopologyItem[]; seqLen: number }) { | |
| if (!topology.length) return <p className="text-sm text-text-muted">No topological features annotated.</p>; | |
| const TYPE_STYLE: Record<string, string> = { | |
| "Signal peptide": "bg-good/15 text-good border-good/30", | |
| "Transmembrane region": "bg-info/15 text-info border-info/30", | |
| "Chain": "bg-accent-purple/15 text-accent-purple border-accent-purple/30", | |
| "Propeptide": "bg-accent-amber/15 text-accent-amber border-accent-amber/30", | |
| "Peptide": "bg-accent-cyan/15 text-accent-cyan border-accent-cyan/30", | |
| }; | |
| return ( | |
| <div className="space-y-3"> | |
| <div className="overflow-x-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Type", "Description", "Start", "End", "Length"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {topology.map((t, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2"> | |
| <span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${TYPE_STYLE[t.type] ?? "bg-surface-3/15 text-text-muted border-glass-border"}`}> | |
| {t.type} | |
| </span> | |
| </td> | |
| <td className="px-3 py-2 text-text-secondary">{t.description || "\u2014"}</td> | |
| <td className="px-3 py-2 text-text-muted">{t.begin}</td> | |
| <td className="px-3 py-2 text-text-muted">{t.end}</td> | |
| <td className="px-3 py-2 text-text-muted">{t.begin && t.end ? t.end - t.begin + 1 : "\u2014"}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function VariantsTable({ variants }: { variants: VariantItem[] }) { | |
| if (!variants.length) return <p className="text-sm text-text-muted">No variants or mutagenesis data.</p>; | |
| return ( | |
| <div className="max-h-80 overflow-y-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Type", "Position", "Description"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {variants.map((v, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2"> | |
| <span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${ | |
| v.type === "Mutagenesis" ? "bg-warn/15 text-warn border-warn/30" : "bg-info/15 text-info border-info/30" | |
| }`}>{v.type}</span> | |
| </td> | |
| <td className="px-3 py-2 text-text-muted">{v.begin}</td> | |
| <td className="px-3 py-2 text-text-secondary max-w-sm">{v.description || "\u2014"}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ); | |
| } | |
| function GOTermsView({ terms }: { terms: GOTerm[] }) { | |
| if (!terms.length) return <p className="text-sm text-text-muted">No Gene Ontology terms.</p>; | |
| const cats = [ | |
| { id: "molecular_function", label: "Molecular Function", color: "text-info" }, | |
| { id: "biological_process", label: "Biological Process", color: "text-good" }, | |
| { id: "cellular_component", label: "Cellular Component", color: "text-accent-purple" }, | |
| ]; | |
| return ( | |
| <div className="space-y-4"> | |
| {cats.map(cat => { | |
| const items = terms.filter(t => t.category === cat.id); | |
| if (!items.length) return null; | |
| return ( | |
| <div key={cat.id}> | |
| <h4 className={`text-xs font-semibold mb-2 ${cat.color}`}>{cat.label} ({items.length})</h4> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {items.map((t, i) => ( | |
| <span key={i} className="px-2 py-1 text-[10px] rounded-lg bg-surface-1 border border-glass-border text-text-secondary"> | |
| {t.id} · {t.term.replace(/^[FPC]:\s*/, "")} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function PathwaysView({ pathways }: { pathways: PathwayAnnotation[] }) { | |
| if (!pathways.length) return <p className="text-sm text-text-muted">No pathway annotations.</p>; | |
| const DB_ICONS: Record<string, string> = { KEGG: "🟠", Reactome: "🔵", WikiPathways: "🟣" }; | |
| return ( | |
| <div className="space-y-2"> | |
| {pathways.map((p, i) => { | |
| const url = p.database === "KEGG" | |
| ? `https://www.genome.jp/entry/${p.id}` | |
| : p.database === "Reactome" | |
| ? `https://reactome.org/content/detail/${p.id}` | |
| : `https://www.wikipathways.org/index.php/${p.id}`; | |
| return ( | |
| <a key={i} href={url} target="_blank" rel="noopener noreferrer" | |
| className="flex items-center gap-3 px-3 py-2 rounded-xl border border-glass-border hover:bg-surface-1 transition-colors"> | |
| <span className="text-lg">{DB_ICONS[p.database] ?? "📋"}</span> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-xs font-medium text-text-primary truncate">{p.name || p.id}</p> | |
| <p className="text-[10px] text-text-muted">{p.database} · {p.id}</p> | |
| </div> | |
| <span className="text-text-muted text-[10px]">\u2197</span> | |
| </a> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function StructureInfoView({ data }: { data: FullAnalysis }) { | |
| return ( | |
| <div className="space-y-4"> | |
| {data.disulfide_bonds.length > 0 && ( | |
| <div> | |
| <h4 className="text-xs font-semibold text-text-primary mb-2">Disulfide Bonds ({data.disulfide_bonds.length})</h4> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {data.disulfide_bonds.map((b, i) => ( | |
| <span key={i} className="px-2 py-1 text-[10px] rounded-lg bg-accent-amber/10 border border-accent-amber/30 text-accent-amber"> | |
| Cys{b.begin}\u2013Cys{b.end} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {data.composition_bias.length > 0 && ( | |
| <div> | |
| <h4 className="text-xs font-semibold text-text-primary mb-2">Composition Bias ({data.composition_bias.length})</h4> | |
| <div className="max-h-40 overflow-y-auto rounded-xl border border-glass-border"> | |
| <table className="w-full text-xs"> | |
| <thead className="bg-surface-1 sticky top-0"> | |
| <tr> | |
| {["Type", "Description", "Start", "End"].map(h => ( | |
| <th key={h} className="px-3 py-2 text-left text-text-muted font-medium">{h}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {data.composition_bias.map((b, i) => ( | |
| <tr key={i} className="border-t border-glass-border hover:bg-surface-1"> | |
| <td className="px-3 py-2 text-text-primary">{b.type}</td> | |
| <td className="px-3 py-2 text-text-secondary">{b.description || "\u2014"}</td> | |
| <td className="px-3 py-2 text-text-muted">{b.begin}</td> | |
| <td className="px-3 py-2 text-text-muted">{b.end}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {!data.disulfide_bonds.length && !data.composition_bias.length && ( | |
| <p className="text-sm text-text-muted">No structural annotations.</p> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /* ---------- export menu ---------- */ | |
| function ExportMenu({ accession, data, domains, svgRef }: { | |
| accession: string; | |
| data: FullAnalysis | null; | |
| domains: Domain[]; | |
| svgRef: React.RefObject<SVGSVGElement>; | |
| }) { | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <button onClick={() => exportSvgPng(svgRef.current, `domains-${accession}.png`)} | |
| className="btn-ghost text-xs px-2 py-1">Export PNG</button> | |
| <button onClick={() => { | |
| const rows: [string, string, string, string, string, string][] = domains.map(d => | |
| [d.accession, d.name, d.source_db, String(d.start), String(d.end), String(d.score ?? "")] | |
| ); | |
| downloadTsv(["Accession", "Name", "DB", "Start", "End", "Score"], rows, `domains-${accession}.tsv`); | |
| }} className="btn-ghost text-xs px-2 py-1">Export TSV</button> | |
| {data && ( | |
| <button onClick={() => { | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = `domains-${accession}.json`; a.click(); | |
| URL.revokeObjectURL(url); | |
| }} className="btn-ghost text-xs px-2 py-1">Export JSON</button> | |
| )} | |
| </div> | |
| ); | |
| } | |