55e11ee5939bfbe8b3f7283b1abb337de5a55ae3
max
Tue Sep 15 05:48:03 2026 -0700
hgTracks: the exon and codon mouseovers disagreed on out-of-frame transcripts, refs #38353
An exon's mouseover worked its codon numbers out as (c+2)/3 and the per-codon mouseover took
them from the codon list, which builds them from exonFrames. Where a transcript's annotated
CDS does not begin on a codon boundary the two part company: baseColorCodonsFromGenePred
makes codon 1 short rather than shifting every number after it, so the arithmetic is off by
the missing bases for the rest of the transcript. About 9,000 transcripts in hg38's
wgEncodeGencodeCompV48 and 101 in ncbiRefSeqCurated are affected. hg38.knownGene has no
exonFrames and never was.
The exon note now reads the numbers off the codon list when there is one, and otherwise -
zoomed out past the level that builds it - counts them with the same short first codon in
mind. The per-codon note measures its c. range off the codon's own bases instead of taking
it as 3*codonIndex-2..3*codonIndex, which was wrong for the same reason, and gathers both
pieces of a codon split across an intron.
Checked on hg38 ncbiRefSeqCurated NM_001372106.1, whose first coding exon has frame 1: exon 3
now reads c.369-602 (p.124-201) and the first per-codon box inside it c.369-371 (p.124),
where before the exon said p.123 and the codon box said c.370-372.
Also in this file, two things the review turned up while reading it:
loadTxCdsBatch() capped its IN list at 2000 accessions and dropped the rest without saying
so, which would leave the transcripts past the cap numbered along the genome with nothing to
show for it. It queries in chunks now.
txCodonIndexForCodon()'s comment claimed any of a codon's three bases gives the same answer.
It does not when there is an indel inside the codon, which is the case the whole feature
exists for; the code already uses the 5'-most base, so only the comment was wrong.
genbankCds txCds is zeroed before baseColorTxAliForGenePred() may leave it untouched.
diff --git src/hg/hgTracks/simpleTracks.c src/hg/hgTracks/simpleTracks.c
index 3ee4d114db6..3c1f1b2a517 100644
--- src/hg/hgTracks/simpleTracks.c
+++ src/hg/hgTracks/simpleTracks.c
@@ -2985,31 +2985,136 @@
return splicedBaseCount(lf, lf->start, g + 1);
else
return splicedBaseCount(lf, g, lf->end);
}
static int cdsMrnaPos(struct linkedFeatures *lf, int g)
/* 1-based HGVS c. position of genomic base g, which must lie in the CDS.
* c.1 is the first base of the CDS, distances measured in spliced space. */
{
if (lf->orientation >= 0)
return splicedBaseCount(lf, lf->tallStart, g) + 1;
else
return splicedBaseCount(lf, g + 1, lf->tallEnd) + 1;
}
-static void exonCdsNote(struct linkedFeatures *lf, int s, int e, char *buf, int bufSize)
+static void sfCdsRange(struct linkedFeatures *lf, struct simpleFeature *sf, int *retLo, int *retHi)
+/* The c. positions of the first and last base of sf, low number first. On the minus strand
+ * c. counts the other way along the genome, so the two ends swap. */
+{
+int a = cdsMrnaPos(lf, sf->start), b = cdsMrnaPos(lf, sf->end - 1);
+*retLo = min(a, b);
+*retHi = max(a, b);
+}
+
+static void codonCdsRange(struct linkedFeatures *lf, struct simpleFeature *prev,
+ struct simpleFeature *codon, int *retCStart, int *retCEnd)
+/* The HGVS c. range of one codon from lf->codons, measured rather than computed as
+ * 3*codonIndex-2 .. 3*codonIndex. The two part company whenever the annotated CDS does not
+ * begin on a codon boundary: exonFrames says so, and baseColorCodonsFromGenePred then makes
+ * codon 1 short instead of shifting every number after it, so from there on the arithmetic
+ * is off by the missing bases. A codon split across an intron is two list entries sharing
+ * one codonIndex and they are neighbours in the list, so prev and next are all that have to
+ * be looked at. prev is the list entry before codon, NULL at the head of the list. */
+{
+int lo, hi;
+sfCdsRange(lf, codon, &lo, &hi);
+struct simpleFeature *neighbors[2];
+neighbors[0] = prev;
+neighbors[1] = codon->next;
+int i;
+for (i = 0; i < ArraySize(neighbors); i++)
+ {
+ struct simpleFeature *other = neighbors[i];
+ if ((other == NULL) || (other->codonIndex != codon->codonIndex))
+ continue;
+ int oLo, oHi;
+ sfCdsRange(lf, other, &oLo, &oHi);
+ lo = min(lo, oLo);
+ hi = max(hi, oHi);
+ }
+*retCStart = lo;
+*retCEnd = hi;
+}
+
+static int cdsFirstCodonBases(struct track *tg, struct linkedFeatures *lf)
+/* How many coding bases the transcript's first codon has: 3 normally, but 1 or 2 when the
+ * annotated CDS starts part way into a codon, as a 5'-truncated transcript's does. genePred
+ * records that as the frame of the first coding exon, and baseColorCodonsFromGenePred numbers
+ * the codons accordingly - a short codon 1, not a shifted numbering - so anything turning a
+ * c. position into a p. number has to know about it. 3 when there is no frame to read, which
+ * is also the right answer for every transcript whose CDS starts on a codon boundary. */
+{
+char *type = (tg->tdb != NULL) ? tg->tdb->type : NULL;
+if (type == NULL || !(startsWith("genePred", type) || startsWith("bigGenePred", type)))
+ return 3;
+struct genePred *gp = (struct genePred *)(lf->original);
+/* Same test baseColorCodonsFromGenePred uses to decide whether to trust exonFrames. */
+if (gp == NULL || gp->exonFrames == NULL || gp->optFields < genePredExonFramesFld)
+ return 3;
+int i, i0, iN, iInc;
+if (lf->orientation >= 0)
+ { i0 = 0; iN = gp->exonCount; iInc = 1; }
+else
+ { i0 = gp->exonCount - 1; iN = -1; iInc = -1; }
+for (i = i0; i != iN; i += iInc)
+ {
+ if (gp->exonEnds[i] <= gp->cdsStart || gp->exonStarts[i] >= gp->cdsEnd)
+ continue; // an all-UTR exon, ahead of the first coding one
+ return (gp->exonFrames[i] > 0) ? 3 - gp->exonFrames[i] : 3;
+ }
+return 3;
+}
+
+static int codonForCdsPos(int firstCodonBases, int c)
+/* The 1-based codon number that c. position c falls in. With a full first codon this is the
+ * familiar (c+2)/3; with a short one every codon after it is shifted. */
+{
+if (c <= firstCodonBases)
+ return 1;
+return 2 + (c - firstCodonBases - 1) / 3;
+}
+
+static boolean exonCodonRange(struct linkedFeatures *lf, int cdS, int cdE,
+ int *retP5, int *retP3)
+/* First and last codon number covering the coding interval [cdS, cdE), read off lf->codons
+ * so that the exon's mouseover and the per-codon mouseovers inside it cannot disagree.
+ * FALSE when the track has no codon list, which is when it is not drawing codons at all and
+ * so has no per-codon mouseover to agree with. */
+{
+if (lf->codons == NULL)
+ return FALSE;
+int lo = 0, hi = 0;
+struct simpleFeature *sf;
+for (sf = lf->codons; sf != NULL; sf = sf->next)
+ {
+ if (sf->codonIndex <= 0 || sf->start >= cdE || sf->end <= cdS)
+ continue; // a UTR block (codonIndex 0), or a codon outside this exon
+ if (lo == 0 || sf->codonIndex < lo)
+ lo = sf->codonIndex;
+ if (sf->codonIndex > hi)
+ hi = sf->codonIndex;
+ }
+if (lo == 0)
+ return FALSE;
+*retP5 = lo;
+*retP3 = hi;
+return TRUE;
+}
+
+static void exonCdsNote(struct track *tg, struct linkedFeatures *lf, int s, int e,
+ char *buf, int bufSize)
/* Describe the exon [s,e) of a coding transcript in HGVS c. coordinates: the UTR
* piece(s) as c.-N / c.*N and the coding piece as a c. range together with the
* codon (p.) numbers it covers. This is what the popup shows when we are zoomed
* out too far to label the individual codons. */
{
buf[0] = '\0';
boolean posStrand = (lf->orientation >= 0);
int cdsStart = lf->tallStart, cdsEnd = lf->tallEnd;
/* the exon split into its three possible pieces, in genomic coordinates */
int upS = s, upE = min(e, cdsStart); // left of the CDS
int cdS = max(s, cdsStart), cdE = min(e, cdsEnd);
int dnS = max(s, cdsEnd), dnE = e; // right of the CDS
/* in transcription order the left piece is the 5' UTR on + strand, the 3' on - */
int utr5S = posStrand ? upS : dnS, utr5E = posStrand ? upE : dnE;
int utr3S = posStrand ? dnS : upS, utr3E = posStrand ? dnE : upE;
@@ -3017,31 +3122,40 @@
int len = 0;
if (utr5E > utr5S)
{
utrHgvsCoord(lf, posStrand ? utr5S : utr5E - 1, loBuf, sizeof(loBuf));
utrHgvsCoord(lf, posStrand ? utr5E - 1 : utr5S, hiBuf, sizeof(hiBuf));
if (sameString(loBuf, hiBuf))
safef(buf, bufSize, "5' UTR: c.%s
", loBuf);
else
safef(buf, bufSize, "5' UTR: c.%s_%s
", loBuf, hiBuf);
len = strlen(buf);
}
if (cdE > cdS)
{
int c5 = cdsMrnaPos(lf, posStrand ? cdS : cdE - 1);
int c3 = cdsMrnaPos(lf, posStrand ? cdE - 1 : cdS);
- int p5 = (c5 + 2) / 3, p3 = (c3 + 2) / 3;
+ int p5, p3;
+ if (!exonCodonRange(lf, cdS, cdE, &p5, &p3))
+ {
+ /* No codon list to read the numbers off - the track is zoomed out past the level
+ * that builds one - so count them, taking the same short first codon into account
+ * that the codon list would have. */
+ int firstCodonBases = cdsFirstCodonBases(tg, lf);
+ p5 = codonForCdsPos(firstCodonBases, c5);
+ p3 = codonForCdsPos(firstCodonBases, c3);
+ }
if (p5 == p3)
safef(buf + len, bufSize - len, "Codons: c.%d-%d (p.%d)
", c5, c3, p5);
else
safef(buf + len, bufSize - len, "Codons: c.%d-%d (p.%d-%d)
",
c5, c3, p5, p3);
len = strlen(buf);
}
if (utr3E > utr3S)
{
utrHgvsCoord(lf, posStrand ? utr3S : utr3E - 1, loBuf, sizeof(loBuf));
utrHgvsCoord(lf, posStrand ? utr3E - 1 : utr3S, hiBuf, sizeof(hiBuf));
if (sameString(loBuf, hiBuf))
safef(buf + len, bufSize - len, "3' UTR: c.%s
", loBuf);
else
safef(buf + len, bufSize - len, "3' UTR: c.%s_%s
", loBuf, hiBuf);
@@ -3179,34 +3293,34 @@
if (exonIx < gp->exonCount)
endPhase = gp->exonFrames[exonIx];
}
else
if (exonIx>1)
endPhase = gp->exonFrames[exonIx-2];
// construct a string that tells the user about the codon frame situation of this exon
makeExonFrameText(exonIntronNumber, numExons, startPhase, endPhase, phaseText);
}
if (w > 0) // draw exon or intron if width is greater than 0
{
// draw mapBoxes for the codons if we are zoomed in far enough
if (isExon && lf->codons && zoomedToCdsColorLevel)
{
- struct simpleFeature *codon;
+ struct simpleFeature *codon, *prevCodon = NULL;
struct dyString *codonDy = dyStringNew(0);
int codonS, codonE;
- for (codon = lf->codons; codon != NULL; codon = codon->next)
+ for (codon = lf->codons; codon != NULL; prevCodon = codon, codon = codon->next)
{
codonS = codon->start; codonE = codon->end;
if (codonS > e || codonE < s)
continue; // only write out mouseovers for codons in the current exon
if (codonS <= winEnd && codonE >= winStart)
{
int codonSClp = (codonS < winStart) ? winStart : codonS;
int codonEClp = (codonE > winEnd) ? winEnd : codonE;
int codonsx = round((codonSClp - winStart)*scale) + insideX;
int codonex = round((codonEClp - winStart)*scale) + insideX;
// skip regions entirely outside available picture
// (accounts for space taken by exon arrows buttons)
if (codonsx <= picEnd && codonex >= picStart)
@@ -3214,37 +3328,41 @@
// clip it to avail pic
codonsx = (codonsx < picStart) ? picStart : codonsx;
codonex = (codonex > picEnd) ? picEnd : codonex;
int w = codonex - codonsx;
if (w > 0)
{
// temporarily remove the mouseOver from the lf, since linkedFeatureMapItem will always
// prefer a lf->mouseOver over the itemName
char *oldMouseOver = lf->mouseOver;
lf->mouseOver = NULL;
dyStringClear(codonDy);
// if you change this text, make sure you also change hgTracks.js:mouseOverToLabel
if (!isEmpty(existingText))
dyStringPrintf(codonDy, "Transcript: %s
", existingText);
- int codonHgvsIx = (codon->codonIndex - 1) * 3;
- if (codonHgvsIx >= 0)
- {
- int cStart = codonHgvsIx + 1;
- int cEnd = codonHgvsIx + 3;
+ if (codon->codonIndex > 0)
+ {
+ /* The c. range is measured off the codon's own bases
+ * rather than taken as 3*codonIndex-2 .. 3*codonIndex:
+ * when the annotated CDS does not start on a codon
+ * boundary codon 1 is short, and the arithmetic is
+ * then wrong for the whole transcript. */
+ int cStart, cEnd;
+ codonCdsRange(lf, prevCodon, codon, &cStart, &cEnd);
// a codon is a single amino acid; p. is 1-based like c.
- int pPos = codonHgvsIx / 3 + 1;
+ int pPos = codon->codonIndex;
// the one-letter amino acid was stored on the codon when it
// was translated (cds.c); map it to its three-letter code
char aaLetter = codon->codonAa;
char aaAbbr[8];
char *aaName = NULL;
if (aaLetter == '*')
{
safecpy(aaAbbr, sizeof(aaAbbr), "Ter");
aaName = "termination";
}
else if (aaLetter == 'X') // error/partial codon: nothing to show
aaAbbr[0] = '\0';
else
{
aaToAbbr(aaLetter, aaAbbr, sizeof(aaAbbr));
@@ -3332,31 +3450,31 @@
// non-coding transcript (no CDS): label the exon with its
// spliced HGVS n. nucleotide range instead of a codon note.
boolean posStrand = (lf->orientation >= 0);
int n5 = txMrnaPos(lf, posStrand ? s : e - 1);
int n3 = txMrnaPos(lf, posStrand ? e - 1 : s);
if (n5 == n3)
safef(posBuf, sizeof(posBuf), "Position: n.%d
", n5);
else
safef(posBuf, sizeof(posBuf), "Position: n.%d_%d
",
n5, n3);
}
else
// coding transcript, too far out to draw the codons: give the
// exon's cDNA range and the codons it covers, so a c. or p.
// position can be found without zooming into every exon
- exonCdsNote(lf, s, e, posBuf, sizeof(posBuf));
+ exonCdsNote(tg, lf, s, e, posBuf, sizeof(posBuf));
posNote = posBuf;
}
}
safef(mouseOverText, sizeof(mouseOverText), "Transcript: %s
%s"
"Strand: %s
%s: %s %d of %d %s %d bp
%s",
existingText, posNote, strandStr, exonOrIntron, exonIntronText,
exonIntronNumber, numExonIntrons, lengthLabel, e - s, phaseText);
// temporarily remove the mouseOver from the lf, since linkedFeatureMapItem will always
// prefer a lf->mouseOver over the itemName
char *oldMouseOver = lf->mouseOver;
lf->mouseOver = NULL;
tg->mapItem(tg, hvg, item, mouseOverText, tg->mapItemName(tg, item),
@@ -4551,30 +4669,31 @@
if (psl && baseColorNeedsCodons)
{
boolean isXeno = ((tg->subType == lfSubXeno) || (tg->subType == lfSubChain) ||
startsWith("mrnaBla", tg->table));
int sizeMul = pslIsProtein(psl) ? 3 : 1;
lf->codons = baseColorCodonsFromPsl(lf, psl, sizeMul, isXeno, maxShade, drawOpt, tg);
}
else if (drawOpt > baseColorDrawOff)
{
if (gp && gp->cdsStart != gp->cdsEnd)
{
/* Where the transcript has an indel relative to the genome, counting codons along
* the genome does not give the transcript's own codon numbers. This alignment is
* what lets each codon carry both numbers; NULL for a track with no alignment. */
struct genbankCds txCds;
+ ZeroVar(&txCds); // stays zeroed when there is no alignment to fill it in
struct psl *txAli = baseColorTxAliForGenePred(tg, gp, &txCds);
lf->codons = baseColorCodonsFromGenePred(lf, gp, (drawOpt != baseColorDrawDiffCodons),
cartUsualBooleanClosestToHome(cart, tg->tdb, FALSE, CODON_NUMBERING_SUFFIX, TRUE),
txAli, &txCds);
}
}
if (psl && drawOpt == baseColorDrawCds && !zoomedToCdsColorLevel)
baseColorSetCdsBounds(lf, psl, tg);
tallStart = lf->tallStart;
tallEnd = lf->tallEnd;
if ((tallStart == 0 && tallEnd == 0) && lf->start != 0 && !sameWord(tg->table, "jaxQTL3"))
{
// sometimes a bed <8 will get passed off as a bed 8, tsk tsk
tallStart = lf->start;