Skip to content

Commit

Permalink
Allow non-continuous scatterplot variables
Browse files Browse the repository at this point in the history
This implements a requested improvement to the original
scatterplot implementation. The implementation hinges on two changes:
(1) The collection of values for a given variable (e.g. x-var) need
to be computed and passed to PhyloTree to act as the scale's domain.
We reuse the colorScale machinery here, which could be optimised
(see todo messages in code), but this has the advantage that the
domain ordering matches the legend (unless user supplied).
(2) PhyloTree needed to be modified to use non-linear scales, in this
case `pointScale`.

This commit should be fully functional, however there are some
future improvements to be made:

(i) Grid text is obscured and unreadable when there are many entries
in the domain.
(ii) Genotypes and Boolean scales are not yet available.
(iii) Jitter should be added to nodes to avoid obfuscation.
  • Loading branch information
jameshadfield committed May 18, 2021
1 parent a9c0c3f commit f6ee8e7
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 68 deletions.
2 changes: 1 addition & 1 deletion src/actions/recomputeReduxState.js
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree, viewingNarra
// todo: these should be JSON definable (via display_defaults)
if (state.layout==="scatter" || state.layout==="clock") {
state.scatterVariables = validateScatterVariables(
state.scatterVariables, metadata.colorings, state.distanceMeasure, state.colorBy, state.layout==="clock"
state, metadata, tree, state.layout==="clock"
);
if (query.scatterX && query.scatterX!==state.scatterVariables.x) delete query.scatterX;
if (query.scatterY && query.scatterY!==state.scatterVariables.y) delete query.scatterY;
Expand Down
50 changes: 33 additions & 17 deletions src/components/controls/choose-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { withTranslation } from 'react-i18next';
import Select from "react-select/lib/Select";
import * as icons from "../framework/svg-icons";
import { controlsWidth } from "../../util/globals";
import { collectAvailableScatterVariables, validateScatterVariables} from "../../util/scatterplotHelpers";
import { collectAvailableScatterVariables, validateScatterVariables, addScatterAxisInfo} from "../../util/scatterplotHelpers";
import { CHANGE_LAYOUT } from "../../actions/types";
import { SidebarSubtitle, SidebarButton } from "./styles";
import Toggle from "./toggle";
Expand All @@ -30,8 +30,9 @@ export const RowContainer = styled.div`
layout: state.controls.layout,
scatterVariables: state.controls.scatterVariables,
colorBy: state.controls.colorBy,
distanceMeasure: state.controls.distanceMeasure,
colorings: state.metadata.colorings,
controls: state.controls,
tree: state.tree,
metadata: state.metadata,
showTreeToo: state.controls.showTreeToo,
branchLengthsToDisplay: state.controls.branchLengthsToDisplay
};
Expand All @@ -48,12 +49,15 @@ class ChooseLayout extends React.Component {
const scatterVariables = modifiedScatterVariables ?
{...this.props.scatterVariables, ...modifiedScatterVariables} :
this.props.scatterVariables;
if (layout==="scatter" && (!scatterVariables.xContinuous || !scatterVariables.yContinuous)) {
scatterVariables.showRegression= false;
}
this.props.dispatch({type: CHANGE_LAYOUT, layout, scatterVariables});
};
}

renderScatterplotAxesSelector() {
const options = collectAvailableScatterVariables(this.props.colorings);
const options = collectAvailableScatterVariables(this.props.metadata.colorings);
const selectedX = options.filter((o) => o.value===this.props.scatterVariables.x)[0];
const selectedY = options.filter((o) => o.value===this.props.scatterVariables.y)[0];
const miscSelectProps = {options, clearable: false, searchable: false, multi: false, valueKey: "label"};
Expand All @@ -66,7 +70,10 @@ class ChooseLayout extends React.Component {
<Select
{...miscSelectProps}
value={selectedX}
onChange={(value) => this.updateLayout("scatter", {x: value.value, xLabel: value.label})}
onChange={(value) => this.updateLayout(
"scatter",
addScatterAxisInfo({x: value.value, xLabel: value.label}, "x", this.props.controls, this.props.tree, this.props.metadata)
)}
/>
</ScatterSelectContainer>
</ScatterVariableContainer>
Expand All @@ -77,7 +84,10 @@ class ChooseLayout extends React.Component {
<Select
{...miscSelectProps}
value={selectedY}
onChange={(value) => this.updateLayout("scatter", {y: value.value, yLabel: value.label})}
onChange={(value) => this.updateLayout(
"scatter",
addScatterAxisInfo({y: value.value, yLabel: value.label}, "y", this.props.controls, this.props.tree, this.props.metadata)
)}
/>
</ScatterSelectContainer>
</ScatterVariableContainer>
Expand All @@ -98,15 +108,21 @@ class ChooseLayout extends React.Component {
/>
</ScatterVariableContainer>
<div style={{paddingTop: "2px"}}/>
<ScatterVariableContainer>
<Toggle
display
on={this.props.scatterVariables.showRegression}
callback={() => this.updateLayout(this.props.layout, {showRegression: !this.props.scatterVariables.showRegression})}
label={"Show regression"}
/>
</ScatterVariableContainer>
<div style={{paddingTop: "2px"}}/>
{
(this.props.scatterVariables.xContinuous && this.props.scatterVariables.yContinuous) && (
<>
<ScatterVariableContainer>
<Toggle
display
on={this.props.scatterVariables.showRegression}
callback={() => this.updateLayout(this.props.layout, {showRegression: !this.props.scatterVariables.showRegression})}
label={"Show regression"}
/>
</ScatterVariableContainer>
<div style={{paddingTop: "2px"}}/>
</>
)
}
</>
);
}
Expand Down Expand Up @@ -157,7 +173,7 @@ class ChooseLayout extends React.Component {
selected={selected === "clock"}
onClick={() => this.updateLayout(
"clock",
validateScatterVariables(this.props.scatterVariables, this.props.colorings, this.props.distanceMeasure, this.props.colorBy, true)
validateScatterVariables(this.props.controls, this.props.metadata, this.props.tree, true)
)}
>
{t("sidebar:clock")}
Expand All @@ -174,7 +190,7 @@ class ChooseLayout extends React.Component {
selected={selected === "scatter"}
onClick={() => this.updateLayout(
"scatter",
validateScatterVariables(this.props.scatterVariables, this.props.colorings, this.props.distanceMeasure, this.props.colorBy, false)
validateScatterVariables(this.props.controls, this.props.metadata, this.props.tree, false)
)}
>
{t("sidebar:scatter")}
Expand Down
11 changes: 11 additions & 0 deletions src/components/tree/phyloTree/grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ export const addGrid = function addGrid() {
(this.layout!=="scatter" && this.distance==="num_date")
) {
xGridPoints = computeTemporalGridPoints(xmin, xmax, xAxisPixels, "x");
} else if (this.layout==="scatter" && !this.scatterVariables.xContinuous) {
xGridPoints = {
majorGridPoints: this.xScale.domain().map((name) => ({
name, visibility: "visible", axis: "x", position: name
})),
minorGridPoints: []
};
} else {
xGridPoints = computeNumericGridPoints(xmin, xmax, layout, this.params.minorTicks, "x");
}
Expand Down Expand Up @@ -319,6 +326,10 @@ export const addGrid = function addGrid() {
const yAxisPixels = this.yScale.range()[1] - this.yScale.range()[0];
const temporalGrid = computeTemporalGridPoints(ymin, ymax, yAxisPixels, "y");
majorGridPoints.push(...temporalGrid.majorGridPoints);
} else if (this.layout==="scatter" && !this.scatterVariables.yContinuous) {
majorGridPoints.push(...this.yScale.domain().map((name) => ({
name, visibility: "visible", axis: "y", position: name
})));
} else {
const numericGrid = computeNumericGridPoints(ymin, ymax, layout, 1, "y");
majorGridPoints.push(...numericGrid.majorGridPoints);
Expand Down
112 changes: 70 additions & 42 deletions src/components/tree/phyloTree/layouts.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable no-multi-spaces */
/* eslint-disable space-infix-ops */
import { min, max } from "d3-array";
import scaleLinear from "d3-scale/src/linear";
import {point as scalePoint} from "d3-scale/src/band";
import { addLeafCount} from "./helpers";
import { calculateRegressionThroughRoot, calculateRegressionWithFreeIntercept } from "./regression";
import { timerStart, timerEnd } from "../../../util/perf";
Expand Down Expand Up @@ -259,10 +261,23 @@ export const setDistance = function setDistance(distanceAttribute) {


/**
* sets the range of the scales used to map the x,y coordinates to the screen
* Initializes and sets the range of the scales (this.xScale, this.yScale)
* which are used to map the x,y coordinates to the screen
* @param {margins} -- object with "right, left, top, bottom" margins
*/
export const setScales = function setScales(margins) {

if (this.layout==="scatter" && !this.scatterVariables.xContinuous) {
this.xScale = scalePoint().round(true).align(0.5);
} else {
this.xScale = scaleLinear();
}
if (this.layout==="scatter" && !this.scatterVariables.yContinuous) {
this.yScale = scalePoint().round(true).align(0.5);
} else {
this.yScale = scaleLinear();
}

const width = parseInt(this.svg.attr("width"), 10);
const height = parseInt(this.svg.attr("height"), 10);
if (this.layout === "radial" || this.layout === "unrooted") {
Expand All @@ -276,7 +291,7 @@ export const setScales = function setScales(margins) {
this.yScale.range([0.5 * ySlack + margins["top"] || 0, height - 0.5 * ySlack - (margins["bottom"] || 0)]);

} else {
// for rectancular layout, allow flipping orientation of left right and top/botton
// for rectangular layout, allow flipping orientation of left/right and top/bottom
if (this.params.orientation[0] > 0) {
this.xScale.range([margins["left"] || 0, width - (margins["right"] || 0)]);
} else {
Expand Down Expand Up @@ -331,62 +346,75 @@ export const mapToScreen = function mapToScreen() {
/* set the range of the x & y scales */
this.setScales(tmpMargins);

/* find minimum & maximum x & y values */
let [minY, maxY, minX, maxX] = [1000000, -100000, 1000000, -100000];
let nodesInDomain = this.nodes.filter((d) => d.inView && d.y!==undefined && d.x!==undefined);
// scatterplots further restrict nodes used for domain calcs - if not rendering branches,
// then we don't consider internal nodes for the domain calc
if (this.layout==="scatter" && this.scatterVariables.showBranches===false) {
nodesInDomain = nodesInDomain.filter((d) => d.terminal);
}
nodesInDomain.forEach((d) => {
if (d.x > maxX) maxX = d.x;
if (d.y > maxY) maxY = d.y;
if (d.x < minX) minX = d.x;
if (d.y < minY) minY = d.y;
});

/* fixes state of 0 length domain */
if (minX === maxX) {
minX -= 0.005;
maxX += 0.005;
}

/* slightly pad min and max y to account for small clades */
if (inViewTerminalNodes.length < 30) {
const delta = 0.05 * (maxY - minY);
minY -= delta;
maxY += delta;
/* Compute the domains to pass to the d3 scales for the x & y axes */
let xDomain, yDomain, spanX, spanY;
if (this.layout!=="scatter" || this.scatterVariables.xContinuous) {
let [minX, maxX] = [1000000, -100000];
nodesInDomain.forEach((d) => {
if (d.x < minX) minX = d.x;
if (d.x > maxX) maxX = d.x;
});
/* fixes state of 0 length domain */
if (minX === maxX) {
minX -= 0.005;
maxX += 0.005;
}
/* Don't allow tiny x-axis domains -- e.g. if zoomed into a polytomy where the
divergence values are all tiny, then we don't want to display the tree topology */
const minimumXAxisSpan = 1E-8;
spanX = maxX-minX;
if (spanX < minimumXAxisSpan) {
maxX = minimumXAxisSpan - minX;
spanX = minimumXAxisSpan;
}
xDomain = [minX, maxX];
} else {
const seenValues = new Set(nodesInDomain.map((d) => d.x));
xDomain = this.scatterVariables.xDomain.filter((v) => seenValues.has(v));
}

/* Don't allow tiny x-axis domains -- e.g. if zoomed into a polytomy where the
divergence values are all tiny, then we don't want to display the tree topology */
const minimumXAxisSpan = 1E-8;
let spanX = maxX-minX;
if (spanX < minimumXAxisSpan) {
maxX = minimumXAxisSpan - minX;
spanX = minimumXAxisSpan;
if (this.layout!=="scatter" || this.scatterVariables.yContinuous) {
let [minY, maxY] = [1000000, -100000];
nodesInDomain.forEach((d) => {
if (d.y < minY) minY = d.y;
if (d.y > maxY) maxY = d.y;
});
/* slightly pad min and max y to account for small clades */
if (inViewTerminalNodes.length < 30) {
const delta = 0.05 * (maxY - minY);
minY -= delta;
maxY += delta;
}
spanY = maxY-minY;
yDomain = [minY, maxY];
} else {
const seenValues = new Set(nodesInDomain.map((d) => d.y));
yDomain = this.scatterVariables.yDomain.filter((v) => seenValues.has(v));
}

/* set the domain of the x & y scales */
/* Radial / Unrooted layouts need to be square since branch lengths
depend on this */
if (this.layout === "radial" || this.layout === "unrooted") {
// handle "radial and unrooted differently since they need to be square
// since branch length move in x and y direction
// TODO: should be tied to svg dimensions
const spanY = maxY-minY;
const maxSpan = max([spanY, spanX]);
const ySlack = (spanX>spanY) ? (spanX-spanY)*0.5 : 0.0;
const xSlack = (spanX<spanY) ? (spanY-spanX)*0.5 : 0.0;
this.xScale.domain([minX-xSlack, minX+maxSpan-xSlack]);
this.yScale.domain([minY-ySlack, minY+maxSpan-ySlack]);
} else if (this.layout==="clock" || this.layout==="scatter") {
// same as rectangular, but flipped yscale
this.xScale.domain([minX, maxX]);
this.yScale.domain([maxY, minY]);
} else { // rectangular
this.xScale.domain([minX, maxX]);
this.yScale.domain([minY, maxY]);
xDomain = [xDomain[0]-xSlack, xDomain[0]+maxSpan-xSlack];
yDomain = [yDomain[0]-ySlack, yDomain[0]+maxSpan-ySlack];
}
/* Clock & Scatter plots flip the yDomain */
if (this.layout === "clock" || this.layout === "scatter") {
yDomain.reverse();
}

this.xScale.domain(xDomain);
this.yScale.domain(yDomain);

const hiddenYPosition = this.yScale.range()[1] + 100;
const hiddenXPosition = this.xScale.range()[0] - 100;
Expand Down
3 changes: 0 additions & 3 deletions src/components/tree/phyloTree/phyloTree.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import scaleLinear from "d3-scale/src/linear";
import { createDefaultParams } from "./defaultParams";
import { createChildrenAndParentsReturnNumTips, setYValues } from "./helpers";
import { change, modifySVG, modifySVGInStages } from "./change";
Expand Down Expand Up @@ -38,8 +37,6 @@ const PhyloTree = function PhyloTree(reduxNodes, id, idxOfInViewRootNode) {
});
this.numberOfTips = createChildrenAndParentsReturnNumTips(this.nodes);
setYValues(this.nodes);
this.xScale = scaleLinear();
this.yScale = scaleLinear();
this.zoomNode = this.nodes[idxOfInViewRootNode];
this.strainToNode = {};
this.nodes.forEach((phylonode) => {this.strainToNode[phylonode.n.name] = phylonode;});
Expand Down
8 changes: 7 additions & 1 deletion src/util/colorScale.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => {
const colorings = metadata.colorings;
const treeTooNodes = treeToo ? treeToo.nodes : undefined;
let continuous = false;
let colorScale, legendValues, legendBounds, legendLabels;
let colorScale, legendValues, legendBounds, legendLabels, domain;

let genotype;
if (isColorByGenotype(colorBy) && controls.geneLength) {
Expand Down Expand Up @@ -62,6 +62,10 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => {
throw new Error(`ColorBy ${colorBy} invalid type -- ${scaleType}`);
}

/* We store a copy of the `domain`, which for non-continuous scales is a ordered list of values for this colorBy,
for future list */
if (scaleType !== 'continuous') domain = legendValues.slice();

/* Use user-defined `legend` data (if any) to define custom legend elements */
const legendData = parseUserProvidedLegendData(colorings[colorBy].legend, legendValues, scaleType);
if (legendData) {
Expand Down Expand Up @@ -92,6 +96,7 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => {
legendBounds,
legendLabels,
genotype,
domain,
scaleType: scaleType,
visibleLegendValues: visibleLegendValues
};
Expand All @@ -107,6 +112,7 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => {
legendBounds: createLegendBounds(["unknown"]),
genotype: null,
scaleType: null,
domain: null,
visibleLegendValues: ["unknown"]
};
}
Expand Down
Loading

0 comments on commit f6ee8e7

Please sign in to comment.