File size: 8,458 Bytes
f0b48d6
 
6c03381
f0b48d6
349f22c
f0b48d6
cc7ed3f
13df1a1
f0b48d6
b3c481f
fe5ce87
bac1801
c83d9b9
ec313db
4de1e1a
6c03381
bac1801
aa498ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0b48d6
cc7ed3f
 
 
 
 
 
 
 
 
 
f0b48d6
 
 
cc7ed3f
f0b48d6
 
 
b3c481f
f0b48d6
 
aa498ec
 
 
 
 
b3c481f
 
cc7ed3f
f0b48d6
 
 
 
cc7ed3f
f0b48d6
cc7ed3f
13df1a1
b3c481f
cc7ed3f
b3c481f
f0b48d6
 
 
 
 
aa498ec
 
 
 
f0b48d6
 
 
 
6c03381
 
 
 
cc7ed3f
6c03381
 
 
 
 
 
 
 
 
 
 
f0b48d6
cc7ed3f
 
 
 
 
 
6c03381
f0b48d6
 
aa498ec
6c03381
f0b48d6
 
 
 
aa498ec
f0b48d6
 
aa498ec
f0b48d6
 
6c03381
f0b48d6
 
6c03381
f0b48d6
 
 
 
150c960
f0b48d6
 
 
 
 
150c960
f0b48d6
cc7ed3f
f0b48d6
 
 
bac1801
4de1e1a
bac1801
150c960
cc7ed3f
 
ec313db
 
 
bac1801
 
6c03381
bac1801
 
 
 
 
6c03381
bac1801
 
 
 
 
 
f0b48d6
 
 
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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
'use client';

import { useState } from 'react';
import { motion } from 'framer-motion';
import { CircleNotch as LoaderCircle } from '@phosphor-icons/react';
import { fadeUp } from '@/lib/animations';
import { runAlignment, type AlignmentMethod } from '@/lib/api';
import { extractErrorMessage } from '@/lib/errors';
import type { AlignmentResult } from '@/lib/api';
import { useAuditTrail } from '@/hooks/useAuditTrail';
import PhyloTreeViewer from '@/components/phylo/PhyloTreeViewer';
import { ConservationTrack } from '@/components/alignment/ConservationTrack';
import { AlignmentStatsBar } from '@/components/alignment/AlignmentStatsBar';
import { AlignmentBlock } from '@/components/alignment/AlignmentBlock';
import { computeAlignmentStats, parseAlignedFasta } from '@/lib/alignment-stats';
import { BackButton, CriticalButton, ClaySegmented, FlatTextarea, PageHeader } from '@/components/ui';

const SAMPLE_PROTEIN = `>p53_human
MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD
>p53_mouse
MEEPQSDPSIEPPLSQETFSDLWKLLPENNVLSPLPSQAVDDLMLSPDDLAQWFTEDPGPDEAPRMSEAAPPAAPAPAAPTPAAPAPAPSWPLSSFVPSQKTYQGNYGFHLGFLQSGTAKSVMCTYSPPLNKLFCQLAKTCPVQLWVSATPPAGSRVRAMAIYKKSQHMTEVVRRCPHHERCSDSSDGLAPPQHLIRVEGNLRAEYLDDRNTFRHSIVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRDSFEVRVCACPGRDRRTEEENFKKKEPCPEPPPGSTRALGSTSTSSPTPKKKPLDGEYFTLKIRGRERFEMFRELNEALELKDAHATEEPFGGSRAHSSHLKSKKGQSTSRHKKFKKTADPSS`;
const SAMPLE_DNA = `>seq_human
AGCTAGCTAGCTAGCTAGCTAGCTAGCTAGCT
>seq_chimp
AGCTAGCTAGCTAGCTAGCCAGCTAGCTAGCT`;

function parseFasta(text: string): { headers: string[]; sequences: string[] } {
  const lines = text.split('\n');
  const headers: string[] = [];
  const sequences: string[] = [];
  let currentHeader = '';
  let currentSeq = '';
  for (const line of lines) {
    if (line.startsWith('>')) {
      if (currentHeader) {
        headers.push(currentHeader);
        sequences.push(currentSeq);
      }
      currentHeader = line.slice(1).trim();
      currentSeq = '';
    } else if (currentHeader) {
      currentSeq += line.trim();
    }
  }
  if (currentHeader) {
    headers.push(currentHeader);
    sequences.push(currentSeq);
  }
  return { headers, sequences };
}

function validateFasta(text: string): string | null {
  if (!text.trim()) return 'Enter sequences in FASTA format';
  const { headers, sequences } = parseFasta(text);
  if (headers.length < 2) return 'Provide at least 2 sequences in FASTA format (each starting with >)';
  const uniqueSeqs = new Set(sequences.map(s => s.toUpperCase().replace(/[^A-Z]/g, '')));
  if (uniqueSeqs.size < 2) return 'Sequences are identical — provide different sequences for alignment';
  for (let i = 0; i < sequences.length; i++) {
    const clean = sequences[i].replace(/[^A-Za-z]/g, '');
    if (clean.length < 4) return `Sequence "${headers[i]}" is too short (min 4 residues)`;
  }
  return null;
}

