Skip to content

Commit

Permalink
WIP! [entropy] Negative strand CDSs
Browse files Browse the repository at this point in the history
TODO -- the zooming is reversed for negative strand CDSs (no changes yet
to _rangeLocalZoomCoordinates)

To best accommodate CDSs in a limited space but without wanting and CDSs
to overlap, we now calculate the most efficient stacking of CDSs (to be
shown above/below the nav axis) to minimise vertical space whilst not
having any overlapping CDSs. This is done ahead of time so different
genomes will make the best use of space: genomes without -ve strand CDSs
don't have excess whitespace below the axis, genomes with no overlapping
CDSs use less vertical space etc.

The main axis also uses this stacking approach but purposefully overlaps
genes to save space, and an arrowhead is used to convey strandedness.
This resolves the issue of overlapping text in my test datasets (incl.
HepB) but it may re-occur in some very complex genomes. When a CDS is
selected then we change the space available for annotation (as there is
only ever one "row" of annotation here) and thus maximise the available
space for the barchart.
  • Loading branch information
jameshadfield committed Aug 8, 2023
1 parent 49b3a3f commit b047629
Show file tree
Hide file tree
Showing 3 changed files with 308 additions and 92 deletions.
216 changes: 151 additions & 65 deletions src/components/entropy/entropyD3.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ EntropyChart.prototype.render = function render(props) {
this.bars = props.bars;
this._setSelectedNodes();
this.svg.selectAll("*").remove(); /* tear things down */
this.svg.append('rect').attr('width', props.width).attr('height', props.height)
.attr('fill', '#c994c7').attr('fill-opacity', 0.1)
this._calcOffsets(props.width, props.height);
this._createGroups();
this._setScales(props.maxYVal);
Expand Down Expand Up @@ -314,6 +316,7 @@ EntropyChart.prototype._drawMainCds = function _drawMainCds() {
const [inViewNucA, inViewNucB] = this.scales.xMain.domain();

if (this.selectedCds!==nucleotide_gene) {

this._groups.mainCds.selectAll(".cds")
.data(this.selectedCds.segments)
.enter()
Expand All @@ -325,7 +328,7 @@ EntropyChart.prototype._drawMainCds = function _drawMainCds() {
return this.scales.xMain(Math.min(d.rangeLocal[1]+0.5, inViewNucB)) -
this.scales.xMain(Math.max(d.rangeLocal[0]-0.5, inViewNucA))
})
.attr("height", this.offsets.mainCdsHeight)
.attr("height", this.offsets.mainCdsRectHeight)
.style("fill", this.selectedCds.color)
.style("stroke", "#fff")
.style("stroke-width", 2)
Expand All @@ -352,7 +355,7 @@ EntropyChart.prototype._drawMainCds = function _drawMainCds() {
.attr("text-anchor", "middle") // horizontal axis
.attr("dominant-baseline", "hanging") // vertical axis
.style("fill", "white")
.style("font-size", `${this.offsets.mainCdsHeight-2}px`)
.style("font-size", `${this.offsets.mainCdsRectHeight-2}px`)
.text(() => this.selectedCds.name);

return;
Expand All @@ -371,32 +374,27 @@ EntropyChart.prototype._drawMainCds = function _drawMainCds() {
this.scales.xMain(Math.max(s.rangeGenome[0]-0.5, inViewNucA)),
this.scales.xMain(Math.min(s.rangeGenome[1]+0.5, inViewNucB))
];
if (s.strand === '+') {
s.yOffset = s.frame * this.offsets.mainCdsHeight/2;
} else {
// TODO XXX
}
/* Calculate the vertical coordinates in pixel space (relative to the <g> transform) */
const jitter = (s.cds.stackPosition-1)*this.offsets.mainCdsJitter;
s.yPx = s.cds.strand==='+' ? // always top-left (irregardless of strand)
(this.offsets.mainCdsPositiveDelta - jitter - this.offsets.mainCdsRectHeight) :
(this.offsets.mainCdsNegativeDelta + jitter);
return s;
})
.filter((s) => !!s)

this._groups.mainCds.selectAll(".cds")
.data(cdsSegments)
.enter()
.append("rect")
.append("path")
.attr("class", "gene")
.attr("x", (d) => d.rangePx[0])
.attr("y", (d) => d.yOffset)
.attr("width", (d) => d.rangePx[1] - d.rangePx[0])
.attr("height", this.offsets.mainCdsHeight)
.attr("d", (d) => _cdsPath(d, this.offsets))
.style("fill", (d) => d.color)
.style('opacity', 0.7)
.on("mouseover", (d) => { // note that the last-rendered CDS (rect) captures
this.callbacks.onHover({d3event, tooltip: this._cdsTooltip(d)})
})
.on("mouseout", (d) => {
this.callbacks.onLeave(d);
})
.on("mouseout", this.callbacks.onLeave)
.on("click", this.callbacks.onCdsClick)
.style("cursor", "pointer");

Expand All @@ -405,11 +403,11 @@ EntropyChart.prototype._drawMainCds = function _drawMainCds() {
.enter()
.append("text")
.attr("x", (d) => d.rangePx[0] + 0.5*(d.rangePx[1]-d.rangePx[0]))
.attr("y", (d) => d.yOffset+2)
.attr("y", (d) => d.yPx+1)
.attr("pointer-events", "none")
.attr("text-anchor", "middle") // horizontal axis
.attr("dominant-baseline", "hanging") // vertical axis
.style("font-size", `${this.offsets.mainCdsHeight-2}px`)
.style("font-size", `${this.offsets.mainCdsRectHeight-2}px`)
.style("fill", "white")
.text((d) => textIfSpaceAllows(d.name, d.rangePx[1] - d.rangePx[0], 10));
};
Expand All @@ -428,6 +426,25 @@ function textIfSpaceAllows(text, width, pxPerChar) {
}


function _cdsPath(d, offsets) {
const h = offsets.mainCdsRectHeight;
let w = Math.floor(d.rangePx[1] - d.rangePx[0]); // width of CDS (pixels)
let x = Math.round(d.rangePx[0])
if (w<20) {
/* no directional arrow - space is too limited */
return `M ${x},${d.yPx} h ${w} v ${h} h -${w} Z`
}
const w2 = Math.floor(Math.min(w/4, 10)); // width of arrow head
w-=w2;
const h2 = h/2;
if (d.cds.strand==='+') {
return `M ${x},${d.yPx} h ${w} l ${w2},${h2} l -${w2},${h2} h -${w} Z`;
}
// start at top-right and go around the arrow anticlockwise
x = Math.round(d.rangePx[1])
return `M ${x},${d.yPx} h -${w} l -${w2},${h2} l ${w2},${h2} h ${w} Z`;
}

/**
* Renders the CDS annotations in the lower "Nav" track. These have fixed
* posisions and cannot be updated by zooming. Given the limited amount of
Expand All @@ -449,10 +466,11 @@ EntropyChart.prototype._drawNavCds = function _drawNavCds() {
cdsSegments.forEach((s) => {
s.rangePx = [this.scales.xNav(s.rangeGenome[0]), this.scales.xNav(s.rangeGenome[1])];
if (s.strand === '+') {
s.yOffset = s.frame * this.offsets.navCdsHeight/2
} else {
s.yOffset = (this.offsets.navCdsY2 - this.offsets.navCdsY1) - this.offsets.navCdsHeight -
(1/((s.frame+1)%3))*this.offsets.navCdsHeight/2;
s.yPx = this.offsets.navCdsPositiveStrandSpace - // start at the _bottom_ of the +ve strand CDS space
(s.cds.stackPosition) * this.offsets.navCdsRectHeight
} else if (s.strand === '-') {
s.yPx = this.offsets.navCdsNegativeY1Delta + // starting point is below axis labels
(s.cds.stackPosition-1) * this.offsets.navCdsRectHeight;
}
})

Expand All @@ -465,12 +483,15 @@ EntropyChart.prototype._drawNavCds = function _drawNavCds() {
.append("rect")
.attr("class", "gene")
.attr("x", (d) => d.rangePx[0])
.attr("y", (d) => d.yOffset)
.attr("y", (d) => d.yPx)
.attr("width", (d) => d.rangePx[1] - d.rangePx[0])
.attr("height", this.offsets.navCdsHeight)
.attr("height", this.offsets.navCdsRectHeight)
.style("fill", (d) => d.color)
.style('fill-opacity', (d) => d.name===this.selectedCds?.name ? 1 : 0.7)
.style('stroke', (d) => d.name===this.selectedCds?.name ? '#000' : '#fff')
.style('fill-opacity', (d) => {
if (this.selectedCds===nucleotide_gene) return 1;
return d.name===this.selectedCds.name ? 1 : 0.3
})
.style('stroke', '#fff')
.style('stroke-width', 1)
.on("mouseover", (d) => { // note that the last-rendered CDS (rect) captures
this.callbacks.onHover({d3event, tooltip: this._cdsTooltip(d)})
Expand All @@ -486,12 +507,12 @@ EntropyChart.prototype._drawNavCds = function _drawNavCds() {
.enter()
.append("text")
.attr("x", (d) => d.rangePx[0] + 0.5*(d.rangePx[1]-d.rangePx[0]))
.attr("y", (d) => d.yOffset+2)
.attr("y", (d) => d.yPx+2)
.attr("pointer-events", "none")
.attr("text-anchor", "middle") // horizontal axis
.attr("dominant-baseline", "hanging") // vertical axis
.style("font-size", `${this.offsets.navCdsHeight-2}px`)
.style("fill", (d) => d.name===this.selectedCds?.name ? '#000' : '#fff')
.style("font-size", `${this.offsets.navCdsRectHeight-2}px`)
.style("fill", '#fff')
.text((d) => textIfSpaceAllows(d.name, d.rangePx[1] - d.rangePx[0], 30/4));
};

Expand All @@ -501,7 +522,8 @@ EntropyChart.prototype._cdsSegments = function _cdsSegments() {
.genes.forEach((gene) => {
gene.cds.forEach((cds) => {
cds.segments.forEach((cdsSegment, idx) => {
const s = {...cdsSegment};
const s = {...cdsSegment}; // shallow copy so we don't modify the redux data
s.cds = cds; // this is a pointer to the redux data - don't modify it!
s.color = cds.color;
s.name = cds.name;
s.strand = cds.strand;
Expand Down Expand Up @@ -655,7 +677,9 @@ EntropyChart.prototype._updateMainScaleAndAxis = function _updateMainScaleAndAxi
this._groups.mainBars.attr("transform", "translate(" + this.offsets.x1Narrow + "," + this.offsets.mainY1 + ")");
this._groups.mainCds.attr("transform", "translate(" + this.offsets.x1Narrow + "," + this.offsets.mainCdsY1 + ")");
this._groups.mainXAxis.attr("transform", "translate(" + this.offsets.x1Narrow + "," + this.offsets.mainAxisY1 + ")");
this._groups.mainClip.select("#cliprect").attr("width", this.offsets.widthNarrow)
this._groups.mainClip.select("#cliprect")
.attr("width", this.offsets.widthNarrow)
.attr("height", this.offsets.heightMainBars);

/* update the scales & rerender x-axis */
this.scales.xMain.domain([0, mainAxisLength])
Expand All @@ -664,7 +688,9 @@ EntropyChart.prototype._updateMainScaleAndAxis = function _updateMainScaleAndAxi
this._groups.mainXAxis.call(this.axes.xMain);
}

