From e4b02c510e2124c61b09aeb98e08685402539792 Mon Sep 17 00:00:00 2001 From: Trevor Bedford Date: Sun, 11 Jul 2021 15:34:58 -0700 Subject: [PATCH] Zoom to visible nodes This commit re-implements the "zoom to selected" function in the tree panel to emphasize visible nodes by expanding their "yValues" to take up 80% of the vertical span of the panel. Notes on implementation details: - I mirrored redux dataflow of "distanceMeasure" and "layout" to create a new redux variable of "treeZoom". This is defaults to "even" but is updated to "zoom" when clicking the "zoom to selected" tab. Further clicks increment the redux variable to "zoom-2", "zoom-3", etc... and clicking "reset layout" restores it to "even". - A PhyloTree redraw is triggered when redux treeZoom variable is updated. This allows filters to change, etc... without triggering immediate changes to layout, but then clicking "zoom to selected" will redraw layout to emphasize currently selected nodes. - phylotree.layouts contains the actual logic in the calcYValues function. This dynamically sets node.n.yValue based on node.visibility, so that calls to other layout functions like rectangularLayout will have updated node.n.yValue from which to construct node.y. --- src/actions/recomputeReduxState.js | 3 + src/actions/types.js | 1 + src/components/tree/index.js | 1 + src/components/tree/phyloTree/change.js | 10 ++- src/components/tree/phyloTree/layouts.js | 61 ++++++++++++++++++- src/components/tree/phyloTree/phyloTree.js | 1 + src/components/tree/phyloTree/renderers.js | 4 +- .../tree/reactD3Interface/change.js | 6 ++ .../tree/reactD3Interface/initialRender.js | 1 + src/components/tree/tree.js | 26 +++++--- src/middleware/changeURL.js | 4 ++ src/reducers/controls.js | 7 +++ src/util/globals.js | 1 + 13 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index b6e4c3058..89a439751 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -58,6 +58,9 @@ const modifyStateViaURLQuery = (state, query) => { if (query.m && state.branchLengthsToDisplay === "divAndDate") { state["distanceMeasure"] = query.m; } + if (query.z) { + state["treeZoom"] = query.z; + } if (query.c) { state["colorBy"] = query.c; } diff --git a/src/actions/types.js b/src/actions/types.js index 130540fa4..0d352bf72 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -11,6 +11,7 @@ export const SEARCH_INPUT_CHANGE = "SEARCH_INPUT_CHANGE"; export const CHANGE_LAYOUT = "CHANGE_LAYOUT"; export const CHANGE_BRANCH_LABEL = "CHANGE_BRANCH_LABEL"; export const CHANGE_DISTANCE_MEASURE = "CHANGE_DISTANCE_MEASURE"; +export const CHANGE_TREE_ZOOM = "CHANGE_TREE_ZOOM"; export const CHANGE_DATES_VISIBILITY_THICKNESS = "CHANGE_DATES_VISIBILITY_THICKNESS"; export const CHANGE_ABSOLUTE_DATE_MIN = "CHANGE_ABSOLUTE_DATE_MIN"; export const CHANGE_ABSOLUTE_DATE_MAX = "CHANGE_ABSOLUTE_DATE_MAX"; diff --git a/src/components/tree/index.js b/src/components/tree/index.js index b40ef52c0..b90d685ef 100644 --- a/src/components/tree/index.js +++ b/src/components/tree/index.js @@ -13,6 +13,7 @@ const Tree = connect((state) => ({ scatterVariables: state.controls.scatterVariables, temporalConfidence: state.controls.temporalConfidence, distanceMeasure: state.controls.distanceMeasure, + treeZoom: state.controls.treeZoom, mutType: state.controls.mutType, colorScale: state.controls.colorScale, metadata: state.metadata, diff --git a/src/components/tree/phyloTree/change.js b/src/components/tree/phyloTree/change.js index 8369bb5ec..025c8d3d5 100644 --- a/src/components/tree/phyloTree/change.js +++ b/src/components/tree/phyloTree/change.js @@ -256,6 +256,7 @@ export const change = function change({ /* change these things to provided value (unless undefined) */ newDistance = undefined, newLayout = undefined, + newTreeZoom = undefined, updateLayout = undefined, // todo - this seems identical to `newLayout` newBranchLabellingKey = undefined, newTipLabelKey = undefined, @@ -310,7 +311,7 @@ export const change = function change({ svgPropsToUpdate.add("stroke-width"); nodePropsToModify["stroke-width"] = branchThickness; } - if (newDistance || newLayout || updateLayout || zoomIntoClade || svgHasChangedDimensions) { + if (newDistance || newLayout || newTreeZoom || updateLayout || zoomIntoClade || svgHasChangedDimensions) { elemsToUpdate.add(".tip").add(".branch.S").add(".branch.T").add(".branch"); elemsToUpdate.add(".vaccineCross").add(".vaccineDottedLine").add(".conf"); elemsToUpdate.add('.branchLabel').add('.tipLabel'); @@ -344,8 +345,10 @@ export const change = function change({ /* run calculations as needed - these update properties on the phylotreeNodes (similar to updateNodesWithNewData) */ /* distance */ if (newDistance || updateLayout) this.setDistance(newDistance); - /* layout (must run after distance) */ - if (newDistance || newLayout || updateLayout) { + /* treeZoom */ + if (newTreeZoom || updateLayout) this.setTreeZoom(newTreeZoom); + /* layout (must run after distance and treeZoom) */ + if (newDistance || newLayout || newTreeZoom || updateLayout) { this.setLayout(newLayout || this.layout, scatterVariables); } /* show confidences - set this param which actually adds the svg paths for @@ -356,6 +359,7 @@ export const change = function change({ svgPropsToUpdate.has(["stroke-width"]) || newDistance || newLayout || + newTreeZoom || updateLayout || zoomIntoClade || svgHasChangedDimensions || diff --git a/src/components/tree/phyloTree/layouts.js b/src/components/tree/phyloTree/layouts.js index 081568379..b15974a49 100644 --- a/src/components/tree/phyloTree/layouts.js +++ b/src/components/tree/phyloTree/layouts.js @@ -3,10 +3,11 @@ 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 { addLeafCount } from "./helpers"; import { calculateRegressionThroughRoot, calculateRegressionWithFreeIntercept } from "./regression"; import { timerStart, timerEnd } from "../../../util/perf"; import { getTraitFromNode, getDivFromNode } from "../../../util/treeMiscHelpers"; +import { NODE_VISIBLE } from "../../../util/globals"; /** * assigns the attribute this.layout and calls the function that @@ -265,6 +266,64 @@ export const setDistance = function setDistance(distanceAttribute) { timerEnd("setDistance"); }; +/** + * given nodes add y values (node.yvalue) to every node + * Nodes are the phyloTree nodes (i.e. node.n is the redux node) + * Nodes must have parent child links established (via createChildrenAndParents) + * PhyloTree can subsequently use this information. Accessed by prototypes + * rectangularLayout, radialLayout, createChildrenAndParents + * side effects: node.n.yvalue (i.e. in the redux node) and node.yRange (i.e. in the phyloTree node) + */ +export const calcYValues = (nodes, spacing = "even") => { + // console.log("calcYValues started with ", spacing); + let total = 0; /* cumulative counter of y value at tip */ + let calcY; /* fn called calcY(node) to return some amount of y value at a tip */ + if (spacing.includes("zoom") && 'visibility' in nodes[0]) { + const numberOfTips = nodes.length; + const numTipsVisible = nodes.map((d) => d.terminal && d.visibility === NODE_VISIBLE).filter((x) => x).length; + const yPerVisible = (0.8 * numberOfTips) / numTipsVisible; + const yPerNotVisible = (0.2 * numberOfTips) / (numberOfTips - numTipsVisible); + calcY = (node) => { + total += node.visibility === NODE_VISIBLE ? yPerVisible : yPerNotVisible; + return total; + }; + } else { /* fall back to even spacing */ + if (spacing !== "even") console.warn("falling back to even spacing of y values. Unknown arg:", spacing); + calcY = () => ++total; + } + + const recurse = (node) => { + if (node.children) { + for (let i = node.children.length - 1; i >= 0; i--) { + recurse(node.children[i]); + } + } else { + node.n.yvalue = calcY(node); + node.yRange = [node.n.yvalue, node.n.yvalue]; + return; + } + /* if here, then all children have yvalues, but we dont. */ + node.n.yvalue = node.children.reduce((acc, d) => acc + d.n.yvalue, 0) / node.children.length; + node.yRange = [node.n.children[0].yvalue, node.n.children[node.n.children.length - 1].yvalue]; + }; + recurse(nodes[0]); +}; + +/** + * assigns the attribute this.treeZoom and calls the function that + * recalculates yvalues based on treeZoom setting + * @param treeZoom -- how to zoom nodes, eg ["even", "zoom"] + */ +export const setTreeZoom = function setTreeZoom(treeZoom) { + timerStart("setTreeZoom"); + if (typeof treeZoom === "undefined") { + this.treeZoom = "even"; + } else { + this.treeZoom = treeZoom; + } + calcYValues(this.nodes, this.treeZoom); + timerEnd("setTreeZoom"); +}; /** * Initializes and sets the range of the scales (this.xScale, this.yScale) diff --git a/src/components/tree/phyloTree/phyloTree.js b/src/components/tree/phyloTree/phyloTree.js index 13c8c7df4..1c310fd39 100644 --- a/src/components/tree/phyloTree/phyloTree.js +++ b/src/components/tree/phyloTree/phyloTree.js @@ -65,6 +65,7 @@ PhyloTree.prototype.updateColorBy = renderers.updateColorBy; /* C A L C U L A T E G E O M E T R I E S E T C ( M O D I F I E S N O D E S , N O T S V G ) */ PhyloTree.prototype.setDistance = layouts.setDistance; PhyloTree.prototype.setLayout = layouts.setLayout; +PhyloTree.prototype.setTreeZoom = layouts.setTreeZoom; PhyloTree.prototype.rectangularLayout = layouts.rectangularLayout; PhyloTree.prototype.scatterplotLayout = layouts.scatterplotLayout; PhyloTree.prototype.unrootedLayout = layouts.unrootedLayout; diff --git a/src/components/tree/phyloTree/renderers.js b/src/components/tree/phyloTree/renderers.js index a76bd55d2..0e6ca8735 100644 --- a/src/components/tree/phyloTree/renderers.js +++ b/src/components/tree/phyloTree/renderers.js @@ -7,6 +7,7 @@ import { getEmphasizedColor } from "../../../util/colorHelpers"; * @param {d3 selection} svg -- the svg into which the tree is drawn * @param {string} layout -- the layout to be used, e.g. "rect" * @param {string} distance -- the property used as branch length, e.g. div or num_date + * @param {string} treeZoom -- how to to treat spread of yValues, e.g. "even" or "zoom" * @param {object} parameters -- an object that contains options that will be added to this.params * @param {object} callbacks -- an object with call back function defining mouse behavior * @param {array} branchThickness -- array of branch thicknesses (same ordering as tree nodes) @@ -21,7 +22,7 @@ import { getEmphasizedColor } from "../../../util/colorHelpers"; * @param {object} scatterVariables -- {x, y} properties to map nodes => scatterplot (only used if layout="scatter") * @return {null} */ -export const render = function render(svg, layout, distance, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) { +export const render = function render(svg, layout, distance, treeZoom, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) { timerStart("phyloTree render()"); this.svg = svg; this.params = Object.assign(this.params, parameters); @@ -31,6 +32,7 @@ export const render = function render(svg, layout, distance, parameters, callbac /* set x, y values & scale them to the screen */ this.setDistance(distance); + this.setTreeZoom(treeZoom); this.setLayout(layout, scatterVariables); this.mapToScreen(); diff --git a/src/components/tree/reactD3Interface/change.js b/src/components/tree/reactD3Interface/change.js index 085da07e2..c118bb3f0 100644 --- a/src/components/tree/reactD3Interface/change.js +++ b/src/components/tree/reactD3Interface/change.js @@ -51,6 +51,12 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, args.newDistance = newProps.distanceMeasure; } + /* change treeZoom behavior */ + if (oldProps.treeZoom !== newProps.treeZoom) { + args.newTreeZoom = newProps.treeZoom; + args.updateLayout = true; + } + /* change in key used to define branch labels, tip labels */ if (oldProps.canRenderBranchLabels===true && newProps.canRenderBranchLabels===false) { args.newBranchLabellingKey = "none"; diff --git a/src/components/tree/reactD3Interface/initialRender.js b/src/components/tree/reactD3Interface/initialRender.js index 07d593f73..f1e23cf72 100644 --- a/src/components/tree/reactD3Interface/initialRender.js +++ b/src/components/tree/reactD3Interface/initialRender.js @@ -22,6 +22,7 @@ export const renderTree = (that, main, phylotree, props) => { select(ref), props.layout, props.distanceMeasure, + props.treeZoom, { /* parameters (modifies PhyloTree's defaults) */ grid: true, confidence: props.temporalConfidence.display, diff --git a/src/components/tree/tree.js b/src/components/tree/tree.js index 635f584e2..4afecfdda 100644 --- a/src/components/tree/tree.js +++ b/src/components/tree/tree.js @@ -1,6 +1,7 @@ import React from "react"; import { withTranslation } from "react-i18next"; import { updateVisibleTipsAndBranchThicknesses } from "../../actions/tree"; +import { CHANGE_TREE_ZOOM } from "../../actions/types"; import Card from "../framework/card"; import Legend from "./legend/legend"; import PhyloTree from "./phyloTree/phyloTree"; @@ -36,6 +37,7 @@ class Tree extends React.Component { this.clearSelectedTip = callbacks.clearSelectedTip.bind(this); // this.handleIconClickHOF = callbacks.handleIconClickHOF.bind(this); this.redrawTree = () => { + this.props.dispatch({ type: CHANGE_TREE_ZOOM, data: "even" }); this.props.dispatch(updateVisibleTipsAndBranchThicknesses({ root: [0, 0] })); @@ -95,15 +97,8 @@ class Tree extends React.Component { } getStyles = () => { - const activeResetTreeButton = this.props.tree.idxOfInViewRootNode !== 0 || - this.props.treeToo.idxOfInViewRootNode !== 0; - - const filteredTree = !!this.props.tree.idxOfFilteredRoot && - this.props.tree.idxOfInViewRootNode !== this.props.tree.idxOfFilteredRoot; - const filteredTreeToo = !!this.props.treeToo.idxOfFilteredRoot && - this.props.treeToo.idxOfInViewRootNode !== this.props.treeToo.idxOfFilteredRoot; - const activeZoomButton = filteredTree || filteredTreeToo; - + const activeResetTreeButton = true; + const activeZoomButton = true; return { treeButtonsDiv: { zIndex: 100, @@ -141,6 +136,19 @@ class Tree extends React.Component { } zoomToSelected = () => { + // if currently set to "even", start at "zoom" + let treeZoomData = "zoom"; + if (this.props.treeZoom.includes("zoom")) { + // if currently at "zoom", increment to "zoom-2" + if (!this.props.treeZoom.includes("-")) { + treeZoomData = "zoom-2"; + } else { + // if currently at "zoom-2", increment to "zoom-3", etc... + const increment = parseInt(this.props.treeZoom.split('-')[1], 10) + 1; + treeZoomData = "zoom-" + increment.toString(); + } + } + this.props.dispatch({ type: CHANGE_TREE_ZOOM, data: treeZoomData }); this.props.dispatch(updateVisibleTipsAndBranchThicknesses({ root: [this.props.tree.idxOfFilteredRoot, this.props.treeToo.idxOfFilteredRoot] })); diff --git a/src/middleware/changeURL.js b/src/middleware/changeURL.js index 53799a603..e20d0dc47 100644 --- a/src/middleware/changeURL.js +++ b/src/middleware/changeURL.js @@ -112,6 +112,10 @@ export const changeURLMiddleware = (store) => (next) => (action) => { query.p = action.notInURLState === true ? undefined : action.data; break; } + case types.CHANGE_TREE_ZOOM: { + query.z = action.data === state.controls.defaults.treeZoom ? undefined : action.data; + break; + } case types.TOGGLE_SIDEBAR: { // we never add this to the URL on purpose -- it should be manually set as it specifies a world // where resizes can not open / close the sidebar. The exception is if it's toggled, we diff --git a/src/reducers/controls.js b/src/reducers/controls.js index a3cc7525d..706e86ff0 100644 --- a/src/reducers/controls.js +++ b/src/reducers/controls.js @@ -4,6 +4,7 @@ import { defaultGeoResolution, defaultDateRange, defaultDistanceMeasure, defaultLayout, + defaultTreeZoom, defaultMutType, controlsHiddenWidth, strainSymbol, @@ -19,6 +20,7 @@ export const getDefaultControlsState = () => { const defaults = { distanceMeasure: defaultDistanceMeasure, layout: defaultLayout, + treeZoom: defaultTreeZoom, geoResolution: defaultGeoResolution, filters: {}, colorBy: defaultColorBy, @@ -51,6 +53,7 @@ export const getDefaultControlsState = () => { layout: defaults.layout, scatterVariables: {}, distanceMeasure: defaults.distanceMeasure, + treeZoom: defaults.treeZoom, dateMin, dateMinNumeric, dateMax, @@ -153,6 +156,10 @@ const Controls = (state = getDefaultControlsState(), action) => { }); } return Object.assign({}, state, updatesToState); + case types.CHANGE_TREE_ZOOM: + return Object.assign({}, state, { + treeZoom: action.data + }); case types.CHANGE_DATES_VISIBILITY_THICKNESS: { const newDates = { quickdraw: action.quickdraw }; if (action.dateMin) { diff --git a/src/util/globals.js b/src/util/globals.js index 37eab04bf..3a90223cd 100644 --- a/src/util/globals.js +++ b/src/util/globals.js @@ -27,6 +27,7 @@ export const defaultColorBy = "country"; export const defaultGeoResolution = "country"; export const defaultLayout = "rect"; export const defaultDistanceMeasure = "num_date"; +export const defaultTreeZoom = "even"; export const defaultDateRange = 6; export const date_select = true; export const file_prefix = "Zika_";