Spaces:
Running
Running
File size: 5,382 Bytes
c83d9b9 773cf91 c83d9b9 | 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 | // Alignment statistics helpers — shared by every alignment viewer so the
// matched / mismatched / gaps / length metrics are always shown consistently.
export interface AlignmentStats {
/** Number of alignment columns */
length: number;
/** Columns where every non-gap residue is identical */
matched: number;
/** Columns with >=2 distinct residues and no gaps */
mismatched: number;
/** Columns containing at least one gap character */
gapped: number;
/** Total '-' gap characters across all sequences */
total_gaps: number;
/** matched / length * 100 */
identity_pct: number;
}
const GAP_CHARS = new Set(['-', '.']);
/** Physico-chemical amino acid groups — shared by the conservation track and
* the per-column CLUSTAL-style consensus symbols in the MSA viewer. */
export const PHYSICOCHEMICAL_GROUPS: Record<string, string[]> = {
polar_positive: ['K', 'R', 'H'],
polar_negative: ['D', 'E'],
polar_uncharged: ['S', 'T', 'N', 'Q'],
nonpolar: ['A', 'V', 'I', 'L', 'M', 'F', 'W', 'P'],
special: ['C', 'G', 'Y'],
};
export function groupOf(aa: string): string {
const up = aa.toUpperCase();
for (const [g, members] of Object.entries(PHYSICOCHEMICAL_GROUPS)) {
if (members.includes(up)) return g;
}
return 'other';
}
/** Loosely related groups used for the CLUSTAL '.' (weak conservation) symbol. */
const WEAK_GROUPS: string[][] = [
['A', 'S', 'T', 'P', 'G'],
['N', 'D', 'E', 'Q'],
['R', 'K', 'H'],
['V', 'I', 'L', 'M'],
['F', 'Y', 'W'],
['C'],
];
function weakGroupOf(aa: string): string {
const up = aa.toUpperCase();
for (let i = 0; i < WEAK_GROUPS.length; i++) {
if (WEAK_GROUPS[i].includes(up)) return String(i);
}
return 'other';
}
export interface ColumnAnalysis {
/** Most frequent non-gap residue; '-' when the column is all gaps. */
consensus: string;
/** CLUSTAL conservation symbol: '*' identical, ':' strong, '.' weak, ' ' variable. */
symbol: string;
}
/**
* Per-column analysis of an already-aligned set of sequences. The consensus is
* the majority non-gap residue; the symbol follows the CLUSTALX convention.
*/
export function analyzeColumns(seqs: string[]): ColumnAnalysis[] {
if (!seqs.length) return [];
const L = Math.max(...seqs.map(s => s.length));
const columns: ColumnAnalysis[] = [];
for (let i = 0; i < L; i++) {
const residues: string[] = [];
for (const s of seqs) {
const c = (s[i] ?? '-').toUpperCase();
if (!GAP_CHARS.has(c)) residues.push(c);
}
if (!residues.length) {
columns.push({ consensus: '-', symbol: ' ' });
continue;
}
const freqs = new Map<string, number>();
for (const r of residues) freqs.set(r, (freqs.get(r) ?? 0) + 1);
let consensus = residues[0];
let max = 0;
for (const [r, n] of Array.from(freqs.entries())) {
if (n > max) {
max = n;
consensus = r;
}
}
let symbol = ' ';
if (freqs.size === 1) {
symbol = '*';
} else if (new Set(residues.map(groupOf)).size === 1) {
symbol = ':';
} else if (new Set(residues.map(weakGroupOf)).size === 1) {
symbol = '.';
}
columns.push({ consensus, symbol });
}
return columns;
}
/**
* Compute column-wise statistics from an already-aligned set of sequences
* (equal-length strings). Works for both pairwise (2 sequences) and multiple
* sequence alignments. Columns are classified as:
*
* matched — all non-gap residues identical (invariant column)
* mismatched — >= 2 distinct residues, no gaps (variable column)
* gapped — at least one gap character present
*
* so that matched + mismatched + gapped === length.
*/
export function computeAlignmentStats(seqs: string[]): AlignmentStats {
const empty: AlignmentStats = {
length: 0,
matched: 0,
mismatched: 0,
gapped: 0,
total_gaps: 0,
identity_pct: 0,
};
if (!seqs.length) return empty;
const L = Math.max(...seqs.map(s => s.length));
if (L === 0) return empty;
let matched = 0;
let mismatched = 0;
let gapped = 0;
let totalGaps = 0;
for (let i = 0; i < L; i++) {
const distinct = new Set<string>();
let colHasGap = false;
for (const s of seqs) {
const c = (s[i] ?? '-').toUpperCase();
if (GAP_CHARS.has(c)) {
colHasGap = true;
totalGaps++;
} else {
distinct.add(c);
}
}
if (colHasGap) gapped++;
else if (distinct.size <= 1) matched++;
else mismatched++;
}
return {
length: L,
matched,
mismatched,
gapped,
total_gaps: totalGaps,
identity_pct: L > 0 ? Math.round((matched / L) * 1000) / 10 : 0,
};
}
/**
* Parse an aligned FASTA string into headers and equal-length sequences.
* Shared so every MSA viewer (and stats bar) reads sequences the same way.
*/
export function parseAlignedFasta(fasta: string): { headers: string[]; seqs: string[] } {
const headers: string[] = [];
const seqs: string[] = [];
let header = '';
let seq = '';
for (const line of fasta.split('\n')) {
const t = line.trim();
if (t.startsWith('>')) {
if (header) {
headers.push(header);
seqs.push(seq);
}
header = t.slice(1);
seq = '';
} else if (header) {
seq += t;
}
}
if (header) {
headers.push(header);
seqs.push(seq);
}
return { headers, seqs };
}
|