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_";