← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/analysis.ts

17.7 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import { SEQUENCE_VIEWER_LIMITS } from "../runtime-contract";
import { expandNucleotideSymbol } from "../nucleotide-alphabet";
import { getGeneticCode, translateGeneticCodeCodon } from "./genetic-code";
import { reverseComplement, translateFrame } from "./translation";

export type SequenceStatistics = {
  ambiguousCount: number;
  gcFraction: number | null;
  length: number;
  molecule: "nucleic-acid" | "protein";
  nFraction: number | null;
  symbolCounts: Record<string, number>;
};

export type SequenceOrf = {
  aminoAcidLength: number;
  completeStart: boolean;
  completeStop: boolean;
  end: number;
  frame: 1 | 2 | 3 | -1 | -2 | -3;
  geneticCodeId: number;
  geneticCodeName: string;
  nucleotideSequence: string;
  start: number;
  strand: "+" | "-";
  translation: string;
};

export type RestrictionEnzyme = {
  cutBottom: number;
  cutTop: number;
  id: string;
  name: string;
  recognitionSequence: string;
};

export type RestrictionSite = {
  cutBottom: number;
  cutTop: number;
  end: number;
  enzymeId: string;
  enzymeName: string;
  recognitionSequence: string;
  start: number;
  strand: "+" | "-";
  wrapsOrigin: boolean;
};

export type DigestFragment = {
  end: number;
  length: number;
  start: number;
  wrapsOrigin: boolean;
};

export type PrimerCandidate = {
  end: number;
  gcFraction: number;
  length: number;
  score: number;
  selfComplementarity: number;
  sequence: string;
  start: number;
  strand: "+" | "-";
  tmCelsius: number;
};

export type PrimerPair = {
  forward: PrimerCandidate;
  productEnd: number;
  productLength: number;
  productStart: number;
  reverse: PrimerCandidate;
  score: number;
};

export const COMMON_RESTRICTION_ENZYMES: ReadonlyArray<RestrictionEnzyme> =
  Object.freeze([
    enzyme("EcoRI", "GAATTC", 1, 5),
    enzyme("BamHI", "GGATCC", 1, 5),
    enzyme("HindIII", "AAGCTT", 1, 5),
    enzyme("NotI", "GCGGCCGC", 2, 6),
    enzyme("XhoI", "CTCGAG", 1, 5),
    enzyme("XbaI", "TCTAGA", 1, 5),
    enzyme("SpeI", "ACTAGT", 1, 5),
    enzyme("NheI", "GCTAGC", 1, 5),
    enzyme("PstI", "CTGCAG", 5, 1),
    enzyme("KpnI", "GGTACC", 5, 1),
    enzyme("SacI", "GAGCTC", 5, 1),
    enzyme("SalI", "GTCGAC", 1, 5),
    enzyme("SmaI", "CCCGGG", 3, 3),
    enzyme("ApaI", "GGGCCC", 5, 1),
    enzyme("BglII", "AGATCT", 1, 5),
    enzyme("ClaI", "ATCGAT", 2, 4),
    enzyme("DpnI", "GATC", 2, 2),
    enzyme("HaeIII", "GGCC", 2, 2),
    enzyme("MluI", "ACGCGT", 1, 5),
    enzyme("NcoI", "CCATGG", 1, 5),
    enzyme("NdeI", "CATATG", 2, 4),
    enzyme("SbfI", "CCTGCAGG", 6, 2),
    enzyme("BsaI", "GGTCTC", 7, 11),
    enzyme("BsmBI", "CGTCTC", 7, 11),
  ]);

