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..c8c7839ed 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, @@ -191,8 +194,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_";