From 70e15e439796f5249639c7b7c8445e8439a000a9 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 | 59 +++++++++++++++++++ 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 | 27 ++++++--- src/middleware/changeURL.js | 4 ++ src/reducers/controls.ts | 9 ++- src/util/globals.js | 1 + 13 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 588e5dc62..7a6f90b73 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -64,6 +64,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 10a71d8db..543f46d27 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -7,6 +7,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 ded81bef0..414943de2 100644 --- a/src/components/tree/index.js +++ b/src/components/tree/index.js @@ -15,6 +15,7 @@ const Tree = connect((state) => ({ temporalConfidence: state.controls.temporalConfidence, distanceMeasure: state.controls.distanceMeasure, explodeAttr: state.controls.explodeAttr, + treeZoom: state.controls.treeZoom, colorScale: state.controls.colorScale, colorings: state.metadata.colorings, genomeMap: state.entropy.genomeMap, diff --git a/src/components/tree/phyloTree/change.js b/src/components/tree/phyloTree/change.js index a3a0c4277..b7b96a4a5 100644 --- a/src/components/tree/phyloTree/change.js +++ b/src/components/tree/phyloTree/change.js @@ -258,6 +258,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, showAllBranchLabels = undefined, @@ -313,7 +314,7 @@ export const change = function change({ svgPropsToUpdate.add("stroke-width"); nodePropsToModify["stroke-width"] = branchThickness; } - if (newDistance || newLayout || updateLayout || zoomIntoClade || svgHasChangedDimensions || changeNodeOrder) { + if (newDistance || newLayout || newTreeZoom || updateLayout || zoomIntoClade || svgHasChangedDimensions || changeNodeOrder) { elemsToUpdate.add(".tip").add(".branch.S").add(".branch.T").add(".branch"); elemsToUpdate.add(".vaccineCross").add(".vaccineDottedLine").add(".conf"); elemsToUpdate.add('.branchLabel').add('.tipLabel'); @@ -359,8 +360,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 || changeNodeOrder) { + /* treeZoom */ + if (newTreeZoom || updateLayout) this.setTreeZoom(newTreeZoom); + /* layout (must run after distance and treeZoom) */ + if (newDistance || newLayout || newTreeZoom || updateLayout || changeNodeOrder) { this.setLayout(newLayout || this.layout, scatterVariables); } /* show confidences - set this param which actually adds the svg paths for @@ -377,6 +380,7 @@ export const change = function change({ newDistance || newLayout || changeNodeOrder || + newTreeZoom || updateLayout || zoomIntoClade || svgHasChangedDimensions || diff --git a/src/components/tree/phyloTree/layouts.js b/src/components/tree/phyloTree/layouts.js index 226ae2352..230970a8f 100644 --- a/src/components/tree/phyloTree/layouts.js +++ b/src/components/tree/phyloTree/layouts.js @@ -7,6 +7,7 @@ import { timerStart, timerEnd } from "../../../util/perf"; import { getTraitFromNode, getDivFromNode } from "../../../util/treeMiscHelpers"; import { stemParent, nodeOrdering } from "./helpers"; import { numDate } from "../../../util/colorHelpers"; +import { NODE_VISIBLE } from "../../../util/globals"; /** * assigns the attribute this.layout and calls the function that @@ -288,6 +289,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 9c1129c02..abfa04973 100644 --- a/src/components/tree/phyloTree/phyloTree.js +++ b/src/components/tree/phyloTree/phyloTree.js @@ -63,6 +63,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 c955cc978..b45c531a2 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); @@ -42,6 +43,7 @@ export const render = function render(svg, layout, distance, parameters, callbac /* set x, y values & scale them to the screen */ setDisplayOrder(this.nodes); 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 07bff8b1b..0ad46614f 100644 --- a/src/components/tree/reactD3Interface/change.js +++ b/src/components/tree/reactD3Interface/change.js @@ -49,6 +49,12 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, args.changeNodeOrder = true; } + /* 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 b85dd9917..595a41340 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 6589d577c..f3a71093e 100644 --- a/src/components/tree/tree.js +++ b/src/components/tree/tree.js @@ -2,6 +2,7 @@ import React from "react"; import { withTranslation } from "react-i18next"; import { FaSearchMinus } from "react-icons/fa"; 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"; @@ -39,6 +40,7 @@ class Tree extends React.Component { this.clearSelectedNode = callbacks.clearSelectedNode.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] })); @@ -110,15 +112,9 @@ 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; - + // FIXME: double-check this + const activeResetTreeButton = true; + const activeZoomButton = true; const treeIsZoomed = this.props.tree.idxOfInViewRootNode !== 0 || this.props.treeToo.idxOfInViewRootNode !== 0; @@ -167,6 +163,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 a57511c7f..5997a9ec6 100644 --- a/src/middleware/changeURL.js +++ b/src/middleware/changeURL.js @@ -140,6 +140,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.ts b/src/reducers/controls.ts index 6682271a0..1fe003a70 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -4,6 +4,7 @@ import { defaultGeoResolution, defaultDateRange, defaultDistanceMeasure, defaultLayout, + defaultTreeZoom, controlsHiddenWidth, strainSymbol, twoColumnBreakpoint } from "../util/globals"; @@ -43,6 +44,7 @@ export const getDefaultControlsState = () => { const defaults: Partial = { distanceMeasure: defaultDistanceMeasure, layout: defaultLayout, + treeZoom: defaultTreeZoom, geoResolution: defaultGeoResolution, filters: {}, filtersInFooter: [], @@ -70,6 +72,7 @@ export const getDefaultControlsState = () => { layout: defaults.layout, scatterVariables: {}, distanceMeasure: defaults.distanceMeasure, + treeZoom: defaults.treeZoom, dateMin, dateMinNumeric, dateMax, @@ -192,7 +195,11 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con } return Object.assign({}, state, updatesToState); } - case types.CHANGE_DATES_VISIBILITY_THICKNESS: { + case types.CHANGE_TREE_ZOOM: + return Object.assign({}, state, { + treeZoom: action.data + }); + case types.CHANGE_DATES_VISIBILITY_THICKNESS: { const newDates: Partial = { quickdraw: action.quickdraw }; if (action.dateMin) { newDates.dateMin = action.dateMin; diff --git a/src/util/globals.js b/src/util/globals.js index da8964366..14430e545 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_";