export function calculateSequenceStatistics(
  sequence: string,
  molecule: "nucleic-acid" | "protein",
): SequenceStatistics {
  const normalized = sequence.toUpperCase().replaceAll(/\s+/gu, "");
  const symbolCounts: Record<string, number> = {};
  for (const symbol of normalized) {
    symbolCounts[symbol] = (symbolCounts[symbol] ?? 0) + 1;
  }
  if (molecule === "protein") {
    const ambiguousCount = [...normalized].filter((symbol) =>
      "BJOUXZ?".includes(symbol),
    ).length;
    return {
      ambiguousCount,
      gcFraction: null,
      length: normalized.length,
      molecule,
      nFraction: null,
      symbolCounts,
    };
  }
  const canonicalCount =
    (symbolCounts.A ?? 0) +
    (symbolCounts.C ?? 0) +
    (symbolCounts.G ?? 0) +
    (symbolCounts.T ?? 0) +
    (symbolCounts.U ?? 0);
  const ambiguousCount = Math.max(0, normalized.length - canonicalCount);
  return {
    ambiguousCount,
    gcFraction:
      normalized.length === 0
        ? 0
        : ((symbolCounts.G ?? 0) + (symbolCounts.C ?? 0)) / normalized.length,
    length: normalized.length,
    molecule,
    nFraction:
      normalized.length === 0 ? 0 : (symbolCounts.N ?? 0) / normalized.length,
    symbolCounts,
  };
}

export function findOpenReadingFrames({
  geneticCodeId = 1,
  includePartial = false,
  maxResults = SEQUENCE_VIEWER_LIMITS.analysis.maxOrfs,
  minAminoAcids = 30,
  sequence,
  strands = "both",
}: {
  geneticCodeId?: number;
  includePartial?: boolean;
  maxResults?: number;
  minAminoAcids?: number;
  sequence: string;
  strands?: "+" | "-" | "both";
}): { items: Array<SequenceOrf>; truncated: boolean } {
  const code = getGeneticCode(geneticCodeId);
  if (code == null) {
    throw new Error(`NCBI genetic code ${geneticCodeId} is not supported.`);
  }
  const normalized = normalizeDna(sequence);
  const orientedSequences = [
    ...(strands === "+" || strands === "both"
      ? [{ sequence: normalized, strand: "+" as const }]
      : []),
    ...(strands === "-" || strands === "both"
      ? [{ sequence: reverseComplement(normalized), strand: "-" as const }]
      : []),
  ];
  const items: Array<SequenceOrf> = [];
  let truncated = false;
  for (const oriented of orientedSequences) {
    for (let offset = 0; offset < 3; offset += 1) {
      const openStarts: Array<number> = [];
      for (
        let index = offset;
        index + 2 < oriented.sequence.length;
        index += 3
      ) {
        const codon = oriented.sequence.slice(index, index + 3);
        if (isUnambiguousStartCodon(codon, code.startCodons)) {
          openStarts.push(index);
        }
        if (translateGeneticCodeCodon(codon, geneticCodeId) !== "*") continue;
        for (const startIndex of openStarts) {
          const aminoAcidLength = (index - startIndex) / 3;
          if (aminoAcidLength < minAminoAcids) continue;
          items.push(
            createOrf({
              completeStart: true,
              completeStop: true,
              endIndex: index + 3,
              geneticCodeId,
              geneticCodeName: code.name,
              orientedSequence: oriented.sequence,
              originalLength: normalized.length,
              startIndex,
              strand: oriented.strand,
            }),
          );
          if (items.length >= maxResults) {
            truncated = true;
            return { items: sortOrfs(items), truncated };
          }
        }
        openStarts.length = 0;
      }
      if (includePartial) {
        for (const startIndex of openStarts) {
          const endIndex =
            oriented.sequence.length -
            ((oriented.sequence.length - startIndex) % 3);
          if ((endIndex - startIndex) / 3 < minAminoAcids) continue;
          items.push(
            createOrf({
              completeStart: true,
              completeStop: false,
              endIndex,
              geneticCodeId,
              geneticCodeName: code.name,
              orientedSequence: oriented.sequence,
              originalLength: normalized.length,
              startIndex,
              strand: oriented.strand,
            }),
          );
          if (items.length >= maxResults) {
            truncated = true;
            return { items: sortOrfs(items), truncated };
          }
        }
      }
    }
  }
  return { items: sortOrfs(items), truncated };
}