this.scales.y.domain([0, yMax])
this.scales.y
.domain([0, yMax])
.range([this.offsets.heightMainBars, 0]);
this._groups.mainYAxis.call(this.axes.y)
/* requires redraw of bars */
};
Expand All @@ -686,37 +712,84 @@ EntropyChart.prototype._calcOffsets = function _calcOffsets(width, height) {
const marginRight = 15;
const marginBottom = 0;

const spaceBetweenBarsAndHighestCds = 5;
/* Spacing which we'll use in the calculations below */
const navCdsRectHeight = 13;
const tinySpace = 2; // space above axis line before a CDS appears
const navAxisSpaceBelow = 20; // bigger than space above axis to accommodate ticks
const brushSpaceAboveBelowCdsRects = 2;
const metadata = this.genomeMap[0].metadata;
const navCdsPositiveStrandSpace = metadata.strandsObserved.has('+') ?
metadata.posStrandStackHeight*navCdsRectHeight : 0;
const navCdsNegativeStrandSpace = metadata.strandsObserved.has('-') ?
metadata.negStrandStackHeight*navCdsRectHeight : 0;
const mainCdsRectHeight = 16;
const mainCdsJitter = mainCdsRectHeight/2;
const mainCdsPositiveStrandSpace = metadata.strandsObserved.has('+') ?
mainCdsRectHeight + (metadata.posStrandStackHeight-1)*mainCdsJitter : 0;
const mainCdsNegativeStrandSpace = metadata.strandsObserved.has('-') ?
mainCdsRectHeight + (metadata.negStrandStackHeight-1)*mainCdsJitter : 0;

const tickSpace = 20; // ad-hoc TODO XXX
const negStrand = false; // TODO XXX - probably in initial render / constructor?

/* The general approach is to calculate spacings from the bottom up, with the barchart
occupying the remaining space */
occupying the remaining space. The space needed below the bottom of the barchart is
a function of the genomeMap, so we could calculate this ahead-of-time and make the entropy
panel height a function of this. (I think this is a good idea, we should do it!) */
this.offsets = {};

/* nav (navigation) is the fixed axis, with a brush overlay (to zoom) + CDS annotations */
this.offsets.navCdsHeight = 13;
this.offsets.brushHeight = 10;
const negStrandNavCdsSpace = negStrand ? this.offsets.navCdsHeight * 2 : 0;
this.offsets.navAxisY1 = height - marginBottom - tickSpace - negStrandNavCdsSpace;
this.offsets.brushY1 = this.offsets.navAxisY1 - this.offsets.brushHeight/2;
this.offsets.navCdsY1 = this.offsets.brushY1 - 2*this.offsets.navCdsHeight;
this.offsets.navCdsY2 = height - marginBottom;

/* main is the barchart, axis (axes), CDS annotations. While it updates as we zoom,
the updating doesn't change the offsets */
this.offsets.mainCdsHeight = 15; // TODO XXX
this.offsets.mainAxisY1 = this.offsets.navCdsY1 -
0 - // spacing between nav + main -- adjust in conjunction with tickSpace
(negStrand ? this.offsets.mainCdsHeight * 2 : 0) -
this.offsets.tinySpace = tinySpace;
this.offsets.brushHandleHeight = 12;
this.offsets.navCdsRectHeight = navCdsRectHeight;
this.offsets.navAxisY1 = height - marginBottom - this.offsets.brushHandleHeight -
tickSpace - navCdsNegativeStrandSpace;
this.offsets.navCdsY1 = this.offsets.navAxisY1 -
tinySpace - // space above axis line before any CDS appears
navCdsPositiveStrandSpace;
this.offsets.navCdsPositiveStrandSpace = navCdsPositiveStrandSpace;
/* navCdsNegativeY1Delta is the height change between navCdsY1 and the top of the highest -ve strand CDS rect */
this.offsets.navCdsNegativeY1Delta = this.offsets.navAxisY1 + navAxisSpaceBelow - this.offsets.navCdsY1;

/* brush should start just above highest CDS and extend to just below lowest CDS, or extend just below
the tick labels if there are no -ve strand CDSs */
this.offsets.brushY1 = this.offsets.navCdsY1 - brushSpaceAboveBelowCdsRects;
this.offsets.brushHeight = this.offsets.navCdsNegativeY1Delta + // space between top of CDS space & top of -ve strand CDS
navCdsNegativeStrandSpace + 2*brushSpaceAboveBelowCdsRects;

/* The main axis sits ~directly above the top of the brush, with the CDSs
above that. It is used by the barchart, upper axis, upper CDS annotations. It
updates as we zoom, but zooming doesn't change the offsets. When (if) a CDS is
selected then the axis doesn't change position, but the space above it (and
thus mainCdsY1, heightMainBars) changes */
this.offsets.mainAxisY1 = this.offsets.brushY1 -
tinySpace -
tickSpace;
this.offsets.mainCdsY1 = this.offsets.mainAxisY1 -
this.offsets.mainCdsHeight * 2 -
0;
if (this.aa) {
this.offsets.mainCdsY1 = this.offsets.mainAxisY1 -
tinySpace - // space above axis line before any CDS appears
mainCdsRectHeight; // space only for a single rect
} else {
this.offsets.mainCdsY1 = this.offsets.mainAxisY1 -
tinySpace - // space above axis line before any CDS appears
mainCdsNegativeStrandSpace -
tinySpace - // space between -ve, +ve strand CDSs
mainCdsPositiveStrandSpace;
}
this.offsets.mainCdsNegativeStrandSpace = mainCdsNegativeStrandSpace;
this.offsets.mainCdsPositiveStrandSpace = mainCdsPositiveStrandSpace;

/* deltas used to know where to start drawing CDSs relative to the transformed coords, were zero = mainCdsY1 */
this.offsets.mainCdsPositiveDelta = mainCdsPositiveStrandSpace;
this.offsets.mainCdsNegativeDelta = mainCdsPositiveStrandSpace + tinySpace;
this.offsets.mainCdsJitter = mainCdsJitter;
this.offsets.mainCdsRectHeight = mainCdsRectHeight;

/* mainY1 is the top of the bar chart, and so the bars are the space that's left over */
this.offsets.mainY1 = marginTop;
this.offsets.heightMainBars = this.offsets.mainCdsY1 - this.offsets.mainY1 - spaceBetweenBarsAndHighestCds;

/* The Nav, Brush, and y-axis all use x1,x2, width */
this.offsets.heightMainBars = this.offsets.mainCdsY1 -
this.offsets.mainY1 -
tinySpace; // space between topmost CDS + bars starting

/* We now consider the horizontal offsets.
The Nav, Brush, and y-axis all use x1,x2, width, which never changes */
this.offsets.x1 = marginLeft;
this.offsets.width = width - this.offsets.x1 - marginRight;

Expand All @@ -742,13 +815,23 @@ EntropyChart.prototype._updateOffsets = function _updateOffsets() {
if (this.aa) {
this.offsets.x1Narrow = this.offsets.x1 + this.offsets.xMainInternalPad
this.offsets.widthNarrow = this.offsets.width - 2*this.offsets.xMainInternalPad;
this.offsets.mainCdsY1 = this.offsets.mainAxisY1 -
this.offsets.tinySpace - // space above axis line before any CDS appears
this.offsets.mainCdsRectHeight; // space only for a single rect
} else {
this.offsets.x1Narrow = this.offsets.x1;
this.offsets.widthNarrow = this.offsets.width;
this.offsets.mainCdsY1 = this.offsets.mainAxisY1 -
this.offsets.tinySpace - // space above axis line before any CDS appears
this.offsets.mainCdsNegativeStrandSpace -
this.offsets.tinySpace - // space between -ve, +ve strand CDSs
this.offsets.mainCdsPositiveStrandSpace;
}
this.offsets.heightMainBars = this.offsets.mainCdsY1 -
this.offsets.mainY1 -
this.offsets.tinySpace; // space between topmost CDS + bars starting
}


/**
* Creates & renders the brush (the grey shaded area which we can drag to move the zoom window)
* and custom handles (the black triangles which we can use to drag the start/end of the zoom window)
Expand Down Expand Up @@ -777,13 +860,14 @@ EntropyChart.prototype._setUpZoomBrush = function _setUpZoomBrush() {
});

/* https://bl.ocks.org/mbostock/4349545 */
const v = this.offsets.brushHandleHeight;
this.brushHandle = this._groups.navBrush.selectAll(".handle--custom")
.data([{type: "w"}, {type: "e"}])
.enter().append("path")
.attr("class", "handle--custom")
.attr("fill", darkGrey)
.attr("cursor", "ew-resize")
.attr("d", "M0,0 0,0 -5,11 5,11 0,0 Z")
.attr("d", `M0,0 -6,${v} 6,${v} Z`)
/* see the extent x,y params in brushX() (above) */
.attr("transform", (d) => {
return d.type === "e" ? // end (2nd) handle
Expand Down Expand Up @@ -961,6 +1045,15 @@ EntropyChart.prototype._createGroups = function _createGroups() {
.attr("id", "mainBars")
.attr("clip-path", "url(#clip)")
.attr("transform", "translate(" + this.offsets.x1Narrow + "," + this.offsets.mainY1 + ")");

this._groups.navBrush = this.svg.append("g")
.attr("id", "navBrush")
.attr("transform", "translate(" + this.offsets.x1 + "," + this.offsets.brushY1 + ")");

this._groups.navBrushWrapping = this.svg.append("g") // custom brush <rect> which wrap the origin
.attr("id", "navBrushWrapping")
.attr("transform", "translate(" + this.offsets.x1 + "," + this.offsets.brushY1 + ")");

this._groups.navCds = this.svg.append("g")
.attr("id", "navCds")
.attr("transform", "translate(" + this.offsets.x1 + "," + this.offsets.navCdsY1 + ")");
Expand All @@ -980,13 +1073,6 @@ EntropyChart.prototype._createGroups = function _createGroups() {
.attr("id", "navXAxis")
.attr("transform", "translate(" + this.offsets.x1 + "," + this.offsets.navAxisY1 + ")")

this._groups.navBrush = this.svg.append("g")
.attr("id", "navBrush")
.attr("transform", "translate(" + this.offsets.x1 + "," + this.offsets.brushY1 + ")");

this._groups.navBrushWrapping = this.svg.append("g") // custom brush <rect> which wrap the origin
.attr("id", "navBrushWrapping")
.attr("transform", "translate(" + this.offsets.x1 + "," + this.offsets.brushY1 + ")");

this._groups.mainClip = this.svg.append("g")
.attr("id", "mainClip")
Expand Down
Loading

0 comments on commit b047629

Please sign in to comment.