← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/feature-translation.ts

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

↓ Download file

import { complementNucleotideSymbol } from "../nucleotide-alphabet";
import { getGeneticCode, translateGeneticCodeCodon } from "./genetic-code";

import type {
  SequenceFeature,
  SequenceParseWarning,
  SequenceTranslationCoordinate,
} from "./types";

export function enrichCdsFeatures({
  features,
  sequence,
  warnings,
}: {
  features: Array<SequenceFeature>;
  sequence: string;
  warnings: Array<SequenceParseWarning>;
}): Array<SequenceFeature> {
  return features.map((feature) =>
    feature.type.toLowerCase() === "cds"
      ? enrichCdsFeature({ feature, sequence, warnings })
      : feature,
  );
}

export function enrichCdsFeature({
  feature,
  sequence,
  warnings,
}: {
  feature: SequenceFeature;
  sequence: string;
  warnings: Array<SequenceParseWarning>;
}): SequenceFeature {
  const codonStart = parseCodonStart(feature.qualifiers.codon_start);
  const geneticCodeId = parseGeneticCodeId(feature.qualifiers.transl_table);
  const sourceTranslation = normalizeTranslation(
    getSingleQualifier(feature.qualifiers.translation) ?? feature.translation,
  );
  const unavailableReason = feature.translationMappingUnavailableReason;
  const coordinates =
    unavailableReason == null ? getBiologicalCoordinates(feature) : [];
  const offsetCoordinates = coordinates.slice(codonStart - 1);
  const codonCount = Math.floor(offsetCoordinates.length / 3);
  const translation =
    sourceTranslation ??
    translateCoordinates(
      sequence,
      offsetCoordinates,
      geneticCodeId,
      feature.strand,
    ).replace(/\*$/, "");
  let mappingReason = unavailableReason;
  if (getGeneticCode(geneticCodeId) == null) {
    if (sourceTranslation == null) {
      mappingReason ??= `unsupported genetic code ${geneticCodeId}`;
    }
    warnings.push({
      code: "unsupported-genetic-code",
      message:
        sourceTranslation == null
          ? `CDS '${feature.label ?? feature.id}' uses unsupported transl_table ${geneticCodeId}; no source translation was provided, so computed translation and amino-acid mapping are unavailable.`
          : mappingReason == null
            ? `CDS '${feature.label ?? feature.id}' uses unsupported transl_table ${geneticCodeId}; the source translation and its exact codon-coordinate mapping were preserved, but residues cannot be recomputed.`
            : `CDS '${feature.label ?? feature.id}' uses unsupported transl_table ${geneticCodeId}; the source translation remains visible, but amino-acid mapping is unavailable because ${mappingReason}.`,
      severity: "warning",
    });
  }
  if (translation.length > codonCount && mappingReason == null) {
    mappingReason = "translation is longer than the exact CDS codon path";
  }
  const translationCoordinateMap =
    mappingReason == null
      ? createTranslationCoordinateMap(
          offsetCoordinates,
          Math.min(translation.length, codonCount),
        )
      : undefined;
  return {
    ...feature,
    codonStart,
    geneticCodeId,
    translation: translation.length === 0 ? undefined : translation,
    translationCoordinateMap,
    translationMappingUnavailableReason: mappingReason,
    translationSource:
      translation.length === 0
        ? undefined
        : sourceTranslation == null
          ? "computed"
          : "qualifier",
    translationTrackReliable: mappingReason == null,
  };
}

function getBiologicalCoordinates(feature: SequenceFeature): Array<number> {
  const segments = feature.segments ?? [
    { end: feature.end, start: feature.start },
  ];
  const coordinates: Array<number> = [];
  for (const segment of segments) {
    if (segment.remoteAccession != null) continue;
    if (feature.strand === "-") {
      for (
        let coordinate = segment.end;
        coordinate >= segment.start;
        coordinate -= 1
      ) {
        coordinates.push(coordinate);
      }
    } else {
      for (
        let coordinate = segment.start;
        coordinate <= segment.end;
        coordinate += 1
      ) {
        coordinates.push(coordinate);
      }
    }
  }
  return coordinates;
}

function createTranslationCoordinateMap(
  coordinates: Array<number>,
  aminoAcidCount: number,
): Array<SequenceTranslationCoordinate> {
  return Array.from({ length: aminoAcidCount }, (_, index) => {
    const codon = coordinates.slice(index * 3, index * 3 + 3) as [
      number,
      number,
      number,
    ];
    return {
      aminoAcidIndex: index + 1,
      codonCoordinates: codon,
      displayCoordinate: Math.min(...codon),
    };
  });
}

function translateCoordinates(
  sequence: string,
  coordinates: Array<number>,
  geneticCodeId: number,
  strand: SequenceFeature["strand"],
): string {
  if (getGeneticCode(geneticCodeId) == null) return "";
  let translation = "";
  for (let index = 0; index + 2 < coordinates.length; index += 3) {
    const bases = coordinates
      .slice(index, index + 3)
      .map((coordinate) => sequence[coordinate - 1] ?? "N");
    const orientedBases = bases.map((base) => {
      const normalized = base.toUpperCase().replace("U", "T");
      // Coordinates are already in biological order. Negative-strand bases
      // still need complementation after their order is reversed.
      return strand === "-"
        ? complementNucleotideSymbol(normalized, "dna")
        : normalized;
    });
    translation += translateCodon(
      orientedBases.join(""),
      geneticCodeId,
      index === 0,
    );
  }
  return translation;
}

export function translateCodon(
  codon: string,
  geneticCodeId = 1,
  isInitiator = false,
): string {
  return translateGeneticCodeCodon(codon, geneticCodeId, isInitiator);
}

function parseCodonStart(value: string | Array<string> | undefined): 1 | 2 | 3 {
  const parsed = Number(getSingleQualifier(value) ?? 1);
  return parsed === 2 || parsed === 3 ? parsed : 1;
}

function parseGeneticCodeId(value: string | Array<string> | undefined): number {
  const parsed = Number(getSingleQualifier(value) ?? 1);
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}

function normalizeTranslation(value: string | undefined): string | undefined {
  const normalized = value?.replaceAll(/\s+/g, "").toUpperCase();
  return normalized == null || normalized.length === 0 ? undefined : normalized;
}

function getSingleQualifier(
  value: string | Array<string> | undefined,
): string | undefined {
  return Array.isArray(value) ? value[0] : value;
}

SHA-256: 1017db400a5c15030ab89deb050933f860880fbd77b972f21a859d2b81b708f0