export function findRestrictionSites({
  circular = false,
  enzymes = COMMON_RESTRICTION_ENZYMES,
  maxResults = SEQUENCE_VIEWER_LIMITS.analysis.maxRestrictionSites,
  sequence,
}: {
  circular?: boolean;
  enzymes?: ReadonlyArray<RestrictionEnzyme>;
  maxResults?: number;
  sequence: string;
}): { items: Array<RestrictionSite>; truncated: boolean } {
  const normalized = normalizeDna(sequence);
  const items: Array<RestrictionSite> = [];
  const seen = new Set<string>();
  for (const enzymeDefinition of enzymes) {
    const recognitionLength = enzymeDefinition.recognitionSequence.length;
    const searchable = circular
      ? normalized + normalized.slice(0, Math.max(0, recognitionLength - 1))
      : normalized;
    for (const strand of ["+", "-"] as const) {
      const motif =
        strand === "+"
          ? enzymeDefinition.recognitionSequence
          : reverseComplement(enzymeDefinition.recognitionSequence);
      for (
        let index = 0;
        index + motif.length <= searchable.length;
        index += 1
      ) {
        if (
          index >= normalized.length ||
          !matchesIupac(searchable.slice(index, index + motif.length), motif)
        )
          continue;
        const start = index + 1;
        const endUnwrapped = index + motif.length;
        const wrapsOrigin = endUnwrapped > normalized.length;
        const end = wrapsOrigin
          ? endUnwrapped - normalized.length
          : endUnwrapped;
        const key = `${enzymeDefinition.id}:${start}:${end}`;
        if (seen.has(key)) continue;
        seen.add(key);
        const cutBottom =
          index +
          (strand === "+"
            ? enzymeDefinition.cutBottom
            : recognitionLength - enzymeDefinition.cutTop) +
          1;
        const cutTop =
          index +
          (strand === "+"
            ? enzymeDefinition.cutTop
            : recognitionLength - enzymeDefinition.cutBottom) +
          1;
        items.push({
          cutBottom: circular
            ? wrapCoordinate(cutBottom, normalized.length)
            : cutBottom,
          cutTop: circular ? wrapCoordinate(cutTop, normalized.length) : cutTop,
          end,
          enzymeId: enzymeDefinition.id,
          enzymeName: enzymeDefinition.name,
          recognitionSequence: enzymeDefinition.recognitionSequence,
          start,
          strand,
          wrapsOrigin,
        });
        if (items.length >= maxResults) return { items, truncated: true };
      }
    }
  }
  return {
    items: items.sort(
      (left, right) =>
        left.start - right.start ||
        left.enzymeName.localeCompare(right.enzymeName),
    ),
    truncated: false,
  };
}

export function simulateDigest({
  circular,
  sequenceLength,
  sites,
}: {
  circular: boolean;
  sequenceLength: number;
  sites: ReadonlyArray<RestrictionSite>;
}): Array<DigestFragment> {
  if (sequenceLength <= 0) return [];
  const cuts = [
    ...new Set(
      sites.flatMap(({ cutTop }) => {
        if (circular) return [wrapCoordinate(cutTop, sequenceLength)];
        return cutTop >= 1 && cutTop <= sequenceLength ? [cutTop] : [];
      }),
    ),
  ].sort((left, right) => left - right);
  if (cuts.length === 0)
    return [
      {
        end: sequenceLength,
        length: sequenceLength,
        start: 1,
        wrapsOrigin: false,
      },
    ];
  if (!circular) {
    const boundaries = [1, ...cuts, sequenceLength + 1];
    return boundaries.slice(0, -1).flatMap((start, index) => {
      const next = boundaries[index + 1];
      if (next == null || next <= start) return [];
      return [
        { end: next - 1, length: next - start, start, wrapsOrigin: false },
      ];
    });
  }
  return cuts.map((start, index) => {
    const next = cuts[(index + 1) % cuts.length] ?? start;
    const length = next > start ? next - start : sequenceLength - start + next;
    return {
      end: wrapCoordinate(start + length - 1, sequenceLength),
      length,
      start,
      wrapsOrigin: next <= start,
    };
  });
}