const METHOD_LABELS: Record<AlignmentMethod, string> = {
  clustalo: 'Clustal Omega',
  muscle: 'MUSCLE',
  kalign: 'Kalign',
  mafft: 'MAFFT',
  tcoffee: 'T-Coffee',
};

const METHOD_OPTIONS = (Object.entries(METHOD_LABELS) as [AlignmentMethod, string][]).map(([value, label]) => ({ value, label }));

export default function AlignmentPage() {
  const [input, setInput] = useState('');
  const [stype, setStype] = useState('protein');
  const [method, setMethod] = useState<AlignmentMethod>('clustalo');
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<AlignmentResult | null>(null);
  const [error, setError] = useState<string | null>(null);
  const audit = useAuditTrail();

  const handleSubmit = async () => {
    const validationError = validateFasta(input);
    if (validationError) {
      setError(validationError);
      return;
    }
    const seqCount = parseFasta(input).headers.length;
    const inputSummary = `type:${stype},seqs:${seqCount}`;
    audit.emitStarted('alignment_run', METHOD_LABELS[method], inputSummary);
    setLoading(true);
    setError(null);
    setResult(null);
    try {
      const res = await runAlignment(input, stype, method);
      setResult(res);
      audit.emitSuccess('alignment_run', METHOD_LABELS[method], inputSummary, `job_id:${res?.job_id ?? ''},stype:${res?.stype ?? ''}`);
    } catch (err: unknown) {
      const errMsg = extractErrorMessage(err, 'Alignment failed');
      audit.emitFailed('alignment_run', METHOD_LABELS[method], inputSummary, errMsg);
      setError(errMsg);
    } finally {
      setLoading(false);
    }
  };

  const handleSample = () => {
    setInput(stype === 'protein' ? SAMPLE_PROTEIN : SAMPLE_DNA);
    setError(null);
    setResult(null);
  };

  return (
    <div className="max-w-3xl">
      <BackButton />

      <PageHeader
        title="Multiple Sequence Alignment"
        subtitle="Align two or more protein or DNA sequences with a choice of EBI methods."
      />

      <motion.div variants={fadeUp} initial={{ y: 24 }} animate="show" className="data-card p-5 mb-6 space-y-4">
        <ClaySegmented
          options={[
            { value: 'protein', label: 'Protein' },
            { value: 'dna', label: 'DNA' },
          ] as { value: string; label: string }[]}
          value={stype}
          onChange={(v) => { setStype(v); setError(null); setResult(null); }}
        />

        <ClaySegmented
          options={METHOD_OPTIONS as { value: string; label: string }[]}
          value={method}
          onChange={(v) => { setMethod(v as AlignmentMethod); setError(null); setResult(null); }}
        />

        <FlatTextarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder={`Paste 2+ sequences in FASTA format...\n\n>sequence_1\nMEEPQSDPSVEPPLSQETFSDLWKLLPENN\n>sequence_2\nMEEPQSDPSIEPPLSQETFSDLWKLLPENN`}
          className="w-full h-48 font-mono text-sm text-text-primary"
        />

        <div className="flex gap-3">
          <button
            onClick={handleSample}
            className="text-sm text-accent-cyan hover:text-accent-cyan/80 underline"
          >
            Load sample {stype === 'protein' ? 'p53 (human vs mouse)' : 'DNA sequences'}
          </button>
          <div className="flex-1" />
          <CriticalButton onClick={handleSubmit} disabled={loading || !input.trim()}>
            {loading ? <LoaderCircle className="w-4 h-4 animate-spin" /> : null}
            {loading ? 'Aligning...' : 'Align'}
          </CriticalButton>
        </div>
      </motion.div>

      {error && (
        <motion.div variants={fadeUp} initial={{ y: 24 }} animate="show" className="glass-card p-4 border border-error/20">
          <p className="text-sm text-error">{error}</p>
        </motion.div>
      )}

      {loading && !result && (
        <motion.div variants={fadeUp} initial={{ y: 24 }} animate="show" className="glass-card p-8 text-center">
          <LoaderCircle className="w-6 h-6 animate-spin text-accent-cyan mx-auto mb-3" />
          <p className="text-sm text-text-secondary">Running {METHOD_LABELS[method]} on EBI servers...</p>
        </motion.div>
      )}

      {result && (() => {
        const alignedSeqs = parseAlignedFasta(result.aln_fasta).seqs;
        return (
          <motion.div variants={fadeUp} initial={{ y: 24 }} animate="show" className="space-y-4">
          <div className="data-card p-5">
            <h3 className="text-sm font-semibold text-text-primary mb-3">Alignment (FASTA) — {METHOD_LABELS[result.method as AlignmentMethod] ?? result.method ?? 'Clustal Omega'}</h3>
          <AlignmentStatsBar stats={computeAlignmentStats(alignedSeqs)} className="mb-3" />
          <AlignmentBlock alnFasta={result.aln_fasta} className="max-h-80" />
        </div>

            {result.phylotree && (
              <div className="data-card p-5">
                <PhyloTreeViewer newick={result.phylotree} />
              </div>
            )}

            {alignedSeqs.length >= 2 && (
              <div className="data-card p-5">
                <ConservationTrack alignedSeqs={alignedSeqs} />
              </div>
            )}
          </motion.div>
        );
      })()}
    </div>
  );
}