From f6ee8e7ce9983b5f1dd3463af4fce34fc9f2abda Mon Sep 17 00:00:00 2001 From: James Hadfield Date: Tue, 18 May 2021 16:47:34 +1200 Subject: [PATCH] Allow non-continuous scatterplot variables 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. --- src/actions/recomputeReduxState.js | 2 +- src/components/controls/choose-layout.js | 50 +++++---- src/components/tree/phyloTree/grid.js | 11 ++ src/components/tree/phyloTree/layouts.js | 112 +++++++++++++-------- src/components/tree/phyloTree/phyloTree.js | 3 - src/util/colorScale.js | 8 +- src/util/scatterplotHelpers.js | 48 ++++++++- 7 files changed, 166 insertions(+), 68 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 734646a1e..b6e4c3058 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -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; diff --git a/src/components/controls/choose-layout.js b/src/components/controls/choose-layout.js index d914242f8..7c4dc0a23 100644 --- a/src/components/controls/choose-layout.js +++ b/src/components/controls/choose-layout.js @@ -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"; @@ -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 }; @@ -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"}; @@ -66,7 +70,10 @@ class ChooseLayout extends React.Component { 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) + )} /> @@ -98,15 +108,21 @@ class ChooseLayout extends React.Component { />
- - this.updateLayout(this.props.layout, {showRegression: !this.props.scatterVariables.showRegression})} - label={"Show regression"} - /> - -
+ { + (this.props.scatterVariables.xContinuous && this.props.scatterVariables.yContinuous) && ( + <> + + this.updateLayout(this.props.layout, {showRegression: !this.props.scatterVariables.showRegression})} + label={"Show regression"} + /> + +
+ + ) + } ); } @@ -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")} @@ -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")} diff --git a/src/components/tree/phyloTree/grid.js b/src/components/tree/phyloTree/grid.js index 3952a56dd..3b0a3d0f3 100644 --- a/src/components/tree/phyloTree/grid.js +++ b/src/components/tree/phyloTree/grid.js @@ -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"); } @@ -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); diff --git a/src/components/tree/phyloTree/layouts.js b/src/components/tree/phyloTree/layouts.js index 034a0a6bb..1127f5e2b 100644 --- a/src/components/tree/phyloTree/layouts.js +++ b/src/components/tree/phyloTree/layouts.js @@ -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"; @@ -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") { @@ -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 { @@ -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 {this.strainToNode[phylonode.n.name] = phylonode;}); diff --git a/src/util/colorScale.js b/src/util/colorScale.js index 193898aa3..8cbc0ec73 100644 --- a/src/util/colorScale.js +++ b/src/util/colorScale.js @@ -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) { @@ -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) { @@ -92,6 +96,7 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { legendBounds, legendLabels, genotype, + domain, scaleType: scaleType, visibleLegendValues: visibleLegendValues }; @@ -107,6 +112,7 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { legendBounds: createLegendBounds(["unknown"]), genotype: null, scaleType: null, + domain: null, visibleLegendValues: ["unknown"] }; } diff --git a/src/util/scatterplotHelpers.js b/src/util/scatterplotHelpers.js index 84f44b111..d4b10b766 100644 --- a/src/util/scatterplotHelpers.js +++ b/src/util/scatterplotHelpers.js @@ -1,10 +1,12 @@ - +import { calcColorScale } from "./colorScale"; export function collectAvailableScatterVariables(colorings) { // todo: genotype (special case) + // Note for implementation of genotype - be careful with calls to `calcColorScale` (e.g. from `validateScatterVariables`) + // as one of the side effects of that function is setting the genotype on nodes const options = Object.keys(colorings) .filter((key) => key!=="gt") - .filter((key) => colorings[key].type==="continuous") // work needed to render non-continuous scales in PhyloTree + .filter((key) => colorings[key].type!=="boolean") .map((key) => ({ value: key, label: colorings[key].title || key @@ -16,8 +18,10 @@ export function collectAvailableScatterVariables(colorings) { /** * Return a (validated) `scatterVariables` object, given any existing scatterVariables (which may themselves be invalid) */ -export function validateScatterVariables(existingScatterVariables={}, colorings, distanceMeasure, colorBy, isClock) { - const availableOptions = collectAvailableScatterVariables(colorings); +export function validateScatterVariables(controls, metadata, tree, isClock) { + const {distanceMeasure, colorBy } = controls; + const existingScatterVariables = {...controls.scatterVariables}; + const availableOptions = collectAvailableScatterVariables(metadata.colorings); const scatterVariables = {}; // default is to show branches, unless the existing state says otherwise scatterVariables.showBranches = Object.prototype.hasOwnProperty.call(existingScatterVariables, "showBranches") ? @@ -35,6 +39,10 @@ export function validateScatterVariables(existingScatterVariables={}, colorings, const yOption = _getFirstMatchingOption(availableOptions, [existingScatterVariables.y, colorBy], xOption.value); scatterVariables.y = yOption.value; scatterVariables.yLabel = yOption.label; + for (const axis of ["x", "y"]) { + addScatterAxisInfo(scatterVariables, axis, controls, tree, metadata); + } + if (!scatterVariables.xContinuous || !scatterVariables.yContinuous) scatterVariables.showRegression= false; } return scatterVariables; } @@ -62,3 +70,35 @@ function _getFirstMatchingOption(options, tryTheseFirst, notThisValue) { } return undefined; } + +/** + * Given a colorBy and a scatterplot axes, calculate whether the axes is + * continous or not. If not, calculate the domain. For this we use the same code + * as colour scale construction to find a domain. There are opportunities for refactoring here + * to improve performance, as we only use two properties from the returned object. + * Similarly, if one of the axes variables is the current colorBy, we could avoid recalculating this. + * @param {Object} scatterVariables + * @param {string} axis values: "x" or "y" + * @param {Object} controls + * @param {Object} controls + * @param {Object} metadata + * @returns {Object} the `scatterVariables` param with modifications + * @sideEffects adds keys `${axis}Continuous`, `${axis}Domain` to the `scatterVariables` param. + */ +export function addScatterAxisInfo(scatterVariables, axis, controls, tree, metadata) { + const axisVar = scatterVariables[axis]; + if (["div", "num_date"].includes(axisVar) || metadata.colorings[axisVar].type==="continuous") { + scatterVariables[`${axis}Continuous`] = true; + scatterVariables[`${axis}Domain`] = undefined; + return scatterVariables; + } + const {domain, scaleType} = calcColorScale(scatterVariables[axis], controls, tree, null, metadata); + if (scaleType==="continuous") { + scatterVariables[`${axis}Continuous`] = true; + scatterVariables[`${axis}Domain`] = undefined; + return scatterVariables; + } + scatterVariables[`${axis}Continuous`] = false; + scatterVariables[`${axis}Domain`] = domain.slice(); + return scatterVariables; +}