← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/msa/alignment-editing.ts
22.2 KB · Sep 30, 2026 · 23:01 UTC
import { SEQUENCE_VIEWER_LIMITS } from "../runtime-contract";
import { calculatePDistance } from "./guide-tree";
import type {
MsaAnnotationTrack,
MsaDocument,
MsaInsertionRun,
MsaSequenceRow,
} from "./types";
export type AlignmentEngine = "builtin-center-star" | "builtin-pairwise";
export type AlignmentInputSequence = {
description?: string;
id: string;
label: string;
metadata?: Record<string, string>;
sequence: string;
sourceCoordinates?: MsaSequenceRow["sourceCoordinates"];
sourceId?: string;
};
export const ALIGNMENT_ROW_GROUP_METADATA_KEY = "sequenceViewerGroup";
export type AlignmentResult = {
alignedLength: number;
engine: AlignmentEngine;
parameters: {
gapPenalty: number;
matchScore: number;
mismatchScore: number;
};
rows: Array<MsaSequenceRow>;
warning: string;
};
export type AlignmentChangeSet = {
description: string;
operation:
| "add-gap"
| "delete-gap"
| "group-rows"
| "remove-columns"
| "remove-gappy-columns"
| "remove-rows"
| "reorder-rows";
parameters: Record<string, unknown>;
};
const DEFAULT_SCORES = Object.freeze({
gapPenalty: -2,
matchScore: 2,
mismatchScore: -1,
});
export function alignSequences(
input: Array<AlignmentInputSequence>,
scores = DEFAULT_SCORES,
requestedEngine?: AlignmentEngine,
): AlignmentResult {
if (input.length < 2)
throw new Error("Alignment requires at least two sequences.");
const normalized = input.map((item) => ({
...item,
sequence: item.sequence.toUpperCase().replaceAll(/[-.\s]/gu, ""),
}));
if (normalized.some(({ sequence }) => sequence.length === 0))
throw new Error("Alignment input sequences cannot be empty.");
const engine =
requestedEngine ??
(normalized.length === 2 ? "builtin-pairwise" : "builtin-center-star");
if (engine === "builtin-pairwise" && normalized.length !== 2) {
throw new Error(
"Built-in pairwise alignment requires exactly two sequences.",
);
}
if (engine === "builtin-pairwise") {
const pair = needlemanWunsch(
normalized[0]?.sequence ?? "",
normalized[1]?.sequence ?? "",
scores,
);
return resultFromAlignedSequences(
normalized,
[pair.left, pair.right],
"builtin-pairwise",
scores,
);
}
const centerIndex = normalized.reduce(
(selected, current, index, values) =>
current.sequence.length > (values[selected]?.sequence.length ?? 0)
? index
: selected,
0,
);
const center = normalized[centerIndex];
if (center == null)
throw new Error("Alignment center sequence was unavailable.");
const dynamicProgrammingCells = normalized.reduce(
(total, candidate, index) =>
index === centerIndex
? total
: total +
(center.sequence.length + 1) * (candidate.sequence.length + 1),
0,
);
if (
dynamicProgrammingCells >
SEQUENCE_VIEWER_LIMITS.analysis.maxAlignmentDynamicProgrammingCells
) {
throw new Error(
`Center-star alignment is limited to ${SEQUENCE_VIEWER_LIMITS.analysis.maxAlignmentDynamicProgrammingCells.toLocaleString("en-US")} total dynamic-programming cells. Select fewer or shorter sequences.`,
);
}
let masterCenter = center.sequence;
const alignedByIndex = new Map<number, string>([[centerIndex, masterCenter]]);
for (let index = 0; index < normalized.length; index += 1) {
if (index === centerIndex) continue;
const candidate = normalized[index];
if (candidate == null) continue;
const pair = needlemanWunsch(center.sequence, candidate.sequence, scores);
const merged = mergeCenterAlignments({
existing: alignedByIndex,
masterCenter,
pairCenter: pair.left,
pairSequence: pair.right,
});
masterCenter = merged.masterCenter;
alignedByIndex.clear();
for (const [rowIndex, aligned] of merged.existing)
alignedByIndex.set(rowIndex, aligned);
alignedByIndex.set(index, merged.pairSequence);
alignedByIndex.set(centerIndex, masterCenter);
assertAlignmentCellBudget(normalized.length, masterCenter.length);
}
return resultFromAlignedSequences(
normalized,
normalized.map(
(_, index) =>
alignedByIndex.get(index) ?? "-".repeat(masterCenter.length),
),
engine,
scores,
);
}
export function needlemanWunsch(
left: string,
right: string,
{
gapPenalty = DEFAULT_SCORES.gapPenalty,
matchScore = DEFAULT_SCORES.matchScore,
mismatchScore = DEFAULT_SCORES.mismatchScore,
}: Partial<typeof DEFAULT_SCORES> = {},
): { left: string; right: string; score: number } {
const rowCount = left.length + 1;
const columnCount = right.length + 1;
if (rowCount * columnCount > 4_000_000)
throw new Error(
"Pairwise alignment is limited to 4,000,000 dynamic-programming cells.",
);
const scores = new Int32Array(rowCount * columnCount);
const trace = new Uint8Array(rowCount * columnCount);
for (let row = 1; row < rowCount; row += 1) {
scores[row * columnCount] = row * gapPenalty;
trace[row * columnCount] = 1;
}
for (let column = 1; column < columnCount; column += 1) {
scores[column] = column * gapPenalty;
trace[column] = 2;
}
for (let row = 1; row < rowCount; row += 1) {
for (let column = 1; column < columnCount; column += 1) {
const diagonal =
scores[(row - 1) * columnCount + column - 1] +
(left[row - 1]?.toUpperCase() === right[column - 1]?.toUpperCase()
? matchScore
: mismatchScore);
const up = scores[(row - 1) * columnCount + column] + gapPenalty;
const across = scores[row * columnCount + column - 1] + gapPenalty;
const best = Math.max(diagonal, up, across);
const index = row * columnCount + column;
scores[index] = best;
trace[index] = best === diagonal ? 0 : best === up ? 1 : 2;
}
}
const alignedLeft: Array<string> = [];
const alignedRight: Array<string> = [];
let row = left.length;
let column = right.length;
while (row > 0 || column > 0) {
const direction = trace[row * columnCount + column];
if (row > 0 && column > 0 && direction === 0) {
alignedLeft.push(left[row - 1] ?? "-");
alignedRight.push(right[column - 1] ?? "-");
row -= 1;
column -= 1;
} else if (row > 0 && (column === 0 || direction === 1)) {
alignedLeft.push(left[row - 1] ?? "-");
alignedRight.push("-");
row -= 1;
} else {
alignedLeft.push("-");
alignedRight.push(right[column - 1] ?? "-");
column -= 1;
}
}
return {
left: alignedLeft.reverse().join(""),
right: alignedRight.reverse().join(""),
score: scores[left.length * columnCount + right.length] ?? 0,
};
}
export function removeAlignmentColumns(
document: MsaDocument,
start: number,
end: number,
): { change: AlignmentChangeSet; document: MsaDocument } {
const normalizedStart = Math.max(1, Math.min(start, end));
const normalizedEnd = Math.min(document.alignedLength, Math.max(start, end));
if (normalizedStart > normalizedEnd)
throw new Error("The requested alignment-column range is empty.");
const startIndex = normalizedStart - 1;
const endIndex = normalizedEnd;
const removedCount = endIndex - startIndex;
const rows = document.rows.map((row) =>
updateRow(row, removeSlice(row.alignedSequence, startIndex, endIndex)),
);
const annotations = document.annotations.map((track) => ({
...track,
values: removeSlice(track.values, startIndex, endIndex),
}));
const insertions = remapInsertions(document.insertions, startIndex, endIndex);
return {
change: {
description: `Removed alignment columns ${normalizedStart}-${normalizedEnd}.`,
operation: "remove-columns",
parameters: { end: normalizedEnd, start: normalizedStart },
},
document: rebuildDocument(
document,
rows,
annotations,
insertions,
document.alignedLength - removedCount,
),
};
}
export function removeGappyAlignmentColumns(
document: MsaDocument,
minimumGapFraction: number,
): { change: AlignmentChangeSet; document: MsaDocument } {
if (minimumGapFraction < 0 || minimumGapFraction > 1)
throw new Error("Gap fraction must be between 0 and 1.");
const keep = Array.from({ length: document.alignedLength }, (_, column) => {
const gaps = document.rows.filter(({ alignedSequence }) =>
isGap(alignedSequence[column] ?? "-"),
).length;
return (
document.rows.length === 0 ||
gaps === 0 ||
gaps / document.rows.length < minimumGapFraction
);
});
const filterColumns = (value: string) =>
[...value].filter((_, index) => keep[index]).join("");
const rows = document.rows.map((row) =>
updateRow(row, filterColumns(row.alignedSequence)),
);
const annotations = document.annotations.map((track) => ({
...track,
values: filterColumns(track.values),
}));
const columnMap = new Map<number, number>();
let nextColumn = 0;
keep.forEach((retained, index) => {
if (retained) {
columnMap.set(index, nextColumn);
nextColumn += 1;
}
});
const insertions = document.insertions.flatMap((insertion) => {
const mapped = columnMap.get(insertion.afterAlignmentColumn);
return mapped == null
? []
: [{ ...insertion, afterAlignmentColumn: mapped }];
});
const removedCount = keep.filter((value) => !value).length;
return {
change: {
description: `Removed ${removedCount} columns with gap fraction at or above ${minimumGapFraction}.`,
operation: "remove-gappy-columns",
parameters: { minimumGapFraction, removedCount },
},
document: rebuildDocument(
document,
rows,
annotations,
insertions,
nextColumn,
),
};
}
export function addAlignmentGap(
document: MsaDocument,
rowId: string,
column: number,
): { change: AlignmentChangeSet; document: MsaDocument } {
const row = requireRow(document.rows, rowId);
const columnIndex = Math.max(0, Math.min(document.alignedLength, column - 1));
const rows = document.rows.map((candidate) =>
candidate.id === row.id
? updateRow(
candidate,
`${candidate.alignedSequence.slice(0, columnIndex)}-${candidate.alignedSequence.slice(columnIndex)}`,
)
: updateRow(candidate, `${candidate.alignedSequence}-`),
);
const annotations = document.annotations.map((track) => ({
...track,
values: `${track.values} `,
}));
return {
change: {
description: `Inserted a gap in ${row.label} before column ${columnIndex + 1}.`,
operation: "add-gap",
parameters: { column: columnIndex + 1, rowId: row.id },
},
document: rebuildDocument(
document,
rows,
annotations,
document.insertions,
document.alignedLength + 1,
),
};
}
export function deleteAlignmentGap(
document: MsaDocument,
rowId: string,
column: number,
): { change: AlignmentChangeSet; document: MsaDocument } {
const row = requireRow(document.rows, rowId);
const columnIndex = column - 1;
if (
columnIndex < 0 ||
columnIndex >= document.alignedLength ||
!isGap(row.alignedSequence[columnIndex] ?? "")
)
throw new Error(`${row.label} does not have a gap at column ${column}.`);
const rows = document.rows.map((candidate) =>
candidate.id === row.id
? updateRow(
candidate,
`${candidate.alignedSequence.slice(0, columnIndex)}${candidate.alignedSequence.slice(columnIndex + 1)}-`,
)
: candidate,
);
return {
change: {
description: `Deleted the gap in ${row.label} at column ${column}.`,
operation: "delete-gap",
parameters: { column, rowId: row.id },
},
document: rebuildDocument(
document,
rows,
document.annotations,
document.insertions,
document.alignedLength,
),
};
}
export function removeAlignmentRows(
document: MsaDocument,
rowIds: Array<string>,
): { change: AlignmentChangeSet; document: MsaDocument } {
const selected = new Set(rowIds);
const rows = document.rows.filter(({ id }) => !selected.has(id));
if (rows.length === 0)
throw new Error("An alignment copy must retain at least one row.");
const removed = document.rows.length - rows.length;
if (removed === 0)
throw new Error("No alignment rows matched the requested IDs.");
return {
change: {
description: `Removed ${removed} alignment row${removed === 1 ? "" : "s"}.`,
operation: "remove-rows",
parameters: { rowIds: [...selected] },
},
document: rebuildDocument(
document,
rows,
document.annotations,
document.insertions.filter(({ rowId }) => !selected.has(rowId)),
document.alignedLength,
),
};
}
export function assignAlignmentRowGroup(
document: MsaDocument,
rowIds: Array<string>,
group: string | null,
): { change: AlignmentChangeSet; document: MsaDocument } {
const selected = new Set(rowIds);
const unknown = [...selected].filter(
(id) => !document.rows.some((row) => row.id === id),
);
if (unknown.length > 0) {
throw new Error(`Unknown alignment row ${unknown[0]}.`);
}
const normalizedGroup = group?.trim() || null;
const rows = document.rows.map((row) => {
if (!selected.has(row.id)) return row;
const metadata = { ...(row.metadata ?? {}) };
if (normalizedGroup == null) {
delete metadata[ALIGNMENT_ROW_GROUP_METADATA_KEY];
} else {
metadata[ALIGNMENT_ROW_GROUP_METADATA_KEY] = normalizedGroup;
}
return {
...row,
metadata: Object.keys(metadata).length === 0 ? undefined : metadata,
};
});
return {
change: {
description:
normalizedGroup == null
? `Cleared the row group for ${selected.size} alignment row${selected.size === 1 ? "" : "s"}.`
: `Assigned ${selected.size} alignment row${selected.size === 1 ? "" : "s"} to group ${normalizedGroup}.`,
operation: "group-rows",
parameters: { group: normalizedGroup, rowIds: [...selected] },
},
document: rebuildDocument(
document,
rows,
document.annotations,
document.insertions,
document.alignedLength,
),
};
}
export function reorderAlignmentRows(
document: MsaDocument,
rowIds: Array<string>,
): { change: AlignmentChangeSet; document: MsaDocument } {
if (
new Set(rowIds).size !== document.rows.length ||
rowIds.length !== document.rows.length
)
throw new Error("Row order must contain every alignment row exactly once.");
const byId = new Map(document.rows.map((row) => [row.id, row]));
const rows = rowIds.map((id) => {
const row = byId.get(id);
if (row == null) throw new Error(`Unknown alignment row ${id}.`);
return row;
});
return {
change: {
description: "Reordered alignment rows.",
operation: "reorder-rows",
parameters: { rowIds },
},
document: rebuildDocument(
document,
rows,
document.annotations,
document.insertions,
document.alignedLength,
),
};
}
export function sortAlignmentRows(
document: MsaDocument,
mode: "group" | "identity-to-reference" | "label" | "tree",
referenceRowId?: string,
treeOrder: Array<string> = [],
): { change: AlignmentChangeSet; document: MsaDocument } {
let rowIds: Array<string>;
if (mode === "tree") {
const rank = new Map(treeOrder.map((id, index) => [id, index]));
rowIds = [...document.rows]
.sort(
(left, right) =>
(rank.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
(rank.get(right.id) ?? Number.MAX_SAFE_INTEGER) ||
left.label.localeCompare(right.label),
)
.map(({ id }) => id);
} else if (mode === "identity-to-reference") {
const reference = requireRow(
document.rows,
referenceRowId ?? document.rows[0]?.id ?? "",
);
rowIds = [...document.rows]
.sort(
(left, right) =>
calculatePDistance(left.alignedSequence, reference.alignedSequence) -
calculatePDistance(
right.alignedSequence,
reference.alignedSequence,
) || left.label.localeCompare(right.label),
)
.map(({ id }) => id);
} else if (mode === "group") {
rowIds = [...document.rows]
.sort((left, right) => {
const leftGroup =
left.metadata?.[ALIGNMENT_ROW_GROUP_METADATA_KEY] ?? "";
const rightGroup =
right.metadata?.[ALIGNMENT_ROW_GROUP_METADATA_KEY] ?? "";
return (
leftGroup.localeCompare(rightGroup) ||
left.label.localeCompare(right.label)
);
})
.map(({ id }) => id);
} else {
rowIds = [...document.rows]
.sort((left, right) => left.label.localeCompare(right.label))
.map(({ id }) => id);
}
return reorderAlignmentRows(document, rowIds);
}
export function exportAlignedFasta(rows: Array<MsaSequenceRow>): string {
return `${rows.map((row) => `>${row.label}${row.description == null ? "" : ` ${row.description}`}\n${row.alignedSequence}`).join("\n")}\n`;
}
function mergeCenterAlignments({
existing,
masterCenter,
pairCenter,
pairSequence,
}: {
existing: Map<number, string>;
masterCenter: string;
pairCenter: string;
pairSequence: string;
}): {
existing: Map<number, string>;
masterCenter: string;
pairSequence: string;
} {
const nextExisting = new Map<number, Array<string>>(
[...existing.keys()].map((key) => [key, []]),
);
const nextCenter: Array<string> = [];
const nextPair: Array<string> = [];
let masterIndex = 0;
let pairIndex = 0;
while (masterIndex < masterCenter.length || pairIndex < pairCenter.length) {
const masterSymbol = masterCenter[masterIndex];
const pairSymbol = pairCenter[pairIndex];
const consumeMaster =
masterIndex < masterCenter.length &&
(pairIndex >= pairCenter.length ||
pairSymbol !== "-" ||
masterSymbol === "-");
const consumePair =
pairIndex < pairCenter.length &&
(masterIndex >= masterCenter.length ||
masterSymbol !== "-" ||
pairSymbol === "-");
const superSymbol = consumeMaster ? masterSymbol ?? "-" : pairSymbol ?? "-";
nextCenter.push(superSymbol);
for (const [key, aligned] of existing) {
nextExisting
.get(key)
?.push(consumeMaster ? aligned[masterIndex] ?? "-" : "-");
}
nextPair.push(consumePair ? pairSequence[pairIndex] ?? "-" : "-");
if (consumeMaster) masterIndex += 1;
if (consumePair) pairIndex += 1;
}
return {
existing: new Map(
[...nextExisting].map(([key, value]) => [key, value.join("")]),
),
masterCenter: nextCenter.join(""),
pairSequence: nextPair.join(""),
};
}
function resultFromAlignedSequences(
input: Array<AlignmentInputSequence>,
aligned: Array<string>,
engine: AlignmentEngine,
parameters: typeof DEFAULT_SCORES,
): AlignmentResult {
const alignedLength = aligned[0]?.length ?? 0;
assertAlignmentCellBudget(aligned.length, alignedLength);
if (aligned.some((sequence) => sequence.length !== alignedLength))
throw new Error("Alignment engine produced unequal row widths.");
return {
alignedLength,
engine,
parameters,
rows: input.map((item, index) =>
updateRow(
{
alignedSequence: aligned[index] ?? "",
description: item.description,
id: item.id,
label: item.label,
metadata: item.metadata,
sourceCoordinates: item.sourceCoordinates,
sourceId: item.sourceId,
ungappedLength: item.sequence.length,
},
aligned[index] ?? "",
),
),
warning:
"Built-in pairwise and center-star alignment is intended for fast exploratory comparison. Use MAFFT, Clustal Omega, or another validated external engine for publication-grade MSA generation.",
};
}
function rebuildDocument(
document: MsaDocument,
rows: Array<MsaSequenceRow>,
annotations: Array<MsaAnnotationTrack>,
insertions: Array<MsaInsertionRun>,
alignedLength: number,
): MsaDocument {
assertAlignmentCellBudget(rows.length, alignedLength);
const symbolCount = rows.length * alignedLength;
const gapCount = rows.reduce(
(count, row) => count + [...row.alignedSequence].filter(isGap).length,
0,
);
return {
...document,
alignedLength,
annotations,
insertions,
rawSummary: {
...document.rawSummary,
gapFraction: symbolCount === 0 ? 0 : gapCount / symbolCount,
maxLabelLength: Math.max(0, ...rows.map(({ label }) => label.length)),
sequenceCount: rows.length,
visibleSequenceCount: rows.filter(({ hidden }) => !hidden).length,
},
rnaStructure: null,
rows,
warnings: [
...document.warnings,
{
code: "edited-copy",
message:
"This in-memory alignment is an edited copy; source bytes were not overwritten. RNA pairing overlays were invalidated by the edit.",
preserved: "preserved",
severity: "info",
},
],
};
}
function remapInsertions(
insertions: Array<MsaInsertionRun>,
startIndex: number,
endIndex: number,
): Array<MsaInsertionRun> {
const removed = endIndex - startIndex;
return insertions.flatMap((insertion) => {
if (
insertion.afterAlignmentColumn >= startIndex &&
insertion.afterAlignmentColumn < endIndex
)
return [];
return [
{
...insertion,
afterAlignmentColumn:
insertion.afterAlignmentColumn >= endIndex
? insertion.afterAlignmentColumn - removed
: insertion.afterAlignmentColumn,
},
];
});
}
function removeSlice(
value: string,
startIndex: number,
endIndex: number,
): string {
return `${value.slice(0, startIndex)}${value.slice(endIndex)}`;
}
function updateRow(
row: MsaSequenceRow,
alignedSequence: string,
): MsaSequenceRow {
return {
...row,
alignedSequence,
ungappedLength: alignedSequence.replaceAll(/[-.]/gu, "").length,
};
}
function requireRow(
rows: Array<MsaSequenceRow>,
rowId: string,
): MsaSequenceRow {
const row = rows.find(({ id }) => id === rowId);
if (row == null) throw new Error(`No alignment row matched ${rowId}.`);
return row;
}
function assertAlignmentCellBudget(
rowCount: number,
alignedLength: number,
): void {
if (rowCount * alignedLength > SEQUENCE_VIEWER_LIMITS.input.maxMsaCells)
throw new Error(
`The resulting alignment would exceed the ${SEQUENCE_VIEWER_LIMITS.input.maxMsaCells.toLocaleString()}-cell viewer budget.`,
);
}
function isGap(symbol: string): boolean {
return symbol === "-" || symbol === ".";
}
SHA-256: 89319a7d4dbd0ccc65aa50bc092537d264ebff74cdaaf5692da62ad052c3af6e