export function designPrimerPairs({
  maxPairs = 10,
  maxPrimerLength = 25,
  maxProductLength = 1_500,
  minPrimerLength = 18,
  minProductLength = 80,
  optimumTm = 60,
  sequence,
  targetEnd,
  targetStart,
}: {
  maxPairs?: number;
  maxPrimerLength?: number;
  maxProductLength?: number;
  minPrimerLength?: number;
  minProductLength?: number;
  optimumTm?: number;
  sequence: string;
  targetEnd: number;
  targetStart: number;
}): Array<PrimerPair> {
  const normalized = normalizeDna(sequence);
  const boundedStart = Math.max(1, Math.min(targetStart, normalized.length));
  const boundedEnd = Math.max(
    boundedStart,
    Math.min(targetEnd, normalized.length),
  );
  const forwardCandidates = enumeratePrimerCandidates({
    maxPrimerLength,
    minPrimerLength,
    optimumTm,
    sequence: normalized,
    strand: "+",
    windowEnd: Math.max(minPrimerLength, boundedStart),
    windowStart: Math.max(1, boundedStart - 250),
  });
  const reverseCandidates = enumeratePrimerCandidates({
    maxPrimerLength,
    minPrimerLength,
    optimumTm,
    sequence: normalized,
    strand: "-",
    windowEnd: Math.min(normalized.length, boundedEnd + 250),
    windowStart: Math.min(normalized.length, boundedEnd),
  });
  const pairs: Array<PrimerPair> = [];
  for (const forward of forwardCandidates) {
    for (const reverse of reverseCandidates) {
      const productStart = forward.start;
      const productEnd = reverse.end;
      const productLength = productEnd - productStart + 1;
      if (productLength < minProductLength || productLength > maxProductLength)
        continue;
      pairs.push({
        forward,
        productEnd,
        productLength,
        productStart,
        reverse,
        score:
          forward.score +
          reverse.score +
          Math.abs(forward.tmCelsius - reverse.tmCelsius) * 2,
      });
      if (pairs.length >= SEQUENCE_VIEWER_LIMITS.analysis.maxPrimerCandidates)
        break;
    }
    if (pairs.length >= SEQUENCE_VIEWER_LIMITS.analysis.maxPrimerCandidates)
      break;
  }
  return pairs
    .sort((left, right) => left.score - right.score)
    .slice(0, maxPairs);
}

function enumeratePrimerCandidates({
  maxPrimerLength,
  minPrimerLength,
  optimumTm,
  sequence,
  strand,
  windowEnd,
  windowStart,
}: {
  maxPrimerLength: number;
  minPrimerLength: number;
  optimumTm: number;
  sequence: string;
  strand: "+" | "-";
  windowEnd: number;
  windowStart: number;
}): Array<PrimerCandidate> {
  const candidates: Array<PrimerCandidate> = [];
  for (let start = windowStart; start <= windowEnd; start += 1) {
    for (let length = minPrimerLength; length <= maxPrimerLength; length += 1) {
      const end = start + length - 1;
      if (end > sequence.length || end > windowEnd) continue;
      const genomic = sequence.slice(start - 1, end);
      if (!/^[ACGT]+$/u.test(genomic)) continue;
      const primerSequence =
        strand === "+" ? genomic : reverseComplement(genomic);
      const gcCount = [...primerSequence].filter(
        (symbol) => symbol === "G" || symbol === "C",
      ).length;
      const gcFraction = gcCount / primerSequence.length;
      if (gcFraction < 0.3 || gcFraction > 0.7) continue;
      const tmCelsius = 2 * (primerSequence.length - gcCount) + 4 * gcCount;
      const selfComplementarity = longestComplementaryRun(
        primerSequence,
        reverseComplement(primerSequence),
      );
      if (selfComplementarity >= 8) continue;
      const homopolymerPenalty = /([ACGT])\1{4,}/u.test(primerSequence)
        ? 10
        : 0;
      candidates.push({
        end,
        gcFraction,
        length,
        score:
          Math.abs(tmCelsius - optimumTm) +
          Math.abs(gcFraction - 0.5) * 20 +
          selfComplementarity +
          homopolymerPenalty,
        selfComplementarity,
        sequence: primerSequence,
        start,
        strand,
        tmCelsius,
      });
      if (
        candidates.length >= SEQUENCE_VIEWER_LIMITS.analysis.maxPrimerCandidates
      )
        return candidates;
    }
  }
  return candidates
    .sort((left, right) => left.score - right.score)
    .slice(0, 100);
}

