Spaces:
Running
Running
File size: 7,541 Bytes
5a0e691 2f67ecf 033540e 5a0e691 973dd4c f0992bc 342fa92 2f67ecf 5e98b5a 5a0e691 9a28cd0 5a0e691 cee7881 f0992bc 525a5b5 5a0e691 033540e 525a5b5 5a0e691 9a28cd0 5a0e691 525a5b5 5a0e691 5e98b5a 2a12412 5a0e691 2a12412 5a0e691 342fa92 cee7881 342fa92 00b6857 342fa92 5a0e691 2a12412 9a28cd0 76fb273 2f67ecf 76fb273 2a12412 76fb273 2a12412 cee7881 76fb273 2a12412 76fb273 3154548 76fb273 2a12412 cee7881 2a12412 76fb273 5a0e691 2a12412 76fb273 f0992bc 033540e 525a5b5 033540e fc75164 033540e 5a0e691 76fb273 2f67ecf 76fb273 2f67ecf 5a0e691 | 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 | 'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { CaretDown as ChevronDown, CaretUp as ChevronUp, ArrowSquareOut as ExternalLink, Download, ChartScatter as Scatter, Target } from '@phosphor-icons/react';
import type { BlastHitSummary } from '@/types/pipeline';
import { AlignmentView } from './AlignmentView';
import { PairwiseAlignView } from './PairwiseAlignView';
import { downloadTsv } from '@/lib/export-utils';
import { fadeUp, stagger, cardHover } from '@/lib/animations';
import { confidenceBand, formatEvalue, coverageColor } from '@/lib/confidence';
interface BlastPanelProps {
hits: BlastHitSummary[] | undefined | null;
count: number;
source?: string;
queryLength?: number;
querySequence?: string;
/** Full-length subject sequences keyed by hit accession (e.g. from the MSA). */
fullSequences?: Record<string, string>;
}
/** Looks like a protein (not a clean nucleotide alphabet) -> BLOSUM scoring. */
function looksLikeProtein(seq: string): boolean {
const cleaned = seq.toUpperCase().replace(/[^A-Za-z]/g, '');
if (!cleaned) return false;
return cleaned.split('').some(ch => !'ACGTUN'.includes(ch));
}
export function BlastPanel({ hits, count, source, querySequence, fullSequences }: BlastPanelProps) {
const [expanded, setExpanded] = useState<number | null>(null);
const safeHits = hits ?? [];
/** Best available subject sequence for a hit: the full-length MSA copy when
* present, otherwise the aligned segment (gaps stripped). */
const subjectSequence = (hit: BlastHitSummary): string => {
const full = fullSequences?.[hit.accession] ?? fullSequences?.[hit.accession.replace(/\.\d+$/, '')];
if (full) return full;
return (hit.hit_alignment || hit.midline || '').replace(/-/g, '');
};
return (
<motion.div variants={fadeUp} whileHover={cardHover} className="data-card overflow-hidden">
<div className="px-6 py-4 border-b border-glass-border bg-surface-1 flex items-center justify-between">
<h2 className="font-semibold text-text-primary">
BLAST Hits
<span className="text-xs text-text-muted ml-2 font-normal">({count} found{source ? ` via ${source}` : ''})</span>
</h2>
<div className="flex items-center gap-2">
<button onClick={() => downloadTsv(
["Accession", "Description", "Organism", "E-value", "Identity%", "Coverage%", "Score", "Confidence"],
safeHits.map(h => [h.accession, h.description, h.organism ?? '', h.evalue_raw || String(h.evalue), String(h.identity_pct), String(h.query_coverage_pct ?? ''), String(h.bit_score), confidenceBand(h.evalue).label]),
"blast-hits.tsv"
)} className="btn-ghost text-xs px-2 py-1 flex items-center gap-1">
<Download className="w-3 h-3" /> Export CSV
</button>
<button onClick={() => {
const fasta = safeHits.map(h => `>${h.accession} ${h.description}\n${(h.hit_alignment || h.midline || "").replace(/-/g, "")}`).join("\n");
const a = document.createElement("a");
a.download = "blast-hits.fasta";
a.href = "data:text/fasta;charset=utf-8," + encodeURIComponent(fasta);
a.click();
}} className="btn-ghost text-xs px-2 py-1 flex items-center gap-1">
<Download className="w-3 h-3" /> FASTA
</button>
</div>
</div>
<motion.div variants={stagger} className="divide-y divide-glass-border">
{safeHits.length === 0 ? (
<div className="px-6 py-8 text-center text-sm text-text-muted">No hits to display</div>
) : safeHits.map((hit, i) => {
const band = confidenceBand(hit.evalue);
const isExpanded = expanded === i;
return (
<motion.div key={hit.accession} variants={fadeUp}>
<button
onClick={() => setExpanded(isExpanded ? null : i)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-surface-1 transition"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono bg-surface-1 px-2 py-0.5 rounded text-text-secondary">{hit.accession}</span>
<span className="text-sm text-text-primary truncate">{hit.description}</span>
{hit.organism && <span className="text-xs text-text-muted hidden sm:inline ml-auto">{hit.organism}</span>}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-text-muted">
<span>
E-value:{' '}
<strong className={band.color}>{formatEvalue(hit.evalue, hit.evalue_raw)}</strong>
<span className={`ml-1 px-1.5 py-0.5 rounded-full text-xs ${band.bg} ${band.color}`}>
{band.label}
</span>
</span>
<span>Identity: <strong className="text-text-primary">{hit.identity_pct}%</strong></span>
<span>Coverage: <strong className={coverageColor(hit.query_coverage_pct ?? 0)}>{hit.query_coverage_pct ?? '—'}%</strong></span>
<span>Score: <strong className="text-text-primary">{hit.bit_score}</strong></span>
</div>
</div>
{isExpanded ? <ChevronUp className="w-5 h-5 text-text-muted" /> : <ChevronDown className="w-5 h-5 text-text-muted" />}
</button>
{isExpanded && (
<div className="px-6 pb-4">
<AlignmentView hit={hit} />
<PairwiseAlignView hit={hit} querySequence={querySequence} />
<div className="mt-2 flex flex-wrap items-center gap-2">
<a
href={`/analyze/dotplot?seq_a=${encodeURIComponent(querySequence ?? '')}&seq_b=${encodeURIComponent(subjectSequence(hit))}&scoring=${looksLikeProtein(querySequence ?? '') ? 'blosum62' : 'identity'}`}
className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded border border-glass-border bg-surface-1 text-text-secondary hover:text-accent-cyan transition"
>
<Scatter className="w-3.5 h-3.5" /> Dot plot vs query
</a>
<a
href={`/analyze/motif?sequence=${encodeURIComponent((hit.hit_alignment || hit.midline || '').replace(/-/g, ''))}&uniprot=${encodeURIComponent(hit.accession)}`}
className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded border border-glass-border bg-surface-1 text-text-secondary hover:text-accent-purple transition"
>
<Target className="w-3.5 h-3.5" /> Scan for motifs
</a>
<a
href={`https://www.ncbi.nlm.nih.gov/protein/${hit.accession}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-accent-cyan hover:text-accent-cyan/80 transition"
>
View on NCBI <ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
)}
</motion.div>
);
})}
</motion.div>
</motion.div>
);
}
|