← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/editing.ts
22.9 KB · Sep 30, 2026 · 23:01 UTC
import { reverseComplementNucleotide } from "../nucleotide-alphabet";
import { createFastqSummary } from "./formats/fastq";
import type {
FastqSummary,
SequenceDocument,
SequenceFeature,
SequenceFeatureSegment,
SequenceQuality,
SequenceRecord,
} from "./types";
export type SequenceEditChange = {
description: string;
operation:
| "delete"
| "insert"
| "replace"
| "reverse-complement"
| "rotate";
parameters: Record<string, unknown>;
};
export type SequenceEditResult = {
change: SequenceEditChange;
document: SequenceDocument;
record: SequenceRecord;
};
export function insertSequence(
document: SequenceDocument,
recordId: string,
coordinate: number,
sequence: string,
): SequenceEditResult {
const record = requireRecord(document, recordId);
const inserted = normalizeEditSequence(sequence, record);
if (inserted.length === 0) throw new Error("Inserted sequence cannot be empty.");
if (coordinate < 1 || coordinate > record.length + 1) throw new Error(`Insertion coordinate must be between 1 and ${record.length + 1}.`);
const quality = requireEditableQuality(document, record);
if (quality != null) {
throw new Error(
"Cannot insert residues into a quality-scored FASTQ read without measured quality scores for the inserted bases.",
);
}
const next = updateRecord(record, {
features: record.features.map((feature) => remapFeatureForInsertion(feature, coordinate, inserted.length)),
sequence: `${record.sequence.slice(0, coordinate - 1)}${inserted}${record.sequence.slice(coordinate - 1)}`,
});
return editResult(document, record, next, {
description: `Inserted ${inserted.length} residues before coordinate ${coordinate}.`,
operation: "insert",
parameters: { coordinate, insertedLength: inserted.length },
});
}
export function deleteSequenceRange(
document: SequenceDocument,
recordId: string,
start: number,
end: number,
): SequenceEditResult {
const record = requireRecord(document, recordId);
const range = validateRange(record, start, end);
if (range.start === 1 && range.end === record.length) throw new Error("A sequence edit copy must retain at least one residue.");
const quality = requireEditableQuality(document, record);
if (quality != null && document.recordInventory?.truncated === true) {
throw new Error(
"Cannot change the length of a partially materialized FASTQ collection while preserving exact aggregate quality statistics.",
);
}
const nextFeatures = record.features.flatMap((feature) => {
const mapped = remapFeatureForDeletion(feature, range.start, range.end);
return mapped == null ? [] : [mapped];
});
const next = updateRecord(record, {
features: nextFeatures,
...(quality == null
? {}
: {
quality: {
ascii: `${quality.ascii.slice(0, range.start - 1)}${quality.ascii.slice(range.end)}`,
phred: [
...quality.phred.slice(0, range.start - 1),
...quality.phred.slice(range.end),
],
},
}),
sequence: `${record.sequence.slice(0, range.start - 1)}${record.sequence.slice(range.end)}`,
});
return editResult(document, record, next, {
description: `Deleted residues ${range.start}-${range.end}.`,
operation: "delete",
parameters: range,
});
}
export function replaceSequenceRange(
document: SequenceDocument,
recordId: string,
start: number,
end: number,
sequence: string,
): SequenceEditResult {
const record = requireRecord(document, recordId);
const range = validateRange(record, start, end);
const replacement = normalizeEditSequence(sequence, record);
if (replacement.length === 0) throw new Error("Replacement sequence cannot be empty.");
const removedLength = range.end - range.start + 1;
const quality = requireEditableQuality(document, record);
if (quality != null && replacement.length !== removedLength) {
throw new Error(
"Cannot change the length of a quality-scored FASTQ read without measured quality scores for every resulting base.",
);
}
const delta = replacement.length - removedLength;
const nextFeatures = record.features.flatMap((feature) => {
if (feature.end < range.start) return [feature];
if (feature.start > range.end) return [shiftFeature(feature, delta)];
if (feature.start >= range.start && feature.end <= range.end) return [];
throw new Error(
`Feature ${feature.label ?? feature.id} partially overlaps the replacement. Delete or edit that annotation first so coordinates are not silently approximated.`,
);
});
const next = updateRecord(record, {
features: nextFeatures,
...(quality == null ? {} : { quality }),
sequence: `${record.sequence.slice(0, range.start - 1)}${replacement}${record.sequence.slice(range.end)}`,
});
return editResult(document, record, next, {
description: `Replaced residues ${range.start}-${range.end} with ${replacement.length} residues.`,
operation: "replace",
parameters: { ...range, replacementLength: replacement.length },
});
}
export function reverseComplementSequenceRange(
document: SequenceDocument,
recordId: string,
start: number,
end: number,
): SequenceEditResult {
const record = requireRecord(document, recordId);
if (record.molecule === "protein" || record.molecule === "unknown") throw new Error("Reverse complement is available only for nucleotide sequences.");
const range = validateRange(record, start, end);
const quality = requireEditableQuality(document, record);
const nextFeatures = record.features.map((feature) => {
if (feature.end < range.start || feature.start > range.end) return feature;
if (feature.start < range.start || feature.end > range.end) throw new Error(`Feature ${feature.label ?? feature.id} crosses the requested reverse-complement boundary.`);
return reverseFeatureWithinRange(feature, range.start, range.end);
});
const next = updateRecord(record, {
features: nextFeatures,
...(quality == null
? {}
: {
quality: {
ascii: `${quality.ascii.slice(0, range.start - 1)}${[...quality.ascii.slice(range.start - 1, range.end)].reverse().join("")}${quality.ascii.slice(range.end)}`,
phred: [
...quality.phred.slice(0, range.start - 1),
...quality.phred.slice(range.start - 1, range.end).reverse(),
...quality.phred.slice(range.end),
],
},
}),
sequence: `${record.sequence.slice(0, range.start - 1)}${reverseComplementNucleotide(record.sequence.slice(range.start - 1, range.end), record.molecule === "rna" ? "rna" : "dna")}${record.sequence.slice(range.end)}`,
});
return editResult(document, record, next, {
description: `Reverse-complemented residues ${range.start}-${range.end}.`,
operation: "reverse-complement",
parameters: range,
});
}
export function rotateCircularSequence(
document: SequenceDocument,
recordId: string,
newOrigin: number,
): SequenceEditResult {
const record = requireRecord(document, recordId);
if (record.topology !== "circular") throw new Error("Sequence rotation requires a record marked circular.");
if (newOrigin < 1 || newOrigin > record.length) throw new Error(`New origin must be between 1 and ${record.length}.`);
if (newOrigin === 1) throw new Error("The sequence already starts at coordinate 1.");
const quality = requireEditableQuality(document, record);
const shift = newOrigin - 1;
const mapCoordinate = (coordinate: number) => ((coordinate - shift - 1 + record.length) % record.length) + 1;
const features = record.features.map((feature) =>
rotateFeature(feature, mapCoordinate, record.length),
);
const next = updateRecord(record, {
features,
...(quality == null
? {}
: {
quality: {
ascii: `${quality.ascii.slice(shift)}${quality.ascii.slice(0, shift)}`,
phred: [
...quality.phred.slice(shift),
...quality.phred.slice(0, shift),
],
},
}),
sequence: `${record.sequence.slice(shift)}${record.sequence.slice(0, shift)}`,
});
return editResult(document, record, next, {
description: `Rotated the circular sequence so source coordinate ${newOrigin} is the new origin.`,
operation: "rotate",
parameters: { newOrigin },
});
}
export function addSequenceFeature(
document: SequenceDocument,
recordId: string,
feature: SequenceFeature,
): SequenceDocument {
const record = requireRecord(document, recordId);
validateFeature(record, feature);
if (record.features.some(({ id }) => id === feature.id)) throw new Error(`Feature ID ${feature.id} already exists.`);
return replaceRecord(
document,
updateRecord(record, {
features: [...record.features, feature],
...(record.quality == null ? {} : { quality: record.quality }),
sequence: record.sequence,
}),
);
}
export function updateSequenceFeature(
document: SequenceDocument,
recordId: string,
featureId: string,
update: Partial<Omit<SequenceFeature, "id">>,
): SequenceDocument {
const record = requireRecord(document, recordId);
const current = record.features.find(({ id }) => id === featureId);
if (current == null) throw new Error(`No feature matched ${featureId}.`);
const next = { ...current, ...update, id: current.id };
validateFeature(record, next);
return replaceRecord(
document,
updateRecord(record, {
features: record.features.map((feature) =>
feature.id === featureId ? next : feature,
),
...(record.quality == null ? {} : { quality: record.quality }),
sequence: record.sequence,
}),
);
}
export function deleteSequenceFeature(
document: SequenceDocument,
recordId: string,
featureId: string,
): SequenceDocument {
const record = requireRecord(document, recordId);
const features = record.features.filter(({ id }) => id !== featureId);
if (features.length === record.features.length) throw new Error(`No feature matched ${featureId}.`);
return replaceRecord(
document,
updateRecord(record, {
features,
...(record.quality == null ? {} : { quality: record.quality }),
sequence: record.sequence,
}),
);
}
function editResult(
document: SequenceDocument,
previous: SequenceRecord,
record: SequenceRecord,
change: SequenceEditChange,
): SequenceEditResult {
const { chromatogram: _chromatogram, ...recordWithoutTrace } = record;
// Coordinate transformations can leave the sequence text unchanged (for
// example a palindrome or periodic circular sequence). Keep that provenance.
const coordinatesChanged =
change.operation === "rotate" ||
change.operation === "reverse-complement" ||
previous.sequence !== record.sequence ||
previous.length !== record.length;
const editedRecord = coordinatesChanged
? { ...recordWithoutTrace, evidenceCoordinatesStale: true }
: recordWithoutTrace;
const nextDocument = replaceRecord(document, editedRecord);
return {
change,
document: {
...nextDocument,
...(document.fastqSummary == null
? {}
: {
fastqSummary: updateEditedFastqSummary(
nextDocument,
document.fastqSummary,
previous,
editedRecord,
),
}),
warnings: [
...document.warnings,
...(previous.chromatogram == null
? []
: [
{
code: "source-trace-not-carried",
message:
"The source chromatogram and its base-call confidence values were not carried into this edited copy. They remain available on the unmodified source read.",
severity: "warning" as const,
},
]),
{
code: "edited-copy",
message: `Record ${previous.sourceLabel} is an edited in-memory copy. The source file was not overwritten.`,
severity: "info",
},
],
},
record: editedRecord,
};
}
function updateRecord(
record: SequenceRecord,
update: Pick<SequenceRecord, "features" | "sequence"> & {
quality?: SequenceQuality;
},
): SequenceRecord {
const nextRecord = {
...record,
...update,
features: update.features.map(normalizeSequenceFeature),
length: update.sequence.length,
};
const { description, quality, topology, ...required } = nextRecord;
return {
...required,
...(description == null ? {} : { description }),
...(quality == null || update.quality == null ? {} : { quality }),
...(topology == null ? {} : { topology }),
};
}
function updateEditedFastqSummary(
document: SequenceDocument,
summary: FastqSummary,
previous: SequenceRecord,
next: SequenceRecord,
): FastqSummary {
if (document.recordInventory?.truncated !== true) {
return createFastqSummary(document.records);
}
if (previous.length !== next.length) {
throw new Error(
"Cannot recalculate exact FASTQ statistics after changing a partially materialized read collection.",
);
}
const countSymbols = (sequence: string, symbols: ReadonlySet<string>) =>
[...sequence].reduce(
(count, symbol) => count + (symbols.has(symbol) ? 1 : 0),
0,
);
const gcSymbols = new Set(["G", "C"]);
const nSymbols = new Set(["N"]);
const gcCount =
Math.round(summary.gcFraction * summary.totalBases) -
countSymbols(previous.sequence, gcSymbols) +
countSymbols(next.sequence, gcSymbols);
const nCount =
Math.round(summary.nFraction * summary.totalBases) -
countSymbols(previous.sequence, nSymbols) +
countSymbols(next.sequence, nSymbols);
return {
...summary,
gcFraction: summary.totalBases === 0 ? 0 : gcCount / summary.totalBases,
nFraction: summary.totalBases === 0 ? 0 : nCount / summary.totalBases,
};
}
function requireEditableQuality(
document: SequenceDocument,
record: SequenceRecord,
): SequenceQuality | undefined {
if (record.quality == null) {
if (document.format === "fastq") {
throw new Error(
"Cannot edit a FASTQ read whose measured base-quality scores were not retained within the inspection budget.",
);
}
return undefined;
}
if (
record.quality.ascii.length !== record.length ||
record.quality.phred.length !== record.length
) {
throw new Error(
"Cannot edit a FASTQ read because its measured quality scores do not align with the source sequence.",
);
}
return record.quality;
}
function normalizeSequenceFeature(feature: SequenceFeature): SequenceFeature {
const {
codonStart,
geneticCodeId,
label,
segments,
sourceLocation,
translation,
translationCoordinateMap,
translationMappingUnavailableReason,
translationSource,
translationTrackReliable,
...required
} = feature;
return {
...required,
...(codonStart == null ? {} : { codonStart }),
...(geneticCodeId == null ? {} : { geneticCodeId }),
...(label == null ? {} : { label }),
...(segments == null
? {}
: {
segments: segments.map(
({ partialEnd, partialStart, remoteAccession, ...segment }) => ({
...segment,
...(partialEnd == null ? {} : { partialEnd }),
...(partialStart == null ? {} : { partialStart }),
...(remoteAccession == null ? {} : { remoteAccession }),
}),
),
}),
...(sourceLocation == null ? {} : { sourceLocation }),
...(translation == null ? {} : { translation }),
...(translationCoordinateMap == null ? {} : { translationCoordinateMap }),
...(translationMappingUnavailableReason == null
? {}
: { translationMappingUnavailableReason }),
...(translationSource == null ? {} : { translationSource }),
...(translationTrackReliable == null ? {} : { translationTrackReliable }),
};
}
function replaceRecord(
document: SequenceDocument,
record: SequenceRecord,
): SequenceDocument {
return {
...document,
records: document.records.map((candidate) =>
candidate.id === record.id ? record : candidate,
),
};
}
function remapFeatureForInsertion(
feature: SequenceFeature,
coordinate: number,
length: number,
): SequenceFeature {
if (feature.end < coordinate) return feature;
if (feature.start >= coordinate) return shiftFeature(feature, length);
return {
...feature,
end: feature.end + length,
segments: feature.segments?.map((segment) =>
segment.end < coordinate
? segment
: segment.start >= coordinate
? shiftSegment(segment, length)
: { ...segment, end: segment.end + length },
),
translationCoordinateMap: undefined,
translationMappingUnavailableReason:
"sequence was inserted inside this feature; recompute translation before using amino-acid coordinates",
translationTrackReliable: false,
};
}
function remapFeatureForDeletion(
feature: SequenceFeature,
start: number,
end: number,
): SequenceFeature | null {
const length = end - start + 1;
if (feature.end < start) return feature;
if (feature.start > end) return shiftFeature(feature, -length);
const segments = (feature.segments ?? [
{ end: feature.end, start: feature.start },
]).flatMap((segment) => {
if (segment.end < start) return [segment];
if (segment.start > end) return [shiftSegment(segment, -length)];
const retained: Array<SequenceFeatureSegment> = [];
if (segment.start < start) retained.push({ ...segment, end: start - 1 });
if (segment.end > end)
retained.push({
...segment,
end: segment.end - length,
start,
});
return retained;
});
if (segments.length === 0) return null;
return {
...feature,
end: Math.max(...segments.map(({ end: segmentEnd }) => segmentEnd)),
segments,
start: Math.min(...segments.map(({ start: segmentStart }) => segmentStart)),
translationCoordinateMap: undefined,
translationMappingUnavailableReason:
"sequence was deleted inside this feature; recompute translation before using amino-acid coordinates",
translationTrackReliable: false,
};
}
function shiftFeature(feature: SequenceFeature, delta: number): SequenceFeature {
return {
...feature,
end: feature.end + delta,
segments: feature.segments?.map((segment) => shiftSegment(segment, delta)),
start: feature.start + delta,
translationCoordinateMap: feature.translationCoordinateMap?.map((entry) => ({
...entry,
codonCoordinates: entry.codonCoordinates.map((coordinate) =>
coordinate + delta,
) as [number, number, number],
displayCoordinate: entry.displayCoordinate + delta,
})),
};
}
function shiftSegment(
segment: SequenceFeatureSegment,
delta: number,
): SequenceFeatureSegment {
return { ...segment, end: segment.end + delta, start: segment.start + delta };
}
function reverseFeatureWithinRange(
feature: SequenceFeature,
rangeStart: number,
rangeEnd: number,
): SequenceFeature {
const mapCoordinate = (coordinate: number) =>
rangeStart + (rangeEnd - coordinate);
const segments = (feature.segments ?? [
{ end: feature.end, start: feature.start },
])
.map((segment) => ({
...segment,
end: mapCoordinate(segment.start),
start: mapCoordinate(segment.end),
}))
.reverse();
return {
...feature,
end: Math.max(...segments.map(({ end }) => end)),
segments,
start: Math.min(...segments.map(({ start }) => start)),
strand: feature.strand === "+" ? "-" : feature.strand === "-" ? "+" : feature.strand,
translationCoordinateMap: undefined,
translationMappingUnavailableReason:
"feature sequence was reverse-complemented; recompute translation before using amino-acid coordinates",
translationTrackReliable: false,
};
}
function rotateFeature(
feature: SequenceFeature,
mapCoordinate: (coordinate: number) => number,
recordLength: number,
): SequenceFeature {
const segments = (feature.segments ?? [
{ end: feature.end, start: feature.start },
]).flatMap((segment) => {
const start = mapCoordinate(segment.start);
const end = mapCoordinate(segment.end);
return start <= end
? [{ ...segment, end, start }]
: [
{ ...segment, end: recordLength, start },
{ ...segment, end, start: 1 },
];
});
return {
...feature,
end: Math.max(...segments.map(({ end }) => end)),
segments,
start: Math.min(...segments.map(({ start }) => start)),
translationCoordinateMap: undefined,
translationMappingUnavailableReason:
"circular origin changed; recompute translation before using amino-acid coordinates",
translationTrackReliable: false,
};
}
function validateRange(
record: SequenceRecord,
start: number,
end: number,
): { end: number; start: number } {
const normalized = { end: Math.max(start, end), start: Math.min(start, end) };
if (
!Number.isSafeInteger(normalized.start) ||
!Number.isSafeInteger(normalized.end) ||
normalized.start < 1 ||
normalized.end > record.length
) {
throw new Error(`Range must be within ${record.sourceLabel} coordinates 1-${record.length}.`);
}
return normalized;
}
function validateFeature(record: SequenceRecord, feature: SequenceFeature): void {
if (feature.start < 1 || feature.end < feature.start || feature.end > record.length)
throw new Error(`Feature ${feature.id} lies outside ${record.sourceLabel} coordinates 1-${record.length}.`);
for (const segment of feature.segments ?? []) {
if (segment.start < feature.start || segment.end > feature.end || segment.end < segment.start)
throw new Error(`Feature ${feature.id} has an invalid segment.`);
}
}
function requireRecord(
document: SequenceDocument,
recordId: string,
): SequenceRecord {
const record = document.records.find(({ id }) => id === recordId);
if (record == null) throw new Error(`No sequence record matched ${recordId}.`);
return record;
}
function normalizeEditSequence(
sequence: string,
record: SequenceRecord,
): string {
const normalized: Array<string> = [];
const alphabet = (() => {
switch (record.molecule) {
case "dna":
return new Set("ACGTRYSWKMBDHVN");
case "rna":
return new Set("ACGURYSWKMBDHVN");
case "nucleic-acid-ambiguous":
return new Set("ACGTURYSWKMBDHVN");
case "protein":
return new Set("ACDEFGHIKLMNPQRSTVWYBXZJUO*");
case "unknown":
throw new Error(
`Cannot safely edit ${record.sourceLabel} because its molecular alphabet is unknown.`,
);
}
})();
for (const symbol of sequence) {
if (symbol === " ") continue;
const upper = symbol.toUpperCase();
if (
!/^[A-Za-z*]$/u.test(symbol) ||
upper.length !== 1 ||
!alphabet.has(upper)
) {
throw new Error(
`Residue ${JSON.stringify(symbol)} is not valid for the ${record.molecule} molecular alphabet.`,
);
}
normalized.push(upper);
}
if (
record.molecule === "nucleic-acid-ambiguous" &&
normalized.includes("T") &&
normalized.includes("U")
) {
throw new Error(
"A nucleotide edit cannot mix DNA thymine and RNA uracil residues.",
);
}
return normalized.join("");
}
SHA-256: cb0dc605c70aeb2778bab5a1e1383c49747c005b57e8730ae0b0bccce294aeee