Spaces:
Sleeping
Sleeping
| import { RankedCandidate } from "../api"; | |
| import { useEffect, useMemo, useState } from "react"; | |
| export function CandidateTable({ candidates }: { candidates: RankedCandidate[] }) { | |
| const pageSize = 10; | |
| const [page, setPage] = useState(1); | |
| const [preview, setPreview] = useState<RankedCandidate | null>(null); | |
| const pageCount = Math.max(1, Math.ceil(candidates.length / pageSize)); | |
| const visibleCandidates = useMemo( | |
| () => candidates.slice((page - 1) * pageSize, page * pageSize), | |
| [candidates, page], | |
| ); | |
| useEffect(() => { | |
| setPage(1); | |
| }, [candidates]); | |
| return ( | |
| <section className="results"> | |
| <div className="results-heading"> | |
| <div> | |
| <span className="eyebrow">Candidate intelligence</span> | |
| <h2>Ranked Candidates</h2> | |
| </div> | |
| <span className="count-chip">{candidates.length} structures</span> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Rank</th> | |
| <th>Structure</th> | |
| <th>SMILES</th> | |
| <th>Score</th> | |
| <th>Similarity</th> | |
| <th>Reason</th> | |
| <th>Warnings</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {candidates.length === 0 ? ( | |
| <tr> | |
| <td className="empty-state" colSpan={7}> | |
| No ranked candidates yet. | |
| </td> | |
| </tr> | |
| ) : ( | |
| visibleCandidates.map((item) => ( | |
| <tr key={item.canonical_smiles}> | |
| <td>{item.rank}</td> | |
| <td> | |
| <button | |
| type="button" | |
| aria-label={`Enlarge structure for candidate ${item.rank}`} | |
| className="structure-depiction structure-button" | |
| onClick={() => setPreview(item)} | |
| dangerouslySetInnerHTML={{ __html: item.structure_svg }} | |
| /> | |
| </td> | |
| <td className="mono">{item.canonical_smiles}</td> | |
| <td>{item.score}</td> | |
| <td>{item.similarity}</td> | |
| <td>{item.recommendation}</td> | |
| <td>{item.warnings.join("; ")}</td> | |
| </tr> | |
| )) | |
| )} | |
| </tbody> | |
| </table> | |
| {candidates.length > 0 && ( | |
| <nav className="pagination" aria-label="Candidate pagination"> | |
| <button disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}> | |
| Previous page | |
| </button> | |
| <span> | |
| Page {page} of {pageCount} | |
| </span> | |
| <button disabled={page === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}> | |
| Next page | |
| </button> | |
| </nav> | |
| )} | |
| {preview && ( | |
| <div className="image-modal-backdrop" role="presentation" onClick={() => setPreview(null)}> | |
| <div | |
| aria-label={`Candidate ${preview.rank} structure comparison`} | |
| aria-modal="true" | |
| className="image-modal" | |
| role="dialog" | |
| onClick={(event) => event.stopPropagation()} | |
| > | |
| <div className="results-heading"> | |
| <div> | |
| <span className="eyebrow">Structure preview</span> | |
| <h3>Candidate {preview.rank}</h3> | |
| </div> | |
| <button type="button" onClick={() => setPreview(null)}> | |
| Close | |
| </button> | |
| </div> | |
| <div className="comparison-grid"> | |
| <section> | |
| <h4>Original structure</h4> | |
| <div | |
| aria-label={`Original structure comparison for candidate ${preview.rank}`} | |
| className="structure-preview" | |
| dangerouslySetInnerHTML={{ __html: preview.reference_structure_svg }} | |
| /> | |
| </section> | |
| <section> | |
| <h4>Modified candidate</h4> | |
| <div | |
| aria-label={`Changed candidate atoms highlighted for candidate ${preview.rank}`} | |
| className="structure-preview" | |
| dangerouslySetInnerHTML={{ __html: preview.candidate_change_svg }} | |
| /> | |
| </section> | |
| </div> | |
| <p className="change-note"> | |
| Changed atoms are highlighted in orange on the modified candidate. {preview.change_summary} | |
| </p> | |
| <p className="mono">{preview.canonical_smiles}</p> | |
| </div> | |
| </div> | |
| )} | |
| </section> | |
| ); | |
| } | |