File size: 4,937 Bytes
ec313db
 
 
 
 
 
deb8696
ec313db
deb8696
 
ec313db
 
 
 
 
e83084c
ec313db
 
 
 
 
 
 
 
 
 
deb8696
ec313db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deb8696
ec313db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
'use client';

import { useMemo } from 'react';
import { analyzeColumns, parseAlignedFasta } from '@/lib/alignment-stats';

const AA_COLORS: Record<string, string> = {
  A: '#A78BFA', C: '#FBBF24', D: '#EF4444', E: '#EF4444',
  F: '#7C3AED', G: '#848CA4', H: '#60A5FA', I: '#7C3AED',
  K: '#4ADE80', L: '#7C3AED', M: '#FBBF24', N: '#60A5FA',
  P: '#FB923C', Q: '#60A5FA', R: '#4ADE80', S: '#60A5FA',
  T: '#60A5FA', V: '#7C3AED', W: '#7C3AED', Y: '#7C3AED',
};

function aaColor(ch: string): string {
  if (ch === '-') return 'rgba(132,140,164,0.25)';
  return AA_COLORS[ch.toUpperCase()] ?? 'rgb(var(--text-primary))';
}

type CellClass = 'match' | 'mismatch' | 'gap';

function cellClass(ch: string, consensus: string): CellClass {
  if (ch === '-' || ch === '.') return 'gap';
  return ch.toUpperCase() === consensus ? 'match' : 'mismatch';
}

const CELL_BG: Record<CellClass, string> = {
  match: 'rgba(74,222,128,0.16)',
  mismatch: 'rgba(239,68,68,0.12)',
  gap: 'rgba(132,140,164,0.08)',
};

function symbolClass(symbol: string): string {
  if (symbol === '*') return 'text-accent-cyan font-bold';
  if (symbol === ':') return 'text-accent-amber';
  if (symbol === '.') return 'text-text-muted/40';
  return 'text-transparent';
}

interface AlignmentBlockProps {
  /** Aligned FASTA text (headers + equal-length gapped sequences). */
  alnFasta: string;
  /** Extra classes for the scroll container (e.g. max-height). */
  className?: string;
}

/**
 * Colored MSA grid shared by every alignment surface. Each cell is tinted by
 * its relationship to the column consensus — green = matched, red = mismatched,
 * gray = gap — with the per-residue chemistry color kept as the foreground and
 * a CLUSTAL-style consensus row (* : .) beneath the sequences.
 */
export function AlignmentBlock({ alnFasta, className = '' }: AlignmentBlockProps) {
  const { headers, seqs } = useMemo(() => parseAlignedFasta(alnFasta), [alnFasta]);
  const length = seqs[0]?.length ?? 0;
  const columns = useMemo(() => analyzeColumns(seqs), [seqs]);

  if (!seqs.length) return null;

  return (
    <div className={`rounded-xl border border-glass-border bg-surface-1 overflow-auto ${className}`}>
      <div className="min-w-max p-3 font-mono text-xs leading-[1.7]">
        <div className="flex">
          <span className="w-24 shrink-0 pr-3 text-right text-[10px] text-text-muted/60" />
          <span className="whitespace-pre text-[10px] text-text-muted/50">
            {Array.from({ length }).map((_, i) => ((i + 1) % 10 === 0 ? (i + 1) / 10 % 10 : ' ').toString()).join('')}
          </span>
        </div>
        {headers.map((h, i) => (
          <div key={i} className="flex">
            <span
              className="w-24 shrink-0 pr-3 text-right text-[10px] text-accent-cyan/80 truncate"
              title={h}
            >
              {h.length > 12 ? `${h.slice(0, 11)}…` : h}
            </span>
            <span className="whitespace-pre" style={{ color: 'inherit' }}>
              {Array.from(seqs[i] ?? '').map((ch, j) => {
                const cls = cellClass(ch, columns[j]?.consensus ?? '-');
                return (
                  <span key={j} style={{ color: aaColor(ch), backgroundColor: CELL_BG[cls] }}>
                    {ch}
                  </span>
                );
              })}
            </span>
          </div>
        ))}
        <div className="flex">
          <span className="w-24 shrink-0 pr-3 text-right text-[10px] text-accent-amber/80 truncate">
            consensus
          </span>
          <span className="whitespace-pre">
            {columns.map((col, j) => (
              <span key={j} className={symbolClass(col.symbol)}>
                {col.symbol}
              </span>
            ))}
          </span>
        </div>
      </div>

      <div className="px-3 pb-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-[10px] text-text-muted">
        <span className="flex items-center gap-1.5">
          <span className="w-2.5 h-2.5 rounded-sm" style={{ background: CELL_BG.match }} />
          Matched
        </span>
        <span className="flex items-center gap-1.5">
          <span className="w-2.5 h-2.5 rounded-sm" style={{ background: CELL_BG.mismatch }} />
          Mismatched
        </span>
        <span className="flex items-center gap-1.5">
          <span className="w-2.5 h-2.5 rounded-sm" style={{ background: CELL_BG.gap }} />
          Gap
        </span>
        <span className="flex items-center gap-1.5">
          <span className="font-bold text-accent-cyan">*</span> Identical
        </span>
        <span className="flex items-center gap-1.5">
          <span className="text-accent-amber font-bold">:</span> Strongly conserved group
        </span>
        <span className="flex items-center gap-1.5">
          <span className="text-text-muted/50 font-bold">.</span> Weakly conserved
        </span>
      </div>
    </div>
  );
}