| import React, { useState } from 'react';
|
| import type { DataRecord } from '../../../types/protein';
|
| import { formatTemperature, formatPH } from '../../../utils/format';
|
| import './DataTable.css';
|
|
|
| interface DataTableProps {
|
| data: DataRecord[];
|
| loading: boolean;
|
| total: number;
|
| filtered: number;
|
| }
|
|
|
| type SortField = keyof DataRecord;
|
| type SortDirection = 'asc' | 'desc';
|
|
|
| export const DataTable: React.FC<DataTableProps> = ({ data, loading, total, filtered }) => {
|
| const [sortField, setSortField] = useState<SortField>('rmsd');
|
| const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
| const [currentPage, setCurrentPage] = useState(1);
|
| const itemsPerPage = 50;
|
|
|
| if (loading) {
|
| return <div className="table-loading">Loading data...</div>;
|
| }
|
|
|
| if (data.length === 0) {
|
| return <div className="table-empty">No data to display. Adjust filters to see results.</div>;
|
| }
|
|
|
|
|
| const sortedData = [...data].sort((a, b) => {
|
| const aValue = a[sortField];
|
| const bValue = b[sortField];
|
|
|
| if (typeof aValue === 'number' && typeof bValue === 'number') {
|
| return sortDirection === 'asc' ? aValue - bValue : bValue - aValue;
|
| }
|
|
|
| if (typeof aValue === 'string' && typeof bValue === 'string') {
|
| return sortDirection === 'asc'
|
| ? aValue.localeCompare(bValue)
|
| : bValue.localeCompare(aValue);
|
| }
|
|
|
| return 0;
|
| });
|
|
|
|
|
| const totalPages = Math.ceil(sortedData.length / itemsPerPage);
|
| const startIndex = (currentPage - 1) * itemsPerPage;
|
| const endIndex = startIndex + itemsPerPage;
|
| const paginatedData = sortedData.slice(startIndex, endIndex);
|
|
|
| const handleSort = (field: SortField) => {
|
| if (sortField === field) {
|
| setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
| } else {
|
| setSortField(field);
|
| setSortDirection('asc');
|
| }
|
| setCurrentPage(1);
|
| };
|
|
|
| const getSortIcon = (field: SortField) => {
|
| if (sortField !== field) return '↕';
|
| return sortDirection === 'asc' ? '↑' : '↓';
|
| };
|
|
|
| return (
|
| <div className="data-table-container">
|
| <div className="table-header">
|
| <h3 className="text-xl font-semibold text-slate-900">Alternative observations</h3>
|
| <div className="table-stats">
|
| <span>Showing {paginatedData.length} of {filtered.toLocaleString()} filtered</span>
|
| <span className="separator">|</span>
|
| <span>Total: {total.toLocaleString()}</span>
|
| </div>
|
| </div>
|
|
|
| <div className="table-wrapper">
|
| <table className="data-table">
|
| <thead>
|
| <tr>
|
| <th onClick={() => handleSort('pdb_id_b')}>
|
| PDB ID {getSortIcon('pdb_id_b')}
|
| </th>
|
| <th onClick={() => handleSort('auth_asym_id_b')}>
|
| Chain ID {getSortIcon('auth_asym_id_b')}
|
| </th>
|
| <th onClick={() => handleSort('tm_score')}>
|
| TM-score {getSortIcon('tm_score')}
|
| </th>
|
| <th onClick={() => handleSort('rmsd')}>
|
| RMSD (Å) {getSortIcon('rmsd')}
|
| </th>
|
| <th>
|
| Experimental Method
|
| </th>
|
| <th>
|
| Temp (K)
|
| </th>
|
| <th>
|
| pH
|
| </th>
|
| <th onClick={() => handleSort('binding_status')}>
|
| Binding status {getSortIcon('binding_status')}
|
| </th>
|
| <th onClick={() => handleSort('delta_rosetta')}>
|
| ΔRosetta {getSortIcon('delta_rosetta')}
|
| </th>
|
| <th onClick={() => handleSort('delta_foldx')}>
|
| ΔFoldX {getSortIcon('delta_foldx')}
|
| </th>
|
| <th onClick={() => handleSort('delta_evoef2')}>
|
| ΔEvoEF2 {getSortIcon('delta_evoef2')}
|
| </th>
|
| <th onClick={() => handleSort('delta_rw')}>
|
| ΔRW {getSortIcon('delta_rw')}
|
| </th>
|
| <th onClick={() => handleSort('delta_rw_plus')}>
|
| ΔRW+ {getSortIcon('delta_rw_plus')}
|
| </th>
|
| <th onClick={() => handleSort('state_id_b')}>
|
| State ID {getSortIcon('state_id_b')}
|
| </th>
|
| <th onClick={() => handleSort('state_fidelity')}>
|
| State fidelity {getSortIcon('state_fidelity')}
|
| </th>
|
| <th onClick={() => handleSort('avg_sim')}>
|
| State avg. similarity {getSortIcon('avg_sim')}
|
| </th>
|
| <th onClick={() => handleSort('observation_fidelity')}>
|
| Observation fidelity {getSortIcon('observation_fidelity')}
|
| </th>
|
| <th onClick={() => handleSort('structure_sim')}>
|
| Observation similarity {getSortIcon('structure_sim')}
|
| </th>
|
| </tr>
|
| </thead>
|
| <tbody>
|
| {paginatedData.map((record, index) => (
|
| <tr key={`${record.pdb_id_a}-${record.auth_asym_id_a}-${record.pdb_id_b}-${record.auth_asym_id_b}-${index}`}>
|
| <td>
|
| <span className="chain-id">{record.pdb_id_b.toUpperCase()}</span>
|
| </td>
|
| <td>
|
| <span className="asym-id">{record.auth_asym_id_b}</span>
|
| </td>
|
| <td className="numeric">{record.tm_score.toFixed(3)}</td>
|
| <td className="numeric">{record.rmsd.toFixed(2)}</td>
|
| <td className="experimental-method">
|
| {record.exptl_method || <span className="placeholder">—</span>}
|
| </td>
|
| <td className="numeric">
|
| {formatTemperature(record.temp)}
|
| </td>
|
| <td className="numeric">
|
| {formatPH(record.pH)}
|
| </td>
|
| <td className="capitalize">
|
| {record.binding_status || <span className="placeholder">—</span>}
|
| </td>
|
| {(['delta_rosetta', 'delta_foldx', 'delta_evoef2', 'delta_rw', 'delta_rw_plus'] as const).map(field => {
|
| const val = record[field];
|
| return (
|
| <td key={field} className={`numeric delta-cell${val == null ? '' : val > 0 ? ' delta-pos' : val < 0 ? ' delta-neg' : ''}`}>
|
| {val == null ? <span className="placeholder">—</span> : `${val > 0 ? '+' : ''}${val.toFixed(2)}`}
|
| </td>
|
| );
|
| })}
|
| <td className="font-mono">{record.state_id_b ?? <span className="placeholder">—</span>}</td>
|
| <td>{record.state_fidelity ?? <span className="placeholder">—</span>}</td>
|
| <td className="numeric">{record.avg_sim ?? <span className="placeholder">—</span>}</td>
|
| <td>{record.observation_fidelity ?? <span className="placeholder">—</span>}</td>
|
| <td className="numeric">{record.structure_sim != null ? record.structure_sim.toFixed(3) : <span className="placeholder">—</span>}</td>
|
| </tr>
|
| ))}
|
| </tbody>
|
| </table>
|
| </div>
|
|
|
| {totalPages > 1 && (
|
| <div className="pagination">
|
| <button
|
| onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
| disabled={currentPage === 1}
|
| >
|
| Previous
|
| </button>
|
| <span className="page-info">
|
| Page {currentPage} of {totalPages}
|
| </span>
|
| <button
|
| onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
| disabled={currentPage === totalPages}
|
| >
|
| Next
|
| </button>
|
| </div>
|
| )}
|
| </div>
|
| );
|
| };
|
|
|