function createOrf({
  completeStart,
  completeStop,
  endIndex,
  geneticCodeId,
  geneticCodeName,
  orientedSequence,
  originalLength,
  startIndex,
  strand,
}: {
  completeStart: boolean;
  completeStop: boolean;
  endIndex: number;
  geneticCodeId: number;
  geneticCodeName: string;
  orientedSequence: string;
  originalLength: number;
  startIndex: number;
  strand: "+" | "-";
}): SequenceOrf {
  const nucleotideSequence = orientedSequence.slice(startIndex, endIndex);
  const rawTranslation = translateFrame(nucleotideSequence, 0, geneticCodeId);
  const translation = rawTranslation.replace(/\*$/u, "");
  const start = strand === "+" ? startIndex + 1 : originalLength - endIndex + 1;
  const end = strand === "+" ? endIndex : originalLength - startIndex;
  const offset = startIndex % 3;
  return {
    aminoAcidLength: translation.length,
    completeStart,
    completeStop,
    end,
    frame: (strand === "+"
      ? offset + 1
      : -(offset + 1)) as SequenceOrf["frame"],
    geneticCodeId,
    geneticCodeName,
    nucleotideSequence,
    start,
    strand,
    translation,
  };
}

function sortOrfs(items: Array<SequenceOrf>): Array<SequenceOrf> {
  return items.sort(
    (left, right) =>
      right.aminoAcidLength - left.aminoAcidLength || left.start - right.start,
  );
}

function normalizeDna(sequence: string): string {
  return sequence.toUpperCase().replaceAll("U", "T").replaceAll(/\s+/gu, "");
}

function isUnambiguousStartCodon(
  codon: string,
  startCodons: ReadonlySet<string>,
): boolean {
  if (/^[ACGT]{3}$/u.test(codon)) return startCodons.has(codon);
  let candidates = [""];
  for (const symbol of codon)
    candidates = candidates.flatMap((prefix) =>
      [...expandNucleotideSymbol(symbol)].map((base) => `${prefix}${base}`),
    );
  return (
    candidates.length > 0 &&
    candidates.every((candidate) => startCodons.has(candidate))
  );
}

function matchesIupac(sequence: string, motif: string): boolean {
  return [...motif].every((symbol, index) =>
    expandNucleotideSymbol(symbol).has(sequence[index] ?? ""),
  );
}

function wrapCoordinate(coordinate: number, length: number): number {
  return length <= 0
    ? coordinate
    : ((((coordinate - 1) % length) + length) % length) + 1;
}

function longestComplementaryRun(left: string, right: string): number {
  let longest = 0;
  for (let offset = -right.length; offset <= left.length; offset += 1) {
    let run = 0;
    for (let index = 0; index < left.length; index += 1) {
      if (left[index] === right[index - offset]) {
        run += 1;
        longest = Math.max(longest, run);
      } else run = 0;
    }
  }
  return longest;
}

function enzyme(
  name: string,
  recognitionSequence: string,
  cutTop: number,
  cutBottom: number,
): RestrictionEnzyme {
  return {
    cutBottom,
    cutTop,
    id: name.toLowerCase(),
    name,
    recognitionSequence,
  };
}

SHA-256: 3746ff3cedcbdb19e3eda445023f31664d0c5035aa897c3d8944de6cd9b9c891