bio-nexus-api / bioai-platform /frontend /src /components /InteractionPanel.tsx
Samad14's picture
feat(export): add downloads to ADMET, MD, pairwise, AlphaFold, docking, pathway, plus 4 new learn topics
527a85d
Raw History Blame Contribute Delete
12 kB
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { fadeUp } from '@/lib/animations';
import { Flask as Beaker, WaveSine as Waves, Hexagon, Lightning as Zap, CaretDown as ChevronDown, CaretRight as ChevronRight, DownloadSimple as Download } from '@phosphor-icons/react';
import type { DockingInteraction } from '@/lib/api';
import { downloadTsv } from '@/lib/export-utils';
interface InteractionPanelProps {
interactions: DockingInteraction;
onHighlight?: (coords: [number, number, number] | null) => void;
}
function HbondTable({ data, onHighlight }: { data: DockingInteraction['hbonds']; onHighlight?: (c: [number, number, number] | null) => void }) {
const [collapsed, setCollapsed] = useState(false);
if (!data.length) return null;
return (
<Section title="Hydrogen Bonds" icon={<Beaker className="w-4 h-4 text-interaction-hbond" />} count={data.length} color="bg-interaction-hbond/20 text-interaction-hbond" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)}>
{!collapsed && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr className="border-t border-white/5 text-white/40 uppercase tracking-wide">
<th className="text-left px-3 py-1.5">Residue</th><th className="text-left px-3 py-1.5">Atom</th><th className="text-left px-3 py-1.5">Distance</th><th className="text-left px-3 py-1.5">Confidence</th>
</tr></thead>
<tbody className="divide-y divide-white/5">
{data.map((item, i) => (
<tr key={i}
className="text-white/80 hover:bg-white/[0.02] cursor-pointer"
onMouseEnter={() => onHighlight?.(item.protein_coords)}
onMouseLeave={() => onHighlight?.(null)}>
<td className="px-3 py-1.5 font-mono">{item.protein_residue}{item.protein_residue_seq}<span className="text-white/30">{item.protein_chain}</span></td>
<td className="px-3 py-1.5 font-mono">{item.protein_atom}</td>
<td className="px-3 py-1.5 font-mono">{item.distance.toFixed(2)}Å</td>
<td className="px-3 py-1.5">
<span className={`px-1.5 py-0.5 rounded text-[10px] ${
item.confidence === 'high' ? 'bg-confidence-high/20 text-confidence-high' :
item.confidence === 'moderate' ? 'bg-confidence-moderate/20 text-confidence-moderate' :
'bg-confidence-low/20 text-confidence-low'
}`}>{item.confidence}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
);
}
function HydrophobicTable({ data, onHighlight }: { data: DockingInteraction['hydrophobic']; onHighlight?: (c: [number, number, number] | null) => void }) {
const [collapsed, setCollapsed] = useState(false);
if (!data.length) return null;
return (
<Section title="Hydrophobic Contacts" icon={<Waves className="w-4 h-4 text-interaction-hydrophobic" />} count={data.length} color="bg-interaction-hydrophobic/20 text-interaction-hydrophobic" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)}>
{!collapsed && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr className="border-t border-white/5 text-white/40 uppercase tracking-wide">
<th className="text-left px-3 py-1.5">Residue</th><th className="text-left px-3 py-1.5">Atom</th><th className="text-left px-3 py-1.5">Distance</th>
</tr></thead>
<tbody className="divide-y divide-white/5">
{data.map((item, i) => (
<tr key={i}
className="text-white/80 hover:bg-white/[0.02] cursor-pointer"
onMouseEnter={() => onHighlight?.(item.protein_coords)}
onMouseLeave={() => onHighlight?.(null)}>
<td className="px-3 py-1.5 font-mono">{item.protein_residue}{item.protein_residue_seq}<span className="text-white/30">{item.protein_chain}</span></td>
<td className="px-3 py-1.5 font-mono">{item.protein_atom}</td>
<td className="px-3 py-1.5 font-mono">{item.distance.toFixed(2)}Å</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
);
}
function PiStackTable({ data, onHighlight }: { data: DockingInteraction['pi_stacking']; onHighlight?: (c: [number, number, number] | null) => void }) {
const [collapsed, setCollapsed] = useState(false);
if (!data.length) return null;
return (
<Section title="Pi-Stacking" icon={<Hexagon className="w-4 h-4 text-interaction-pi" />} count={data.length} color="bg-interaction-pi/20 text-interaction-pi" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)}>
{!collapsed && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr className="border-t border-white/5 text-white/40 uppercase tracking-wide">
<th className="text-left px-3 py-1.5">Residue</th><th className="text-left px-3 py-1.5">Type</th><th className="text-left px-3 py-1.5">Distance</th><th className="text-left px-3 py-1.5">Angle</th><th className="text-left px-3 py-1.5">Confidence</th>
</tr></thead>
<tbody className="divide-y divide-white/5">
{data.map((item, i) => (
<tr key={i}
className="text-white/80 hover:bg-white/[0.02] cursor-pointer"
onMouseEnter={() => onHighlight?.(item.ring_centroid)}
onMouseLeave={() => onHighlight?.(null)}>
<td className="px-3 py-1.5 font-mono">{item.protein_residue}{item.protein_residue_seq}<span className="text-white/30">{item.protein_chain}</span></td>
<td className="px-3 py-1.5 font-mono">{item.stacking_type}</td>
<td className="px-3 py-1.5 font-mono">{item.distance.toFixed(2)}Å</td>
<td className="px-3 py-1.5 font-mono">{item.angle.toFixed(1)}°</td>
<td className="px-3 py-1.5">
<span className={`px-1.5 py-0.5 rounded text-[10px] ${
item.confidence === 'high' ? 'bg-confidence-high/20 text-confidence-high' :
'bg-confidence-moderate/20 text-confidence-moderate'
}`}>{item.confidence}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
);
}
function SaltBridgeTable({ data, onHighlight }: { data: DockingInteraction['salt_bridges']; onHighlight?: (c: [number, number, number] | null) => void }) {
const [collapsed, setCollapsed] = useState(false);
if (!data.length) return null;
return (
<Section title="Salt Bridges" icon={<Zap className="w-4 h-4 text-interaction-salt" />} count={data.length} color="bg-interaction-salt/20 text-interaction-salt" collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)}>
{!collapsed && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead><tr className="border-t border-white/5 text-white/40 uppercase tracking-wide">
<th className="text-left px-3 py-1.5">Residue</th><th className="text-left px-3 py-1.5">Atom</th><th className="text-left px-3 py-1.5">Distance</th><th className="text-left px-3 py-1.5">Charge</th>
</tr></thead>
<tbody className="divide-y divide-white/5">
{data.map((item, i) => (
<tr key={i}
className="text-white/80 hover:bg-white/[0.02] cursor-pointer"
onMouseEnter={() => onHighlight?.(item.protein_coords)}
onMouseLeave={() => onHighlight?.(null)}>
<td className="px-3 py-1.5 font-mono">{item.protein_residue}{item.protein_residue_seq}<span className="text-white/30">{item.protein_chain}</span></td>
<td className="px-3 py-1.5 font-mono">{item.protein_atom}</td>
<td className="px-3 py-1.5 font-mono">{item.distance.toFixed(2)}Å</td>
<td className="px-3 py-1.5 font-mono text-[10px]">{item.charge_pair}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
);
}
function Section({ title, icon, count, color, collapsed, onToggle, children }: {
title: string;
icon: React.ReactNode;
count: number;
color: string;
collapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="border border-white/10 rounded-lg overflow-hidden">
<button onClick={onToggle} className="w-full flex items-center justify-between px-3 py-2 text-left hover:bg-white/5 transition-colors">
<div className="flex items-center gap-2 text-sm font-medium text-white/90">
{icon}
<span>{title}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${color}`}>{count}</span>
</div>
{collapsed ? <ChevronRight className="w-4 h-4 text-white/40" /> : <ChevronDown className="w-4 h-4 text-white/40" />}
</button>
{children}
</div>
);
}
export function InteractionPanel({ interactions, onHighlight }: InteractionPanelProps) {
const total =
(interactions.hbonds?.length || 0) +
(interactions.hydrophobic?.length || 0) +
(interactions.pi_stacking?.length || 0) +
(interactions.salt_bridges?.length || 0);
if (!total) return null;
const exportInteractions = () => {
const rows: string[][] = [];
interactions.hbonds.forEach((it) => rows.push(['hbond', it.protein_residue + it.protein_residue_seq + it.protein_chain, it.protein_atom, it.ligand_atom, it.distance.toFixed(2), it.confidence, '']));
interactions.hydrophobic.forEach((it) => rows.push(['hydrophobic', it.protein_residue + it.protein_residue_seq + it.protein_chain, it.protein_atom, it.ligand_atom, it.distance.toFixed(2), '', '']));
interactions.pi_stacking.forEach((it) => rows.push(['pi_stacking', it.protein_residue + it.protein_residue_seq + it.protein_chain, '', '', it.distance.toFixed(2), it.confidence, it.angle.toFixed(1)]));
interactions.salt_bridges.forEach((it) => rows.push(['salt_bridge', it.protein_residue + it.protein_residue_seq + it.protein_chain, it.protein_atom, it.ligand_atom, it.distance.toFixed(2), '', it.charge_pair]));
downloadTsv(
['Type', 'Residue', 'Protein atom', 'Ligand atom', 'Distance (A)', 'Confidence', 'Note'],
rows,
`docking_interactions_${total}.tsv`,
);
};
return (
<motion.div variants={fadeUp} initial={{ y: 24 }} animate="show" className="data-card p-5">
<h3 className="text-sm font-semibold text-text-primary mb-3 flex items-center gap-2">
<Hexagon className="w-4 h-4 text-accent-cyan" />
Interaction Fingerprint
<span className="text-xs font-normal text-text-muted">({total} contacts)</span>
<button
onClick={exportInteractions}
title="Download interactions as TSV"
className="ml-auto text-xs px-2 py-1 rounded bg-surface-1 border border-glass-border text-text-secondary hover:text-accent-cyan transition-colors flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" /> TSV
</button>
</h3>
<div className="space-y-2">
<HbondTable data={interactions.hbonds} onHighlight={onHighlight} />
<HydrophobicTable data={interactions.hydrophobic} onHighlight={onHighlight} />
<PiStackTable data={interactions.pi_stacking} onHighlight={onHighlight} />
<SaltBridgeTable data={interactions.salt_bridges} onHighlight={onHighlight} />
</div>
</motion.div>
);
}