diff --git a/src/components/app.js b/src/components/app.js index 8e58d674d..6dcfc9226 100644 --- a/src/components/app.js +++ b/src/components/app.js @@ -11,7 +11,7 @@ import Controls from "./controls/controls"; import { Entropy } from "./charts/entropy"; import Map from "./map/map"; import Info from "./info/info"; -import TreeView from "./tree/treeView"; +import Tree from "./tree"; import { controlsHiddenWidth, narrativeWidth, controlsWidth } from "../util/globals"; import { sidebarColor } from "../globalStyles"; import TitleBar from "./framework/title-bar"; @@ -106,7 +106,7 @@ class App extends React.Component { {this.props.metadata.panels.indexOf("tree") === -1 ? null : ( - + )} {this.props.metadata.panels.indexOf("map") === -1 ? null : ( diff --git a/src/components/download/downloadModal.js b/src/components/download/downloadModal.js index 4cd02c502..605506023 100644 --- a/src/components/download/downloadModal.js +++ b/src/components/download/downloadModal.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { connect } from "react-redux"; import { DISMISS_DOWNLOAD_MODAL } from "../../actions/types"; import { materialButton, medGrey, infoPanelStyles } from "../../globalStyles"; -import { stopProp } from "../tree/tipSelectedPanel"; +import { stopProp } from "../tree/infoPanels/click"; import { authorString } from "../../util/stringHelpers"; import * as helpers from "./helperFunctions"; import * as icons from "../framework/svg-icons"; diff --git a/src/components/tree/treeView.js b/src/components/tree/index.js similarity index 70% rename from src/components/tree/treeView.js rename to src/components/tree/index.js index 0f16a8a42..23e6a3289 100644 --- a/src/components/tree/treeView.js +++ b/src/components/tree/index.js @@ -4,15 +4,16 @@ import { connect } from "react-redux"; import { select } from "d3-selection"; import { ReactSVGPanZoom } from "react-svg-pan-zoom"; import Card from "../framework/card"; -import Legend from "./legend"; +import Legend from "./legend/legend"; import ZoomOutIcon from "../framework/zoom-out-icon"; import ZoomInIcon from "../framework/zoom-in-icon"; -import PhyloTree from "./phyloTree"; +import PhyloTree from "./phyloTree/phyloTree"; import { mediumTransitionDuration } from "../../util/globals"; -import InfoPanel from "./infoPanel"; -import TipSelectedPanel from "./tipSelectedPanel"; +import HoverInfoPanel from "./infoPanels/hover"; +import TipClickedPanel from "./infoPanels/click"; import computeResponsive from "../../util/computeResponsive"; -import * as funcs from "./treeViewFunctions"; +import { updateStylesAndAttrs, salientPropChanges } from "./reactD3Interface"; +import * as callbacks from "./reactD3Interface/callbacks"; /* this.props.tree contains the nodes etc used to build the PhyloTree @@ -38,7 +39,7 @@ there are actually backlinks from the phylotree tree panelLayout: state.controls.panelLayout }; }) -class TreeView extends React.Component { +class Tree extends React.Component { constructor(props) { super(props); this.Viewer = null; @@ -49,6 +50,11 @@ class TreeView extends React.Component { selectedTip: null, tree: null }; + /* bind callbacks */ + this.clearSelectedTip = callbacks.clearSelectedTip.bind(this); + this.resetView = callbacks.resetView.bind(this); + this.onViewerChange = callbacks.onViewerChange.bind(this); + this.handleIconClickHOF = callbacks.handleIconClickHOF.bind(this); } static propTypes = { mutType: PropTypes.string.isRequired @@ -58,7 +64,7 @@ class TreeView extends React.Component { /* This both creates the tree (when it's loaded into redux) and works out what to update, based upon changes to redux.control */ let tree = this.state.tree; - const changes = funcs.salientPropChanges(this.props, nextProps, tree); + const changes = salientPropChanges(this.props, nextProps, tree); /* usefull for debugging: */ // console.log("CWRP Changes:", // Object.keys(changes).filter((k) => !!changes[k]).reduce((o, k) => { @@ -71,11 +77,11 @@ class TreeView extends React.Component { } else if (changes.newData) { tree = this.makeTree(nextProps); /* extra (initial, once only) call to update the tree colouring */ - for (const k in changes) { + for (const k in changes) { // eslint-disable-line changes[k] = false; } changes.colorBy = true; - funcs.updateStylesAndAttrs(this, changes, nextProps, tree); + updateStylesAndAttrs(this, changes, nextProps, tree); this.setState({tree}); if (this.Viewer) { this.Viewer.fitToViewer(); @@ -83,14 +89,14 @@ class TreeView extends React.Component { return null; /* return to avoid an unnecessary updateStylesAndAttrs call */ } if (tree) { - funcs.updateStylesAndAttrs(this, changes, nextProps, tree); + updateStylesAndAttrs(this, changes, nextProps, tree); } return null; } componentDidMount() { const tree = this.makeTree(this.props); - funcs.updateStylesAndAttrs(this, {colorBy: true}, this.props, tree); + updateStylesAndAttrs(this, {colorBy: true}, this.props, tree); this.setState({tree}); if (this.Viewer) { this.Viewer.fitToViewer(); @@ -120,33 +126,33 @@ class TreeView extends React.Component { makeTree(nextProps) { const nodes = nextProps.tree.nodes; - if (nodes && this.refs.d3TreeElement) { - const myTree = new PhyloTree(nodes[0]); + if (nodes && this.d3ref) { + const myTree = new PhyloTree(nodes); // https://facebook.github.io/react/docs/refs-and-the-dom.html myTree.render( - select(this.refs.d3TreeElement), + select(this.d3ref), this.props.layout, this.props.distanceMeasure, { /* options */ grid: true, confidence: nextProps.temporalConfidence.display, showVaccines: !!nextProps.tree.vaccines, - branchLabels: true, //generate DOM object - showBranchLabels: false, //hide them initially -> couple to redux state - tipLabels: true, //generate DOM object - showTipLabels: true //show + branchLabels: true, + showBranchLabels: false, + tipLabels: true, + showTipLabels: true }, { /* callbacks */ - onTipHover: funcs.onTipHover.bind(this), - onTipClick: funcs.onTipClick.bind(this), - onBranchHover: funcs.onBranchHover.bind(this), - onBranchClick: funcs.onBranchClick.bind(this), - onBranchLeave: funcs.onBranchLeave.bind(this), - onTipLeave: funcs.onTipLeave.bind(this), - branchLabel: funcs.branchLabel, - branchLabelSize: funcs.branchLabelSize, + onTipHover: callbacks.onTipHover.bind(this), + onTipClick: callbacks.onTipClick.bind(this), + onBranchHover: callbacks.onBranchHover.bind(this), + onBranchClick: callbacks.onBranchClick.bind(this), + onBranchLeave: callbacks.onBranchLeave.bind(this), + onTipLeave: callbacks.onTipLeave.bind(this), + branchLabel: callbacks.branchLabel, + branchLabelSize: callbacks.branchLabelSize, tipLabel: (d) => d.n.strain, - tipLabelSize: funcs.tipLabelSize.bind(this) + tipLabelSize: callbacks.tipLabelSize.bind(this) }, nextProps.tree.branchThickness, /* guarenteed to be in redux by now */ nextProps.tree.visibility, @@ -154,9 +160,8 @@ class TreeView extends React.Component { nextProps.tree.vaccines ); return myTree; - } else { - return null; } + return null; } render() { @@ -172,7 +177,7 @@ class TreeView extends React.Component { return ( - - funcs.clearSelectedTip.bind(this)(d)} + @@ -202,10 +207,8 @@ class TreeView extends React.Component { detectAutoPan={false} background={"#FFF"} miniaturePosition={"none"} - // onMouseDown={this.startPan.bind(this)} - onDoubleClick={funcs.resetView.bind(this)} - //onMouseUp={this.endPan.bind(this)} - onChangeValue={ funcs.onViewerChange.bind(this) } + onDoubleClick={this.resetView} + onChangeValue={this.onViewerChange} > - + ref={(c) => {this.d3ref = c;}} + /> - - - - - - - - - - - - - - + + + + + + + + + + + + + + { - if (!e) {e = window.event;} + if (!e) {e = window.event;} // eslint-disable-line no-param-reassign e.cancelBubble = true; if (e.stopPropagation) {e.stopPropagation();} }; @@ -39,9 +39,9 @@ const item = (key, value) => ( const formatURL = (url) => { if (url !== undefined && url.startsWith("https_")) { - url = url.replace("https_", "https:"); + return url.replace("https_", "https:"); } else if (url !== undefined && url.startsWith("http_")) { - url = url.replace("http_", "http:"); + return url.replace("http_", "http:"); } return url; }; @@ -79,7 +79,7 @@ const displayVaccineInfo = (d) => { const validValue = (value) => value !== "?" && value !== undefined && value !== "undefined"; const validAttr = (attrs, key) => key in attrs && validValue(attrs[key]); -const TipSelectedPanel = ({tip, goAwayCallback, metadata}) => { +const TipClickedPanel = ({tip, goAwayCallback, metadata}) => { if (!tip) {return null;} const url = validAttr(tip.n.attr, "url") ? formatURL(tip.n.attr.url) : false; const uncertainty = "num_date_confidence" in tip.n.attr && tip.n.attr.num_date_confidence[0] !== tip.n.attr.num_date_confidence[1]; @@ -121,4 +121,4 @@ const TipSelectedPanel = ({tip, goAwayCallback, metadata}) => { ); }; -export default TipSelectedPanel; +export default TipClickedPanel; diff --git a/src/components/tree/infoPanel.js b/src/components/tree/infoPanels/hover.js similarity index 92% rename from src/components/tree/infoPanel.js rename to src/components/tree/infoPanels/hover.js index 46e92a01d..ac123a4b8 100644 --- a/src/components/tree/infoPanel.js +++ b/src/components/tree/infoPanels/hover.js @@ -1,8 +1,8 @@ import React from "react"; -import { infoPanelStyles } from "../../globalStyles"; -import { prettyString } from "../../util/stringHelpers"; -import { numericToCalendar } from "../../util/dateHelpers"; -import { getTipColorAttribute } from "./treeHelpers"; +import { infoPanelStyles } from "../../../globalStyles"; +import { prettyString } from "../../../util/stringHelpers"; +import { numericToCalendar } from "../../../util/dateHelpers"; +import { getTipColorAttribute } from "../treeHelpers"; const infoLineJSX = (item, value) => ( @@ -20,8 +20,8 @@ const infoBlockJSX = (item, values) => (

{item}

- {values.map((k, i) => ( -

+ {values.map((k) => ( +

{k}

))} @@ -69,7 +69,7 @@ const displayColorBy = (d, distanceMeasure, temporalConfidence, colorByConfidenc if (colorByConfidence === true) { const lkey = colorBy + "_confidence"; if (Object.keys(d.attr).indexOf(lkey) === -1) { - console.log("Error - couldn't find confidence vals for ", lkey); + console.error("Error - couldn't find confidence vals for ", lkey); return null; } const vals = Object.keys(d.attr[lkey]) @@ -144,7 +144,7 @@ const getMutationsJSX = (d, mutType) => { } return infoLineJSX("No amino acid mutations", ""); } - console.log("Error parsing mutations for branch", d.strain); + console.warn("Error parsing mutations for branch", d.strain); return null; }; @@ -194,7 +194,7 @@ const getPanelStyling = (d, viewer) => { }; if (pos.x < viewerState.viewerWidth * 0.6) { styles.container.left = pos.x + xOffset; - }else{ + } else { styles.container.right = viewerState.viewerWidth - pos.x + xOffset; } if (pos.y < viewerState.viewerHeight * 0.55) { @@ -207,11 +207,8 @@ const getPanelStyling = (d, viewer) => { const tipDisplayColorByInfo = (d, colorBy, distanceMeasure, temporalConfidence, mutType, colorScale) => { if (colorBy === "num_date") { - if (distanceMeasure === "num_date") { - return null; - } else { - return getBranchTimeJSX(d.n, temporalConfidence); - } + if (distanceMeasure === "num_date") return null; + return getBranchTimeJSX(d.n, temporalConfidence); } if (colorBy.slice(0, 2) === "gt") { const key = mutType === "nuc" ? @@ -236,7 +233,7 @@ const displayVaccineInfo = (d) => { }; /* the actual component - a pure function, so we can return early if needed */ -const InfoPanel = ({tree, mutType, temporalConfidence, distanceMeasure, +const HoverInfoPanel = ({tree, mutType, temporalConfidence, distanceMeasure, hovered, viewer, colorBy, colorByConfidence, colorScale}) => { if (!(tree && hovered)) { return null; @@ -257,7 +254,7 @@ const InfoPanel = ({tree, mutType, temporalConfidence, distanceMeasure, inner = ( {getBranchDescendents(d.n)} - {/*getFrequenciesJSX(d.n, mutType)*/} + {/* getFrequenciesJSX(d.n, mutType) */} {getMutationsJSX(d.n, mutType)} {distanceMeasure === "div" ? getBranchDivJSX(d.n) : getBranchTimeJSX(d.n, temporalConfidence)} {displayColorBy(d.n, distanceMeasure, temporalConfidence, colorByConfidence, colorBy)} @@ -280,4 +277,4 @@ const InfoPanel = ({tree, mutType, temporalConfidence, distanceMeasure, ); }; -export default InfoPanel; +export default HoverInfoPanel; diff --git a/src/components/tree/legend-item.js b/src/components/tree/legend/item.js similarity index 68% rename from src/components/tree/legend-item.js rename to src/components/tree/legend/item.js index 46eb4038b..a8ba2defa 100644 --- a/src/components/tree/legend-item.js +++ b/src/components/tree/legend/item.js @@ -1,13 +1,18 @@ import React from "react"; -import { legendMouseEnterExit } from "../../actions/treeProperties"; -import { dataFont, darkGrey } from "../../globalStyles"; -import { prettyString } from "../../util/stringHelpers"; +import { legendMouseEnterExit } from "../../../actions/treeProperties"; +import { dataFont, darkGrey } from "../../../globalStyles"; +import { prettyString } from "../../../util/stringHelpers"; const LegendItem = ({ - dispatch, transform, - legendRectSize, legendSpacing, - rectStroke, rectFill, - label, dFreq}) => ( + dispatch, + transform, + legendRectSize, + legendSpacing, + rectStroke, + rectFill, + label, + dFreq +}) => ( { diff --git a/src/components/tree/legend.js b/src/components/tree/legend/legend.js similarity index 97% rename from src/components/tree/legend.js rename to src/components/tree/legend/legend.js index 5cd0394cc..247c6e0f2 100644 --- a/src/components/tree/legend.js +++ b/src/components/tree/legend/legend.js @@ -2,10 +2,10 @@ import React from "react"; import PropTypes from 'prop-types'; import { connect } from "react-redux"; import { rgb } from "d3-color"; -import LegendItem from "./legend-item"; -import { headerFont, darkGrey } from "../../globalStyles"; -import { legendRectSize, legendSpacing, fastTransitionDuration } from "../../util/globals"; -import { determineColorByGenotypeType } from "../../util/colorHelpers"; +import LegendItem from "./item"; +import { headerFont, darkGrey } from "../../../globalStyles"; +import { legendRectSize, legendSpacing, fastTransitionDuration } from "../../../util/globals"; +import { determineColorByGenotypeType } from "../../../util/colorHelpers"; @connect((state) => { diff --git a/src/components/tree/phyloTree.js b/src/components/tree/phyloTree.js deleted file mode 100644 index a1e20e9ab..000000000 --- a/src/components/tree/phyloTree.js +++ /dev/null @@ -1,1480 +0,0 @@ -/* eslint-disable */ -import _debounce from "lodash/debounce"; -import { event } from "d3-selection"; -import { min, max, sum } from "d3-array"; -import { scaleLinear } from "d3-scale"; -import { flattenTree, appendParentsToTree } from "./treeHelpers"; -import { dataFont, darkGrey } from "../../globalStyles"; - -/* - * adds the total number of descendant leaves to each node in the tree - * the functions works recursively. - * @params: - * node -- root node of the tree. - */ -const addLeafCount = function (node) { - if (node.terminal) { - node.leafCount=1; - }else{ - node.leafCount=0; - for (var i=0; i ({ - n: d, // .n is the original node - x: 0, // x,y coordinates - y: 0, - r: this.params.tipRadius // set defaults - })); - // pull out the total number of tips -- the is the maximal yvalue - this.numberOfTips = max(this.nodes.map(function(d) { - return d.n.yvalue; - })); - this.nodes.forEach(function(d) { - d.inView=true; // each node is visible - d.n.shell = d; // a back link from the original node object to the wrapper - d.terminal = (typeof d.n.children === "undefined"); - }); - - // remember the range of children subtending a node (i.e. the range of yvalues) - // this is useful for drawing - // and create children structure for the shell. - this.nodes.forEach(function(d) { - d.parent = d.n.parent.shell; - if (d.terminal) { - d.yRange = [d.n.yvalue, d.n.yvalue]; - d.children=null; - } else { - d.yRange = [d.n.children[0].yvalue, d.n.children[d.n.children.length - 1].yvalue]; - d.children = []; - for (var i=0; i < d.n.children.length; i++){ - d.children.push(d.n.children[i].shell); - } - } - }); - - this.xScale = scaleLinear(); - this.yScale = scaleLinear(); - this.zoomNode = this.nodes[0]; - addLeafCount(this.nodes[0]); - - /* debounced functions (AFAIK you can't define these as normal prototypes as they need "this") */ - this.debouncedMapToScreen = _debounce(this.mapToScreen, this.params.mapToScreenDebounceTime, - {leading: false, trailing: true, maxWait: this.params.mapToScreenDebounceTime}); -}; - -/* - * set default values. - */ -PhyloTree.prototype.setDefaults = function () { - this.grid = false; - this.attributes = ['r', 'cx', 'cy', 'id', 'class', 'd']; - this.params = { - regressionStroke: darkGrey, - regressionWidth: 6, - majorGridStroke: "#CCC", - majorGridWidth: 2, - minorGridStroke: "#DDD", - minorGridWidth: 1, - tickLabelSize: 12, - tickLabelFill: darkGrey, - minorTicksTimeTree: 3, - minorTicks: 4, - orientation: [1,1], - margins: {left:25, right:15, top:5, bottom:25}, - showGrid: true, - fillSelected:"#A73", - radiusSelected:5, - branchStroke: "#AAA", - branchStrokeWidth: 2, - tipStroke: "#AAA", - tipFill: "#CCC", - tipStrokeWidth: 1, - tipRadius: 4, - fontFamily: dataFont, - branchLabels:false, - showBranchLabels:false, - branchLabelFont: dataFont, - branchLabelFill: "#555", - branchLabelPadX: 8, - branchLabelPadY:5, - tipLabels:true, - // showTipLabels:true, - tipLabelFont: dataFont, - tipLabelFill: "#555", - tipLabelPadX: 8, - tipLabelPadY: 2, - showVaccines: false, - mapToScreenDebounceTime: 500 - }; -}; - - -/** - * @param svg -- the svg into which the tree is drawn - * @param layout -- the layout to be used, e.g. "rect" - * @param distance -- the property used as branch length, e.g. div or num_date - * @param options -- an object that contains options that will be added to this.params - * @param callbacks -- an object with call back function defining mouse behavior - * @param branchThickness (OPTIONAL) -- array of branch thicknesses - * @param visibility (OPTIONAL) -- array of "visible" or "hidden" - * @return {null} - */ -PhyloTree.prototype.render = function(svg, layout, distance, options, callbacks, branchThickness, visibility, drawConfidence, vaccines) { - if (branchThickness) { - this.nodes.forEach(function(d, i) { - d["stroke-width"] = branchThickness[i]; - }); - } - this.svg = svg; - this.params = Object.assign(this.params, options); - this.callbacks = callbacks; - this.vaccines = vaccines ? vaccines.map((d) => d.shell) : undefined; - - this.clearSVG(); - this.setDistance(distance); - this.setLayout(layout); - this.mapToScreen(); - if (this.params.showGrid){ - this.addGrid(); - } - if (this.params.branchLabels){ - this.drawBranches(); - } - this.drawTips(); - if (this.params.showVaccines) { - this.drawVaccines(); - } - this.drawCladeLabels(); - if (visibility) { - this.nodes.forEach(function(d, i) { - d["visibility"] = visibility[i]; - }); - this.svg.selectAll(".tip").style("visibility", (d) => d["visibility"]); - } - - // setting branchLabels and tipLabels to false above in params is not working for some react-dimensions - // hence the commenting here - // if (this.params.branchLabels){ - // this.drawBranchLabels(); - // } - /* don't even bother initially - there will be too many! */ - // if (this.params.tipLabels){ - // this.updateTipLabels(100); - // } - - this.updateGeometry(10); - - this.svg.selectAll(".regression").remove(); - if (this.layout === "clock" && this.distance === "num_date") { - this.drawRegression(); - } - this.removeConfidence(); - if (drawConfidence) { - this.drawConfidence(); - } -}; - -/* - * update branchThicknesses without modifying the SVG - */ -// PhyloTree.prototype.changeBranchThicknessAttr = function (thicknesses) { -// this.nodes.forEach(function(d, i) { -// if (thicknesses[i] !== d["stroke-width"]) { -// d["stroke-width"] = thicknesses[i]; -// } -// }); -// }; - -/* - * set the property that is used as distance along branches - * this is set to "depth" of each node. depth is later used to - * calculate coordinates. Parent depth is assigned as well. - */ -PhyloTree.prototype.setDistance = function(distanceAttribute) { - this.nodes.forEach(function(d) { - d.update = true - }); - if (typeof distanceAttribute === "undefined") { - this.distance = "div"; // default is "div" for divergence - } else { - this.distance = distanceAttribute; - } - // assign node and parent depth - var tmp_dist = this.distance; - this.nodes.forEach(function(d) { - d.depth = d.n.attr[tmp_dist]; - d.pDepth = d.n.parent.attr[tmp_dist]; - if (d.n.attr[tmp_dist+"_confidence"]){ - d.conf = d.n.attr[tmp_dist+"_confidence"]; - }else{ - d.conf = [d.depth, d.depth]; - } - }); -}; - - - -/** - * assigns the attribute this.layout and calls the function that - * calculates the x,y coordinates for the respective layouts - * @param layout -- the layout to be used, has to be one of - * ["rect", "radial", "unrooted", "clock"] - */ -PhyloTree.prototype.setLayout = function(layout){ - if (typeof layout==="undefined" || layout!==this.layout){ - this.nodes.forEach(function(d){d.update=true}); - } - if (typeof layout==="undefined"){ - this.layout = "rect"; - }else { - this.layout = layout; - } - if (this.layout==="rect"){ - this.rectangularLayout(); - } else if (this.layout==="clock"){ - this.timeVsRootToTip(); - } else if (this.layout==="radial"){ - this.radialLayout(); - } else if (this.layout==="unrooted"){ - this.unrootedLayout(); - } -}; - - - -/// ASSIGN XY COORDINATES FOR DIFFERENCE LAYOUTS - -/** - * assignes x,y coordinates for a rectancular layout - * @return {null} - */ -PhyloTree.prototype.rectangularLayout = function() { - this.nodes.forEach(function(d) { - d.y = d.n.yvalue; // precomputed y-values - d.x = d.depth; // depth according to current distance - d.px = d.pDepth; // parent positions - d.py = d.y; - d.x_conf = d.conf; // assign confidence intervals - }); -}; - -/** - * assign x,y coordinates fro the root-to-tip regression layout - * this requires a time tree with attr["num_date"] set - * in addition, this function calculates a regression between - * num_date and div which is saved as this.regression - * @return {null} - */ -PhyloTree.prototype.timeVsRootToTip = function(){ - this.nodes.forEach(function (d) { - d.y = d.n.attr["div"]; - d.x = d.n.attr["num_date"]; - d.px = d.n.parent.attr["num_date"]; - d.py = d.n.parent.attr["div"]; - }); - const nTips = this.numberOfTips; - // REGRESSION WITH FREE INTERCEPT - // const meanDiv = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>d.y))/nTips; - // const meanTime = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>d.depth))/nTips; - // const covarTimeDiv = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.y-meanDiv)*(d.depth-meanTime)))/nTips; - // const varTime = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.depth-meanTime)*(d.depth-meanTime)))/nTips; - //const slope = covarTimeDiv/varTime; - //const intercept = meanDiv-meanTime*slope; - // REGRESSION THROUGH ROOT - const offset = this.nodes[0].depth; - const XY = sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.y)*(d.depth-offset)))/nTips; - const secondMomentTime = sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.depth-offset)*(d.depth-offset)))/nTips; - const slope = XY/secondMomentTime; - const intercept = -offset*slope; - this.regression = {slope:slope, intercept: intercept}; -}; - -/** - * draws the regression line in the svg and adds a text with the rate estimate - * @return {null} - */ -PhyloTree.prototype.drawRegression = function(){ - const leftY = this.yScale(this.regression.intercept+this.xScale.domain()[0]*this.regression.slope); - const rightY = this.yScale(this.regression.intercept+this.xScale.domain()[1]*this.regression.slope); - - const path = "M "+this.xScale.range()[0].toString()+" "+leftY.toString() - +" L " + this.xScale.range()[1].toString()+" "+rightY.toString(); - this.svg - .append("path") - .attr("d", path) - .attr("class", "regression") - .style("fill", "none") - .style("visibility", "visible") - .style("stroke",this.params.regressionStroke) - .style("stroke-width",this.params.regressionWidth); - this.svg - .append("text") - .text("rate estimate: "+this.regression.slope.toFixed(4)+' / year') - .attr("class", "regression") - .attr("x", this.xScale.range()[1] / 2 - 75) - .attr("y", this.yScale.range()[0] + 50) - .style("fill", this.params.regressionStroke) - .style("font-size", this.params.tickLabelSize + 8) - .style("font-weight", 400) - .style("font-family",this.params.fontFamily); -}; - -/** - * calculates and assigns x,y coordinates for the radial layout. - * in addition to x,y, this calculates the end-points of the radial - * arcs and whether that arc is more than pi or not - * @return {null} - */ -PhyloTree.prototype.radialLayout = function() { - const nTips = this.numberOfTips; - const offset = this.nodes[0].depth; - this.nodes.forEach(function(d) { - const angleCBar1 = 2.0 * 0.95 * Math.PI * d.yRange[0] / nTips; - const angleCBar2 = 2.0 * 0.95 * Math.PI * d.yRange[1] / nTips; - d.angle = 2.0 * 0.95 * Math.PI * d.n.yvalue / nTips; - d.y = (d.depth - offset) * Math.cos(d.angle); - d.x = (d.depth - offset) * Math.sin(d.angle); - d.py = d.y * (d.pDepth - offset) / (d.depth - offset + 1e-15); - d.px = d.x * (d.pDepth - offset) / (d.depth - offset + 1e-15); - d.yCBarStart = (d.depth - offset) * Math.cos(angleCBar1); - d.xCBarStart = (d.depth - offset) * Math.sin(angleCBar1); - d.yCBarEnd = (d.depth - offset) * Math.cos(angleCBar2); - d.xCBarEnd = (d.depth - offset) * Math.sin(angleCBar2); - d.smallBigArc = Math.abs(angleCBar2 - angleCBar1) > Math.PI * 1.0; - }); -}; - -/** - * calculates x,y coordinates for the unrooted layout. this is - * done recursively via a the function unrootedPlaceSubtree - * @return {null} - */ -PhyloTree.prototype.unrootedLayout = function(){ - const nTips=this.numberOfTips; - //postorder iteration to determine leaf count of every node - addLeafCount(this.nodes[0]); - //calculate branch length from depth - this.nodes.forEach(function(d){d.branchLength = d.depth - d.pDepth;}); - //preorder iteration to layout nodes - this.nodes[0].x = 0; - this.nodes[0].y = 0; - this.nodes[0].px = 0; - this.nodes[0].py = 0; - this.nodes[0].w = 2*Math.PI; - this.nodes[0].tau = 0; - var eta = 1.5*Math.PI; - for (var i=0; ispanY) ? (spanX-spanY)*0.5 : 0.0; - const xSlack = (spanX0){ - this.xScale.range([margins["left"] || 0, width - (margins["right"] || 0)]); - }else{ - this.xScale.range([width - (margins["right"] || 0), margins["left"] || 0]); - } - if (this.params.orientation[1]>0){ - this.yScale.range([margins["top"] || 0, height - (margins["bottom"] || 0)]); - } else { - this.yScale.range([height - (margins["bottom"] || 0), margins["top"] || 0]); - } - } -}; - -/** - * remove the grid - */ -PhyloTree.prototype.removeGrid = function() { - this.svg.selectAll(".majorGrid").remove(); - this.svg.selectAll(".minorGrid").remove(); - this.svg.selectAll(".gridTick").remove(); - this.grid = false; -}; - -/** - * hide the grid - */ -PhyloTree.prototype.hideGrid = function() { - this.svg.selectAll(".majorGrid").style('visibility', 'hidden'); - this.svg.selectAll(".minorGrid").style('visibility', 'hidden'); - this.svg.selectAll(".gridTick").style('visibility', 'hidden'); -}; - -/** - * hide branchLabels - */ -PhyloTree.prototype.hideBranchLabels = function() { - this.params.showBranchLabels=false; - this.svg.selectAll(".branchLabel").style('visibility', 'hidden'); -}; - -/** - * show branchLabels - */ -PhyloTree.prototype.showBranchLabels = function() { - this.params.showBranchLabels=true; - this.svg.selectAll(".branchLabel").style('visibility', 'visible'); -}; - -/* these functions are never called! */ -// /** -// * hide tipLabels -// */ -// PhyloTree.prototype.hideTipLabels = function() { -// this.params.showTipLabels=false; -// this.svg.selectAll(".tipLabel").style('visibility', 'hidden'); -// }; -// -// /** -// * show tipLabels -// */ -// PhyloTree.prototype.showTipLabels = function() { -// this.params.showTipLabels=true; -// this.svg.selectAll(".tipLabel").style('visibility', 'visible'); -// }; - - -/** - * add a grid to the svg - * @param {layout} - */ -PhyloTree.prototype.addGrid = function(layout, yMinView, yMaxView) { - if (typeof layout==="undefined"){ layout=this.layout;} - - const xmin = (this.xScale.domain()[0]>0)?this.xScale.domain()[0]:0.0; - const ymin = this.yScale.domain()[1]; - const ymax = this.yScale.domain()[0]; - const xmax = layout=="radial" - ? max([this.xScale.domain()[1], this.yScale.domain()[1], - -this.xScale.domain()[0], -this.yScale.domain()[0]]) - : this.xScale.domain()[1]; - - const offset = layout==="radial"?this.nodes[0].depth:0.0; - const viewTop = yMaxView ? yMaxView+this.params.margins.top : this.yScale.range()[0]; - const viewBottom = yMinView ? yMinView-this.params.margins.bottom : this.yScale.range()[1]; - - /* should we re-draw the grid? */ - if (!this.gridParams) { - this.gridParams = [xmin, xmax, ymin, ymax, viewTop, viewBottom, layout]; - } else if (xmin === this.gridParams[0] && xmax === this.gridParams[1] && - ymin === this.gridParams[2] && ymax === this.gridParams[3] && - viewTop === this.gridParams[4] && viewBottom === this.gridParams[5] && - layout === this.gridParams[6]) { - // console.log("bailing - no difference"); - return; - } - - /* yes - redraw and update gridParams */ - this.gridParams = [xmin, xmax, ymin, ymax, viewTop, viewBottom, layout]; - - - const gridline = function(xScale, yScale, layout){ - return function(x){ - const xPos = xScale(x[0]-offset); - let tmp_d=""; - if (layout==="rect" || layout==="clock"){ - tmp_d = 'M'+xPos.toString() + - " " + - viewBottom.toString() + - " L " + - xPos.toString() + - " " + - viewTop.toString(); - }else if (layout==="radial"){ - tmp_d = 'M '+xPos.toString() + - " " + - yScale(0).toString() + - " A " + - (xPos - xScale(0)).toString() + - " " + - (yScale(x[0]) - yScale(offset)).toString() + - " 0 1 0 " + - xPos.toString() + - " " + - (yScale(0)+0.001).toString(); - } - return tmp_d; - }; - }; - - const logRange = Math.floor(Math.log10(xmax - xmin)); - const roundingLevel = Math.pow(10, logRange); - const gridMin = Math.floor((xmin+offset)/roundingLevel)*roundingLevel; - const gridPoints = []; - for (let ii = 0; ii <= (xmax + offset - gridMin)/roundingLevel+10; ii++) { - const pos = gridMin + roundingLevel*ii; - if (pos>offset){ - gridPoints.push([pos, pos-offset>xmax?"hidden":"visible", "x"]); - } - } - - const majorGrid = this.svg.selectAll('.majorGrid').data(gridPoints); - - majorGrid.exit().remove(); // EXIT - majorGrid.enter().append("path") // ENTER - .merge(majorGrid) // ENTER + UPDATE - .attr("d", gridline(this.xScale, this.yScale, layout)) - .attr("class", "majorGrid") - .style("fill", "none") - .style("visibility", function (d){return d[1];}) - .style("stroke",this.params.majorGridStroke) - .style("stroke-width",this.params.majorGridWidth); - - const xTextPos = function(xScale, layout){ - return function(x){ - if (x[2]==="x"){ - return layout==="radial" ? xScale(0) : xScale(x[0]); - }else{ - return xScale.range()[1]; - } - } - }; - const yTextPos = function(yScale, layout){ - return function(x){ - if (x[2]==="x"){ - return layout==="radial" ? yScale(x[0]-offset) : viewBottom + 18; - }else{ - return yScale(x[0]); - } - } - }; - - let logRangeY = 0; - if (this.layout==="clock"){ - const roundingLevelY = Math.pow(10, logRangeY); - logRangeY = Math.floor(Math.log10(ymax - ymin)); - const offsetY=0; - const gridMinY = Math.floor((ymin+offsetY)/roundingLevelY)*roundingLevelY; - for (let ii = 0; ii <= (ymax + offsetY - gridMinY)/roundingLevelY+10; ii++) { - const pos = gridMinY + roundingLevelY*ii; - if (pos>offsetY){ - gridPoints.push([pos, pos-offsetY>ymax?"hidden":"visible","y"]); - } - } - } - - const minorRoundingLevel = roundingLevel / (this.distanceMeasure === "num_date" - ? this.params.minorTicksTimeTree - : this.params.minorTicks); - const minorGridPoints = []; - for (let ii = 0; ii <= (xmax + offset - gridMin)/minorRoundingLevel+50; ii++) { - const pos = gridMin + minorRoundingLevel*ii; - if (pos>offset){ - minorGridPoints.push([pos, pos-offset>xmax+minorRoundingLevel?"hidden":"visible"]); - } - } - const minorGrid = this.svg.selectAll('.minorGrid').data(minorGridPoints); - minorGrid.exit().remove(); // EXIT - minorGrid.enter().append("path") // ENTER - .merge(minorGrid) // ENTER + UPDATE - .attr("d", gridline(this.xScale, this.yScale, layout)) - .attr("class", "minorGrid") - .style("fill", "none") - .style("visibility", function (d){return d[1];}) - .style("stroke",this.params.minorGridStroke) - .style("stroke-width",this.params.minorGridWidth); - - const gridLabels = this.svg.selectAll('.gridTick').data(gridPoints); - const precision = Math.max(0, 1-logRange); - const precisionY = Math.max(0, 1-logRangeY); - gridLabels.exit().remove(); // EXIT - gridLabels.enter().append("text") // ENTER - .merge(gridLabels) // ENTER + UPDATE - .text(function(d){return d[0].toFixed(d[2]==='y'?precisionY:precision);}) - .attr("class", "gridTick") - .style("font-size",this.params.tickLabelSize) - .style("font-family",this.params.fontFamily) - .style("fill",this.params.tickLabelFill) - .style("text-anchor", this.layout==="radial" ? "end" : "middle") - .style("visibility", function (d){return d[1];}) - .attr("x", xTextPos(this.xScale, layout)) - .attr("y", yTextPos(this.yScale, layout)); - - this.grid=true; -}; - - -/* - * add and remove elements from tree, initial render - */ -PhyloTree.prototype.clearSVG = function() { - this.svg.selectAll('.tip').remove(); - this.svg.selectAll('.branch').remove(); - this.svg.selectAll('.branchLabel').remove(); - this.svg.selectAll(".vaccine").remove(); -}; - -/** - * adds crosses to the vaccines - * @return {null} - */ -PhyloTree.prototype.drawVaccines = function() { - this.tipElements = this.svg.append("g").selectAll(".vaccine") - .data(this.vaccines) - .enter() - .append("text") - .attr("class", "vaccine") - .attr("x", (d) => d.xTip) - .attr("y", (d) => d.yTip) - .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'central') - .style("font-family", this.params.fontFamily) - .style("font-size", "20px") - .style("stroke", "#fff") - .style("fill", darkGrey) - .text('\u2716') - // .style("cursor", "pointer") - .on("mouseover", (d) => console.log("vaccine", d)) -}; - -/** - * adds all the tip circles to the svg, they have class tip - * @return {null} - */ -PhyloTree.prototype.drawTips = function() { - var params=this.params; - this.tipElements = this.svg.append("g").selectAll(".tip") - .data(this.nodes.filter(function(d) { - return d.terminal; - })) - .enter() - .append("circle") - .attr("class", "tip") - .attr("id", function(d) { - return "tip_" + d.n.clade; - }) - .attr("cx", function(d) { - return d.xTip; - }) - .attr("cy", function(d) { - return d.yTip; - }) - .attr("r", function(d) { - return d.r; - }) - .on("mouseover", (d) => { - this.callbacks.onTipHover(d, event.pageX, event.pageY) - }) - .on("mouseout", (d) => { - this.callbacks.onTipLeave(d) - }) - .on("click", (d) => { - this.callbacks.onTipClick(d) - }) - .style("pointer-events", "auto") - .style("fill", function(d) { - return d.fill || params.tipFill; - }) - .style("stroke", function(d) { - return d.stroke || params.tipStroke; - }) - .style("stroke-width", function(d) { - // return d['stroke-width'] || params.tipStrokeWidth; - return params.tipStrokeWidth; /* don't want branch thicknesses applied */ - }) - .style("cursor", "pointer"); -}; - -/** - * adds all branches to the svg, these are paths with class branch - * @return {null} - */ -PhyloTree.prototype.drawBranches = function() { - var params = this.params; - this.Tbranches = this.svg.append("g").selectAll('.branch') - .data(this.nodes.filter(function(d){return !d.terminal;})) - .enter() - .append("path") - .attr("class", "branch T") - .attr("id", function(d) { - return "branch_T_" + d.n.clade; - }) - .attr("d", function(d) { - return d.branch[1]; - }) - .style("stroke", function(d) { - return d.stroke || params.branchStroke; - }) - .style("stroke-width", function(d) { - return d['stroke-width'] || params.branchStrokeWidth; - }) - .style("fill", "none") - // .style("cursor", "pointer") - .style("pointer-events", "auto") - // .on("mouseover", (d) => { - // this.callbacks.onBranchHover(d, d3.event.pageX, d3.event.pageY) - // }) - // .on("mouseout", (d) => { - // this.callbacks.onBranchLeave(d) - // }) - // .on("click", (d) => { - // this.callbacks.onBranchClick(d) - // }); - this.branches = this.svg.append("g").selectAll('.branch') - .data(this.nodes) - .enter() - .append("path") - .attr("class", "branch S") - .attr("id", function(d) { - return "branch_S_" + d.n.clade; - }) - .attr("d", function(d) { - return d.branch[0]; - }) - .style("stroke", function(d) { - return d.stroke || params.branchStroke; - }) - .style("stroke-linecap", "round") - .style("stroke-width", function(d) { - return d['stroke-width'] || params.branchStrokeWidth; - }) - .style("fill", "none") - .style("cursor", "pointer") - .style("pointer-events", "auto") - .on("mouseover", (d) => { - this.callbacks.onBranchHover(d, event.pageX, event.pageY) - }) - .on("mouseout", (d) => { - this.callbacks.onBranchLeave(d) - }) - .on("click", (d) => { - this.callbacks.onBranchClick(d) - }); -}; - - -// PhyloTree.prototype.drawBranchLabels = function() { -// var params = this.params; -// const bLFunc = this.callbacks.branchLabel; -// this.branchLabels = this.svg.append("g").selectAll('.branchLabel') -// .data(this.nodes) //.filter(function (d){return bLFunc(d)!=="";})) -// .enter() -// .append("text") -// .text(function (d){return bLFunc(d);}) -// .attr("class", "branchLabel") -// .style("text-anchor","end"); -// } - - -PhyloTree.prototype.drawCladeLabels = function() { - this.branchLabels = this.svg.append("g").selectAll('.branchLabel') - .data(this.nodes.filter(function (d) { return typeof d.n.attr.clade_name !== 'undefined'; })) - .enter() - .append("text") - .style("visibility", "visible") - .text(function (d) { return d.n.attr.clade_name; }) - .attr("class", "branchLabel") - .style("text-anchor", "end"); -} - -// PhyloTree.prototype.drawTipLabels = function() { -// var params = this.params; -// const tLFunc = this.callbacks.tipLabel; -// const inViewTerminalNodes = this.nodes -// .filter(function(d){return d.terminal;}) -// .filter(function(d){return d.inView;}); -// console.log(`there are ${inViewTerminalNodes.length} nodes in view`) -// this.tipLabels = this.svg.append("g").selectAll('.tipLabel') -// .data(inViewTerminalNodes) -// .enter() -// .append("text") -// .text(function (d){return tLFunc(d);}) -// .attr("class", "tipLabel"); -// } - -/* C O N F I D E N C E I N T E R V A L S */ - -PhyloTree.prototype.removeConfidence = function (dt) { - if (dt) { - this.svg.selectAll(".conf") - .transition() - .duration(dt) - .style("opacity", 0) - .remove(); - } else { - this.svg.selectAll(".conf").remove(); - } - // this.props.confidence = false; -}; - -PhyloTree.prototype.drawConfidence = function (dt) { - // this.removeConfidence(); // just in case - // console.log("drawing:", this.svg.selectAll(".conf")) - if (dt) { - this.confidence = this.svg.append("g").selectAll(".conf") - .data(this.nodes) - .enter() - .call((sel) => this.drawSingleCI(sel, 0)); - this.svg.selectAll(".conf") - .transition() - .duration(dt) - .style("opacity", 0.5); - } else { - this.confidence = this.svg.append("g").selectAll(".conf") - .data(this.nodes) - .enter() - .call((sel) => this.drawSingleCI(sel, 0.5)); - } - // this.props.confidence = true; -}; - -const confidenceWidth = (el) => - el["stroke-width"] === 1 ? 0 : - el["stroke-width"] > 6 ? el["stroke-width"] + 6 : el["stroke-width"] * 2; - -PhyloTree.prototype.drawSingleCI = function (selection, opacity) { - selection.append("path") - .attr("class", "conf") - .attr("id", (d) => "conf_" + d.n.clade) - .attr("d", (d) => d.confLine) - .style("stroke", (d) => d.stroke || "#888") - .style("opacity", opacity) - .style("fill", "none") - .style("stroke-width", confidenceWidth); -}; - - -PhyloTree.prototype.updateConfidence = function (dt) { - if (dt) { - this.svg.selectAll(".conf") - .transition() - .duration(dt) - .style("stroke", (el) => el.stroke) - .style("stroke-width", confidenceWidth); - } else { - this.svg.selectAll(".conf") - .style("stroke", (el) => el.stroke) - .style("stroke-width", confidenceWidth); - } -}; - -/************************************************/ - -/** - * call to change the distance measure - * @param attr -- attribute to be used as a distance measure, e.g. div or num_date - * @param dt -- time of transition in milliseconds - * @return null - */ -PhyloTree.prototype.updateDistance = function(attr,dt){ - this.setDistance(attr); - this.setLayout(this.layout); - this.mapToScreen(); - this.updateGeometry(dt); - if (this.grid && this.layout!=="unrooted") {this.addGrid(this.layout);} - else this.hideGrid() - this.svg.selectAll(".regression").remove(); - if (this.layout==="clock" && this.distance === "num_date") this.drawRegression(); -}; - - -/** - * call to change the layout - * @param layout -- needs to be one of "rect", "radial", "unrooted", "clock" - * @param dt -- time of transition in milliseconds - * @return null - */ -PhyloTree.prototype.updateLayout = function(layout,dt){ - this.setLayout(layout); - this.mapToScreen(); - this.updateGeometryFade(dt); - if (this.grid && this.layout!=="unrooted") this.addGrid(layout); - else this.hideGrid() - this.svg.selectAll(".regression").remove(); - if (this.layout==="clock" && this.distance === "num_date") this.drawRegression(); -}; - - -/* - * redraw the tree based on the current xTip, yTip, branch attributes - * this function will remove branches, move the tips continuously - * and add the new branches again after the tips arrived at their destination - * @params dt -- time of transition in milliseconds - */ -PhyloTree.prototype.updateGeometryFade = function(dt) { - this.removeConfidence(dt) - // fade out branches - this.svg.selectAll('.branch').filter(function(d) { - return d.update; - }) - .transition().duration(dt * 0.5) - .style("opacity", 0.0); - this.svg.selectAll('.branchLabels').filter(function(d) { - return d.update; - }) - .transition().duration(dt * 0.5) - .style("opacity", 0.0); - this.svg.selectAll('.tipLabels').filter(function(d) { - return d.update; - }) - .transition().duration(dt * 0.5) - .style("opacity", 0.0); - - // closure to move the tips, called via the time out below - const tipTrans = function(tmp_svg, tmp_dt) { - const svg = tmp_svg; - return function() { - svg.selectAll('.tip').filter(function(d) { - return d.update; - }) - .transition().duration(tmp_dt) - .attr("cx", function(d) { - return d.xTip; - }) - .attr("cy", function(d) { - return d.yTip; - }); - svg.selectAll(".vaccine") - .filter((d) => d.update) - .transition() - .duration(dt) - .attr("x", (d) => d.xTip) - .attr("y", (d) => d.yTip); - }; - }; - setTimeout(tipTrans(this.svg, dt), 0.5 * dt); - - // closure to change the branches, called via time out after the tipTrans is done - const flipBranches = function(tmp_svg) { - const svg = tmp_svg; - return function() { - svg.selectAll('.branch').filter('.S').filter(function(d) { - return d.update; - }) - .attr("d", function(d) { - return d.branch[0]; - }); - svg.selectAll('.branch').filter('.T').filter(function(d) { - return d.update; - }) - .attr("d", function(d) { - return d.branch[1]; - }); - }; - }; - setTimeout(flipBranches(this.svg), 0.5 * dt); - - // closure to add the new branches after the tipTrans - const fadeBack = function(tmp_svg, tmp_dt) { - const svg = tmp_svg; - return function(d) { - svg.selectAll('.branch').filter(function(d) { - return d.update; - }) - .transition().duration(0.5 * tmp_dt) - .style("opacity", 1.0) - }; - }; - setTimeout(fadeBack(this.svg, 0.2 * dt), 1.5 * dt); - this.updateBranchLabels(dt); - this.updateTipLabels(dt); -}; - -/** - * transition of branches and tips at the same time. only useful within a layout - * @param dt -- time of transition in milliseconds - * @return {[type]} - */ -PhyloTree.prototype.updateGeometry = function (dt) { - this.svg.selectAll(".tip") - .filter((d) => d.update) - .transition() - .duration(dt) - .attr("cx", (d) => d.xTip) - .attr("cy", (d) => d.yTip); - - this.svg.selectAll(".vaccine") - .filter((d) => d.update) - .transition() - .duration(dt) - .attr("x", (d) => d.xTip) - .attr("y", (d) => d.yTip); - - const branchEls = [".S", ".T"]; - for (let i = 0; i < 2; i++) { - this.svg.selectAll(".branch") - .filter(branchEls[i]) - .filter((d) => d.update) - .transition() - .duration(dt) - .attr("d", (d) => d.branch[i]); - } - - this.svg.selectAll(".conf") - .filter((d) => d.update) - .transition() - .duration(dt) - .attr("d", (dd) => dd.confLine); - - this.updateBranchLabels(dt); - this.updateTipLabels(dt); -}; - - -PhyloTree.prototype.updateBranchLabels = function(dt){ - const xPad = this.params.branchLabelPadX, yPad = this.params.branchLabelPadY; - const nNIV = this.nNodesInView; - const bLSFunc = this.callbacks.branchLabelSize; - const showBL = (this.layout==="rect") && this.params.showBranchLabels; - const visBL = showBL ? "visible" : "hidden"; - this.svg.selectAll('.branchLabel') - .transition().duration(dt) - .attr("x", function(d) { - return d.xTip - xPad; - }) - .attr("y", function(d) { - return d.yTip - yPad; - }) - .attr("visibility",visBL) - .style("fill", this.params.branchLabelFill) - .style("font-family", this.params.branchLabelFont) - .style("font-size", function(d) {return bLSFunc(d, nNIV).toString()+"px";}); -} - - -/* this was the *old* updateTipLabels */ -// PhyloTree.prototype.updateTipLabels = function(dt){ -// const xPad = this.params.tipLabelPadX, yPad = this.params.tipLabelPadY; -// const nNIV = this.nNodesInView; -// const tLSFunc = this.callbacks.tipLabelSize; -// const showTL = (this.layout==="rect") && this.params.showTipLabels; -// const visTL = showTL ? "visible" : "hidden"; -// this.svg.selectAll('.tipLabel') -// .transition().duration(dt) -// .attr("x", function(d) { -// return d.xTip + xPad; -// }) -// .attr("y", function(d) { -// return d.yTip + yPad; -// }) -// .attr("visibility",visTL) -// .style("fill", this.params.tipLabelFill) -// .style("font-family", this.params.tipLabelFont) -// .style("font-size", function(d) {return tLSFunc(d, nNIV).toString()+"px";}); -// } -/* the new updateTipLabels is here: */ -PhyloTree.prototype.updateTipLabels = function(dt) { - this.svg.selectAll('.tipLabel').remove() - var params = this.params; - const tLFunc = this.callbacks.tipLabel; - const xPad = this.params.tipLabelPadX; - const yPad = this.params.tipLabelPadY; - const inViewTerminalNodes = this.nodes - .filter(function(d){return d.terminal;}) - .filter(function(d){return d.inView;}); - // console.log(`there are ${inViewTerminalNodes.length} nodes in view`) - if (inViewTerminalNodes.length < 50) { - // console.log("DRAWING!", inViewTerminalNodes) - window.setTimeout( () => - this.tipLabels = this.svg.append("g").selectAll('.tipLabel') - .data(inViewTerminalNodes) - .enter() - .append("text") - .attr("x", function(d) { - return d.xTip + xPad; - }) - .attr("y", function(d) { - return d.yTip + yPad; - }) - .text(function (d){return tLFunc(d);}) - .attr("class", "tipLabel") - .style('visibility', 'visible') - , dt - ) - } -} - -PhyloTree.prototype.updateTimeBar = function(d){ - return; -} - -/** - * Update multiple style or attributes of tree elements at once - * @param {string} treeElem one of .tip or .branch - * @param {object} attr object containing the attributes to change as keys, array with values as value - * @param {object} styles object containing the styles to change - * @param {int} dt time in milliseconds - */ -PhyloTree.prototype.updateMultipleArray = function(treeElem, attrs, styles, dt, quickdraw) { - // assign new values and decide whether to update - this.nodes.forEach(function(d, i) { - d.update = false; - /* note that this is not node.attr, but element attr such as d["stroke-width"]); - this.svg.selectAll(".branch") - .transition().duration(0) - .filter(".S") - .attr("d", (d) => d.branch[0]); -}; - - -/** - * as updateAttributeArray, but accepts a callback function rather than an array - * with the values. will create array and call updateAttributeArray - * @param treeElem --- the part of the tree to update (.tip, .branch) - * @param attr --- the attribute to update (e.g. r for tipRadius) - * @param callback -- function that assigns the attribute - * @param dt --- time of transition in milliseconds - * @return {[type]} - */ -PhyloTree.prototype.updateStyleOrAttribute = function(treeElem, attr, callback, dt, styleOrAttribute) { - this.updateStyleOrAttributeArray(treeElem, attr, - this.nodes.map(function(d) { - return callback(d); - }), dt, styleOrAttribute); -}; - -/** - * update an attribute of the tree for all nodes - * @param treeElem --- the part of the tree to update (.tip, .branch) - * @param attr --- the attribute to update (e.g. r for tipRadius) - * @param attr_array --- an array with values for every node in the tree - * @param dt --- time of transition in milliseconds - * @return {[type]} - */ -PhyloTree.prototype.updateStyleOrAttributeArray = function(treeElem, attr, attr_array, dt, styleOrAttribute) { - this.nodes.forEach(function(d, i) { - const newAttr = attr_array[i]; - if (newAttr === d[attr]) { - d.update = false; - } else { - d[attr] = newAttr; - d.update = true; - } - }); - if (typeof styleOrAttribute==="undefined"){ - var all_attr = this.attributes; - if (contains(all_attr, attr)){ - styleOrAttribute="attr"; - }else{ - styleOrAttribute="style"; - } - } - if (styleOrAttribute==="style"){ - this.redrawStyle(treeElem, attr, dt); - }else{ - this.redrawAttribute(treeElem, attr, dt); - } -}; - -/** - * update the svg after all new values have been assigned - * @param treeElem -- one of .tip, .branch - * @param attr -- attribute of the tree element to update - * @param dt -- transition time - */ -PhyloTree.prototype.redrawAttribute = function(treeElem, attr, dt) { - this.svg.selectAll(treeElem).filter(function(d) { - return d.update; - }) - .transition().duration(dt) - .attr(attr, function(d) { - return d[attr]; - }); -}; - -/** - * update the svg after all new values have been assigned - * @param treeElem -- one of .tip, .branch - * @param styleElem -- style element of the tree element to update - * @param dt -- transition time - */ -PhyloTree.prototype.redrawStyle = function(treeElem, styleElem, dt) { - this.svg.selectAll(treeElem).filter(function(d) { - return d.update; - }) - .transition().duration(dt) - .style(styleElem, function(d) { - return d[styleElem]; - }); -}; - -export default PhyloTree; diff --git a/src/components/tree/phyloTree/confidence.js b/src/components/tree/phyloTree/confidence.js new file mode 100644 index 000000000..668555ca2 --- /dev/null +++ b/src/components/tree/phyloTree/confidence.js @@ -0,0 +1,61 @@ + +export const removeConfidence = function removeConfidence(dt) { + if (dt) { + this.svg.selectAll(".conf") + .transition().duration(dt) + .style("opacity", 0) + .remove(); + } else { + this.svg.selectAll(".conf").remove(); + } + // this.props.confidence = false; +}; + +export const drawConfidence = function drawConfidence(dt) { + // this.removeConfidence(); // just in case + // console.log("drawing:", this.svg.selectAll(".conf")) + if (dt) { + this.confidence = this.svg.append("g").selectAll(".conf") + .data(this.nodes) + .enter() + .call((sel) => this.drawSingleCI(sel, 0)); + this.svg.selectAll(".conf") + .transition().duration(dt) + .style("opacity", 0.5); + } else { + this.confidence = this.svg.append("g").selectAll(".conf") + .data(this.nodes) + .enter() + .call((sel) => this.drawSingleCI(sel, 0.5)); + } + // this.props.confidence = true; +}; + +const confidenceWidth = (el) => + el["stroke-width"] === 1 ? 0 : + el["stroke-width"] > 6 ? el["stroke-width"] + 6 : el["stroke-width"] * 2; + +export const drawSingleCI = function drawSingleCI(selection, opacity) { + selection.append("path") + .attr("class", "conf") + .attr("id", (d) => "conf_" + d.n.clade) + .attr("d", (d) => d.confLine) + .style("stroke", (d) => d.stroke || "#888") + .style("opacity", opacity) + .style("fill", "none") + .style("stroke-width", confidenceWidth); +}; + + +export const updateConfidence = function updateConfidence(dt) { + if (dt) { + this.svg.selectAll(".conf") + .transition().duration(dt) + .style("stroke", (el) => el.stroke) + .style("stroke-width", confidenceWidth); + } else { + this.svg.selectAll(".conf") + .style("stroke", (el) => el.stroke) + .style("stroke-width", confidenceWidth); + } +}; diff --git a/src/components/tree/phyloTree/defaultParams.js b/src/components/tree/phyloTree/defaultParams.js new file mode 100644 index 000000000..27a437659 --- /dev/null +++ b/src/components/tree/phyloTree/defaultParams.js @@ -0,0 +1,40 @@ +import { dataFont, darkGrey } from "../../../globalStyles"; + +export const defaultParams = { + regressionStroke: darkGrey, + regressionWidth: 6, + majorGridStroke: "#CCC", + majorGridWidth: 2, + minorGridStroke: "#DDD", + minorGridWidth: 1, + tickLabelSize: 12, + tickLabelFill: darkGrey, + minorTicksTimeTree: 3, + minorTicks: 4, + orientation: [1, 1], + margins: {left: 25, right: 15, top: 5, bottom: 25}, + showGrid: true, + fillSelected: "#A73", + radiusSelected: 5, + branchStroke: "#AAA", + branchStrokeWidth: 2, + tipStroke: "#AAA", + tipFill: "#CCC", + tipStrokeWidth: 1, + tipRadius: 4, + fontFamily: dataFont, + branchLabels: false, + showBranchLabels: false, + branchLabelFont: dataFont, + branchLabelFill: "#555", + branchLabelPadX: 8, + branchLabelPadY: 5, + tipLabels: true, + // showTipLabels:true, + tipLabelFont: dataFont, + tipLabelFill: "#555", + tipLabelPadX: 8, + tipLabelPadY: 2, + showVaccines: false, + mapToScreenDebounceTime: 500 +}; diff --git a/src/components/tree/phyloTree/generalUpdates.js b/src/components/tree/phyloTree/generalUpdates.js new file mode 100644 index 000000000..77b2f4e26 --- /dev/null +++ b/src/components/tree/phyloTree/generalUpdates.js @@ -0,0 +1,278 @@ + +const contains = (array, elem) => array.some((d) => d === elem); + +/** + * as updateAttributeArray, but accepts a callback function rather than an array + * with the values. will create array and call updateAttributeArray + * @param treeElem --- the part of the tree to update (.tip, .branch) + * @param attr --- the attribute to update (e.g. r for tipRadius) + * @param callback -- function that assigns the attribute + * @param dt --- time of transition in milliseconds + * @return {[type]} + */ +export const updateStyleOrAttribute = function updateStyleOrAttribute(treeElem, attr, callback, dt, styleOrAttribute) { + const attr_array = this.nodes.map((d) => callback(d)); + this.updateStyleOrAttributeArray(treeElem, attr, attr_array, dt, styleOrAttribute); +}; + +/** + * update an attribute of the tree for all nodes + * @param treeElem --- the part of the tree to update (.tip, .branch) + * @param attr --- the attribute to update (e.g. r for tipRadius) + * @param attr_array --- an array with values for every node in the tree + * @param dt --- time of transition in milliseconds + * @return {[type]} + */ +export const updateStyleOrAttributeArray = function updateStyleOrAttributeArray(treeElem, attr, attr_array, dt, styleOrAttribute) { + this.nodes.forEach((d, i) => { + const newAttr = attr_array[i]; + if (newAttr === d[attr]) { + d.update = false; + } else { + d[attr] = newAttr; + d.update = true; + } + }); + if (typeof styleOrAttribute === "undefined") { + styleOrAttribute = contains(this.attributes, attr) ? "attr" : "style"; // eslint-disable-line no-param-reassign + } + if (styleOrAttribute === "style") { + this.redrawStyle(treeElem, attr, dt); + } else { + this.redrawAttribute(treeElem, attr, dt); + } +}; + + +/** + * call to change the distance measure + * @param attr -- attribute to be used as a distance measure, e.g. div or num_date + * @param dt -- time of transition in milliseconds + * @return null + */ +export const updateDistance = function updateDistance(attr, dt) { + this.setDistance(attr); + this.setLayout(this.layout); + this.mapToScreen(); + this.updateGeometry(dt); + if (this.grid && this.layout !== "unrooted") this.addGrid(this.layout); + else this.hideGrid(); + this.svg.selectAll(".regression").remove(); + if (this.layout === "clock" && this.distance === "num_date") this.drawRegression(); +}; + + +/** + * call to change the layout + * @param layout -- needs to be one of "rect", "radial", "unrooted", "clock" + * @param dt -- time of transition in milliseconds + * @return null + */ +export const updateLayout = function updateLayout(layout, dt) { + this.setLayout(layout); + this.mapToScreen(); + this.updateGeometryFade(dt); + if (this.grid && this.layout !== "unrooted") this.addGrid(layout); + else this.hideGrid(); + this.svg.selectAll(".regression").remove(); + if (this.layout === "clock" && this.distance === "num_date") this.drawRegression(); +}; + + +/** + * transition of branches and tips at the same time. only useful within a layout + * @param dt -- time of transition in milliseconds + * @return {[type]} + */ +export const updateGeometry = function updateGeometry(dt) { + this.svg.selectAll(".tip") + .filter((d) => d.update) + .transition() + .duration(dt) + .attr("cx", (d) => d.xTip) + .attr("cy", (d) => d.yTip); + + this.svg.selectAll(".vaccine") + .filter((d) => d.update) + .transition() + .duration(dt) + .attr("x", (d) => d.xTip) + .attr("y", (d) => d.yTip); + + const branchEls = [".S", ".T"]; + for (let i = 0; i < 2; i++) { + this.svg.selectAll(".branch") + .filter(branchEls[i]) + .filter((d) => d.update) + .transition() + .duration(dt) + .attr("d", (d) => d.branch[i]); + } + + this.svg.selectAll(".conf") + .filter((d) => d.update) + .transition().duration(dt) + .attr("d", (dd) => dd.confLine); + + this.updateBranchLabels(dt); + this.updateTipLabels(dt); +}; + + +/* + * redraw the tree based on the current xTip, yTip, branch attributes + * this function will remove branches, move the tips continuously + * and add the new branches again after the tips arrived at their destination + * @params dt -- time of transition in milliseconds + */ +export const updateGeometryFade = function updateGeometryFade(dt) { + this.removeConfidence(dt); + + // fade out branches + this.svg.selectAll('.branch') + .filter((d) => d.update) + .transition().duration(dt * 0.5) + .style("opacity", 0.0); + this.svg.selectAll('.branchLabels') + .filter((d) => d.update) + .transition().duration(dt * 0.5) + .style("opacity", 0.0); + this.svg.selectAll('.tipLabels') + .filter((d) => d.update) + .transition().duration(dt * 0.5) + .style("opacity", 0.0); + + // closure to move the tips, called via the time out below + const tipTransHOF = (svgShadow, dtShadow) => () => { + svgShadow.selectAll('.tip') + .filter((d) => d.update) + .transition().duration(dtShadow) + .attr("cx", (d) => d.xTip) + .attr("cy", (d) => d.yTip); + svgShadow.selectAll(".vaccine") + .filter((d) => d.update) + .transition() + .duration(dtShadow) + .attr("x", (d) => d.xTip) + .attr("y", (d) => d.yTip); + }; + setTimeout(tipTransHOF(this.svg, dt), 0.5 * dt); + + // closure to change the branches, called via time out after the tipTrans is done + const flipBranchesHOF = (svgShadow) => () => { + svgShadow.selectAll('.branch').filter('.S') + .filter((d) => d.update) + .attr("d", (d) => d.branch[0]); + svgShadow.selectAll('.branch').filter('.T') + .filter((d) => d.update) + .attr("d", (d) => d.branch[1]); + }; + setTimeout(flipBranchesHOF(this.svg), 0.5 * dt); + + // closure to add the new branches after the tipTrans + const fadeBackHOF = (svgShadow, dtShadow) => () => { + svgShadow.selectAll('.branch') + .filter((dd) => dd.update) + .transition().duration(0.5 * dtShadow) + .style("opacity", 1.0); + }; + setTimeout(fadeBackHOF(this.svg, 0.2 * dt), 1.5 * dt); + this.updateBranchLabels(dt); + this.updateTipLabels(dt); +}; + + +/** + * Update multiple style or attributes of tree elements at once + * @param {string} treeElem one of .tip or .branch + * @param {object} attr object containing the attributes to change as keys, array with values as value + * @param {object} styles object containing the styles to change + * @param {int} dt time in milliseconds + */ +export const updateMultipleArray = function updateMultipleArray(treeElem, attrs, styles, dt, quickdraw) { + // assign new values and decide whether to update + this.nodes.forEach((d, i) => { + d.update = false; + /* note that this is not node.attr, but element attr such as (selection) => { + for (let i = 0; i < stylesToSet.length; i++) { + const prop = stylesToSet[i]; + selection.style(prop, (d) => d[prop]); + } + for (let i = 0; i < attrToSet.length; i++) { + const prop = attrToSet[i]; + selection.attr(prop, (d) => d[prop]); + } + if (updatePath) { + selection.filter('.S').attr("d", (d) => d.branch[0]); + } + }; + // update the svg via the HOF we just created + if (dt) { + this.svg.selectAll(treeElem) + .filter((d) => d.update) + .transition().duration(dt) + .call(updateSVGHOF(Object.keys(attrs), Object.keys(styles))); + } else { + this.svg.selectAll(treeElem) + .filter((d) => d.update) + .call(updateSVGHOF(Object.keys(attrs), Object.keys(styles))); + } +}; + + +/** + * update the svg after all new values have been assigned + * @param treeElem -- one of .tip, .branch + * @param attr -- attribute of the tree element to update + * @param dt -- transition time + */ +export const redrawAttribute = function redrawAttribute(treeElem, attr, dt) { + this.svg.selectAll(treeElem) + .filter((d) => d.update) + .transition() + .duration(dt) + .attr(attr, (d) => d[attr]); +}; + + +/** + * update the svg after all new values have been assigned + * @param treeElem -- one of .tip, .branch + * @param styleElem -- style element of the tree element to update + * @param dt -- transition time + */ +export const redrawStyle = function redrawStyle(treeElem, styleElem, dt) { + this.svg.selectAll(treeElem) + .filter((d) => d.update) + .transition().duration(dt) + .style(styleElem, (d) => d[styleElem]); +}; diff --git a/src/components/tree/phyloTree/grid.js b/src/components/tree/phyloTree/grid.js new file mode 100644 index 000000000..0afab14a5 --- /dev/null +++ b/src/components/tree/phyloTree/grid.js @@ -0,0 +1,167 @@ +/* eslint-disable space-infix-ops */ +import { max } from "d3-array"; + +export const removeGrid = function removeGrid() { + this.svg.selectAll(".majorGrid").remove(); + this.svg.selectAll(".minorGrid").remove(); + this.svg.selectAll(".gridTick").remove(); + this.grid = false; +}; + +export const hideGrid = function hideGrid() { + this.svg.selectAll(".majorGrid").style('visibility', 'hidden'); + this.svg.selectAll(".minorGrid").style('visibility', 'hidden'); + this.svg.selectAll(".gridTick").style('visibility', 'hidden'); +}; + +/** + * add a grid to the svg + * @param {layout} + */ +export const addGrid = function addGrid(layout, yMinView, yMaxView) { + if (typeof layout==="undefined") {layout=this.layout;} // eslint-disable-line no-param-reassign + + const xmin = (this.xScale.domain()[0]>0)?this.xScale.domain()[0]:0.0; + const ymin = this.yScale.domain()[1]; + const ymax = this.yScale.domain()[0]; + const xmax = layout === "radial" ? + max([this.xScale.domain()[1], this.yScale.domain()[1], -this.xScale.domain()[0], -this.yScale.domain()[0]]) : + this.xScale.domain()[1]; + + const offset = layout==="radial"?this.nodes[0].depth:0.0; + const viewTop = yMaxView ? yMaxView+this.params.margins.top : this.yScale.range()[0]; + const viewBottom = yMinView ? yMinView-this.params.margins.bottom : this.yScale.range()[1]; + + /* should we re-draw the grid? */ + if (!this.gridParams) { + this.gridParams = [xmin, xmax, ymin, ymax, viewTop, viewBottom, layout]; + } else if (xmin === this.gridParams[0] && xmax === this.gridParams[1] && + ymin === this.gridParams[2] && ymax === this.gridParams[3] && + viewTop === this.gridParams[4] && viewBottom === this.gridParams[5] && + layout === this.gridParams[6]) { + // console.log("bailing - no difference"); + return; + } + + /* yes - redraw and update gridParams */ + this.gridParams = [xmin, xmax, ymin, ymax, viewTop, viewBottom, layout]; + + + const gridline = function gridline(xScale, yScale, layoutShadow) { + return (x) => { + const xPos = xScale(x[0]-offset); + let tmp_d=""; + if (layoutShadow==="rect" || layoutShadow==="clock") { + tmp_d = 'M'+xPos.toString() + + " " + + viewBottom.toString() + + " L " + + xPos.toString() + + " " + + viewTop.toString(); + } else if (layoutShadow==="radial") { + tmp_d = 'M '+xPos.toString() + + " " + + yScale(0).toString() + + " A " + + (xPos - xScale(0)).toString() + + " " + + (yScale(x[0]) - yScale(offset)).toString() + + " 0 1 0 " + + xPos.toString() + + " " + + (yScale(0)+0.001).toString(); + } + return tmp_d; + }; + }; + + const logRange = Math.floor(Math.log10(xmax - xmin)); + const roundingLevel = Math.pow(10, logRange); // eslint-disable-line no-restricted-properties + const gridMin = Math.floor((xmin+offset)/roundingLevel)*roundingLevel; + const gridPoints = []; + for (let ii = 0; ii <= (xmax + offset - gridMin)/roundingLevel+10; ii++) { + const pos = gridMin + roundingLevel*ii; + if (pos>offset) { + gridPoints.push([pos, pos-offset>xmax?"hidden":"visible", "x"]); + } + } + + const majorGrid = this.svg.selectAll('.majorGrid').data(gridPoints); + + majorGrid.exit().remove(); // EXIT + majorGrid.enter().append("path") // ENTER + .merge(majorGrid) // ENTER + UPDATE + .attr("d", gridline(this.xScale, this.yScale, layout)) + .attr("class", "majorGrid") + .style("fill", "none") + .style("visibility", (d) => d[1]) + .style("stroke", this.params.majorGridStroke) + .style("stroke-width", this.params.majorGridWidth); + + const xTextPos = (xScale, layoutShadow) => (x) => { + if (x[2] === "x") { + return layoutShadow === "radial" ? xScale(0) : xScale(x[0]); + } + return xScale.range()[1]; + }; + const yTextPos = (yScale, layoutShadow) => (x) => { + if (x[2] === "x") { + return layoutShadow === "radial" ? yScale(x[0]-offset) : viewBottom + 18; + } + return yScale(x[0]); + }; + + + let logRangeY = 0; + if (this.layout==="clock") { + const roundingLevelY = Math.pow(10, logRangeY); // eslint-disable-line no-restricted-properties + logRangeY = Math.floor(Math.log10(ymax - ymin)); + const offsetY=0; + const gridMinY = Math.floor((ymin+offsetY)/roundingLevelY)*roundingLevelY; + for (let ii = 0; ii <= (ymax + offsetY - gridMinY)/roundingLevelY+10; ii++) { + const pos = gridMinY + roundingLevelY*ii; + if (pos>offsetY) { + gridPoints.push([pos, pos-offsetY>ymax ? "hidden" : "visible", "y"]); + } + } + } + + const minorRoundingLevel = roundingLevel / + (this.distanceMeasure === "num_date"? this.params.minorTicksTimeTree : this.params.minorTicks); + const minorGridPoints = []; + for (let ii = 0; ii <= (xmax + offset - gridMin)/minorRoundingLevel+50; ii++) { + const pos = gridMin + minorRoundingLevel*ii; + if (pos>offset) { + minorGridPoints.push([pos, pos-offset>xmax+minorRoundingLevel?"hidden":"visible"]); + } + } + const minorGrid = this.svg.selectAll('.minorGrid').data(minorGridPoints); + minorGrid.exit().remove(); // EXIT + minorGrid.enter().append("path") // ENTER + .merge(minorGrid) // ENTER + UPDATE + .attr("d", gridline(this.xScale, this.yScale, layout)) + .attr("class", "minorGrid") + .style("fill", "none") + .style("visibility", (d) => d[1]) + .style("stroke", this.params.minorGridStroke) + .style("stroke-width", this.params.minorGridWidth); + + const gridLabels = this.svg.selectAll('.gridTick').data(gridPoints); + const precision = Math.max(0, 1-logRange); + const precisionY = Math.max(0, 1-logRangeY); + gridLabels.exit().remove(); // EXIT + gridLabels.enter().append("text") // ENTER + .merge(gridLabels) // ENTER + UPDATE + .text((d) => d[0].toFixed(d[2]==='y' ? precisionY : precision)) + .attr("class", "gridTick") + .style("font-size", this.params.tickLabelSize) + .style("font-family", this.params.fontFamily) + .style("fill", this.params.tickLabelFill) + .style("text-anchor", this.layout==="radial" ? "end" : "middle") + .style("visibility", (d) => d[1]) + .attr("x", xTextPos(this.xScale, layout)) + .attr("y", yTextPos(this.yScale, layout)); + + this.grid=true; +}; diff --git a/src/components/tree/phyloTree/helpers.js b/src/components/tree/phyloTree/helpers.js new file mode 100644 index 000000000..b607569de --- /dev/null +++ b/src/components/tree/phyloTree/helpers.js @@ -0,0 +1,63 @@ + +/* + * adds the total number of descendant leaves to each node in the tree + * the functions works recursively. + * @params: + * node -- root node of the tree. + */ +export const addLeafCount = (node) => { + if (node.terminal) { + node.leafCount = 1; + } else { + node.leafCount = 0; + for (let i = 0; i < node.children.length; i++) { + addLeafCount(node.children[i]); + node.leafCount += node.children[i].leafCount; + } + } +}; + + +/* + * this function takes a call back and applies it recursively + * to all child nodes, including internal nodes + * @params: + * node -- node to whose children the function is to be applied + * func -- call back function to apply + */ +export const applyToChildren = (node, func) => { + func(node); + if (node.terminal) { + return; + } + for (let i = 0; i < node.children.length; i++) { + applyToChildren(node.children[i], func); + } +}; + + +/* +* given nodes, create the shell property, which links the redux properties +* (theoretically immutable) with the phylotree properties (changeable) +*/ + + +/* +* given nodes, create the children and parent properties. +* modifies the nodes argument in place +*/ +export const createChildrenAndParents = (nodes) => { + nodes.forEach((d) => { + d.parent = d.n.parent.shell; + if (d.terminal) { + d.yRange = [d.n.yvalue, d.n.yvalue]; + d.children = null; + } else { + d.yRange = [d.n.children[0].yvalue, d.n.children[d.n.children.length - 1].yvalue]; + d.children = []; + for (let i = 0; i < d.n.children.length; i++) { + d.children.push(d.n.children[i].shell); + } + } + }); +}; diff --git a/src/components/tree/phyloTree/labels.js b/src/components/tree/phyloTree/labels.js new file mode 100644 index 000000000..e11f8a1f1 --- /dev/null +++ b/src/components/tree/phyloTree/labels.js @@ -0,0 +1,111 @@ +/** + * hide branchLabels + */ +export const hideBranchLabels = function hideBranchLabels() { + this.params.showBranchLabels = false; + this.svg.selectAll(".branchLabel").style('visibility', 'hidden'); +}; + +/** + * show branchLabels + */ +export const showBranchLabels = function showBranchLabels() { + this.params.showBranchLabels = true; + this.svg.selectAll(".branchLabel").style('visibility', 'visible'); +}; + +/** + * hide tipLabels - this function is never called! + */ +// PhyloTree.prototype.hideTipLabels = function() { +// this.params.showTipLabels=false; +// this.svg.selectAll(".tipLabel").style('visibility', 'hidden'); +// }; + +/** + * show tipLabels - this function is never called! + */ +// PhyloTree.prototype.showTipLabels = function() { +// this.params.showTipLabels=true; +// this.svg.selectAll(".tipLabel").style('visibility', 'visible'); +// }; + +export const updateTipLabels = function updateTipLabels(dt) { + this.svg.selectAll('.tipLabel').remove(); + const tLFunc = this.callbacks.tipLabel; + const xPad = this.params.tipLabelPadX; + const yPad = this.params.tipLabelPadY; + const inViewTerminalNodes = this.nodes + .filter((d) => d.terminal) + .filter((d) => d.inView); + // console.log(`there are ${inViewTerminalNodes.length} nodes in view`) + if (inViewTerminalNodes.length < 50) { + // console.log("DRAWING!", inViewTerminalNodes) + window.setTimeout(() => { + this.tipLabels = this.svg.append("g").selectAll('.tipLabel') + .data(inViewTerminalNodes) + .enter() + .append("text") + .attr("x", (d) => d.xTip + xPad) + .attr("y", (d) => d.yTip + yPad) + .text((d) => tLFunc(d)) + .attr("class", "tipLabel") + .style('visibility', 'visible'); + }, dt); + } +}; + +export const updateBranchLabels = function updateBranchLabels(dt) { + const xPad = this.params.branchLabelPadX, yPad = this.params.branchLabelPadY; + const nNIV = this.nNodesInView; + const bLSFunc = this.callbacks.branchLabelSize; + const showBL = (this.layout === "rect") && this.params.showBranchLabels; + const visBL = showBL ? "visible" : "hidden"; + this.svg.selectAll('.branchLabel') + .transition().duration(dt) + .attr("x", (d) => d.xTip - xPad) + .attr("y", (d) => d.yTip - yPad) + .attr("visibility", visBL) + .style("fill", this.params.branchLabelFill) + .style("font-family", this.params.branchLabelFont) + .style("font-size", (d) => bLSFunc(d, nNIV).toString() + "px"); +}; + +export const drawCladeLabels = function drawCladeLabels() { + this.branchLabels = this.svg.append("g").selectAll('.branchLabel') + .data(this.nodes.filter((d) => typeof d.n.attr.clade_name !== 'undefined')) + .enter() + .append("text") + .style("visibility", "visible") + .text((d) => d.n.attr.clade_name) + .attr("class", "branchLabel") + .style("text-anchor", "end"); +}; + +// PhyloTree.prototype.drawTipLabels = function() { +// var params = this.params; +// const tLFunc = this.callbacks.tipLabel; +// const inViewTerminalNodes = this.nodes +// .filter(function(d){return d.terminal;}) +// .filter(function(d){return d.inView;}); +// console.log(`there are ${inViewTerminalNodes.length} nodes in view`) +// this.tipLabels = this.svg.append("g").selectAll('.tipLabel') +// .data(inViewTerminalNodes) +// .enter() +// .append("text") +// .text(function (d){return tLFunc(d);}) +// .attr("class", "tipLabel"); +// } + + +// PhyloTree.prototype.drawBranchLabels = function() { +// var params = this.params; +// const bLFunc = this.callbacks.branchLabel; +// this.branchLabels = this.svg.append("g").selectAll('.branchLabel') +// .data(this.nodes) //.filter(function (d){return bLFunc(d)!=="";})) +// .enter() +// .append("text") +// .text(function (d){return bLFunc(d);}) +// .attr("class", "branchLabel") +// .style("text-anchor","end"); +// } diff --git a/src/components/tree/phyloTree/layouts.js b/src/components/tree/phyloTree/layouts.js new file mode 100644 index 000000000..fd3cfa053 --- /dev/null +++ b/src/components/tree/phyloTree/layouts.js @@ -0,0 +1,218 @@ +/* eslint-disable no-multi-spaces */ +import { min, sum } from "d3-array"; +import { addLeafCount } from "./helpers"; + +/** + * assigns the attribute this.layout and calls the function that + * calculates the x,y coordinates for the respective layouts + * @param layout -- the layout to be used, has to be one of + * ["rect", "radial", "unrooted", "clock"] + */ +export const setLayout = function setLayout(layout) { + if (typeof layout === "undefined" || layout !== this.layout) { + this.nodes.forEach((d) => {d.update = true;}); + } + if (typeof layout === "undefined") { + this.layout = "rect"; + } else { + this.layout = layout; + } + if (this.layout === "rect") { + this.rectangularLayout(); + } else if (this.layout === "clock") { + this.timeVsRootToTip(); + } else if (this.layout === "radial") { + this.radialLayout(); + } else if (this.layout === "unrooted") { + this.unrootedLayout(); + } +}; + + +/** + * assignes x,y coordinates for a rectancular layout + * @return {null} + */ +export const rectangularLayout = function rectangularLayout() { + this.nodes.forEach((d) => { + d.y = d.n.yvalue; // precomputed y-values + d.x = d.depth; // depth according to current distance + d.px = d.pDepth; // parent positions + d.py = d.y; + d.x_conf = d.conf; // assign confidence intervals + }); +}; + +/** + * assign x,y coordinates fro the root-to-tip regression layout + * this requires a time tree with attr["num_date"] set + * in addition, this function calculates a regression between + * num_date and div which is saved as this.regression + * @return {null} + */ +export const timeVsRootToTip = function timeVsRootToTip() { + this.nodes.forEach((d) => { + d.y = d.n.attr["div"]; + d.x = d.n.attr["num_date"]; + d.px = d.n.parent.attr["num_date"]; + d.py = d.n.parent.attr["div"]; + }); + const nTips = this.numberOfTips; + // REGRESSION WITH FREE INTERCEPT + // const meanDiv = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>d.y))/nTips; + // const meanTime = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>d.depth))/nTips; + // const covarTimeDiv = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.y-meanDiv)*(d.depth-meanTime)))/nTips; + // const varTime = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.depth-meanTime)*(d.depth-meanTime)))/nTips; + // const slope = covarTimeDiv/varTime; + // const intercept = meanDiv-meanTime*slope; + // REGRESSION THROUGH ROOT + const offset = this.nodes[0].depth; + const XY = sum( + this.nodes.filter((d) => d.terminal) + .map((d) => (d.y) * (d.depth - offset)) + ) / nTips; + const secondMomentTime = sum( + this.nodes.filter((d) => d.terminal) + .map((d) => (d.depth - offset) * (d.depth - offset)) + ) / nTips; + const slope = XY / secondMomentTime; + const intercept = -offset * slope; + this.regression = {slope: slope, intercept: intercept}; +}; + +/* + * Utility function for the unrooted tree layout. + * assigns x,y coordinates to the subtree starting in node + * @params: + * node -- root of the subtree. + * nTips -- total number of tips in the tree. + */ +const unrootedPlaceSubtree = (node, nTips) => { + node.x = node.px + node.branchLength * Math.cos(node.tau + node.w * 0.5); + node.y = node.py + node.branchLength * Math.sin(node.tau + node.w * 0.5); + let eta = node.tau; // eta is the cumulative angle for the wedges in the layout + if (!node.terminal) { + for (let i = 0; i < node.children.length; i++) { + const ch = node.children[i]; + ch.w = 2 * Math.PI * ch.leafCount / nTips; + ch.tau = eta; + eta += ch.w; + ch.px = node.x; + ch.py = node.y; + unrootedPlaceSubtree(ch, nTips); + } + } +}; + + +/** + * calculates x,y coordinates for the unrooted layout. this is + * done recursively via a the function unrootedPlaceSubtree + * @return {null} + */ +export const unrootedLayout = function unrootedLayout() { + const nTips = this.numberOfTips; + // postorder iteration to determine leaf count of every node + addLeafCount(this.nodes[0]); + // calculate branch length from depth + this.nodes.forEach((d) => {d.branchLength = d.depth - d.pDepth;}); + // preorder iteration to layout nodes + this.nodes[0].x = 0; + this.nodes[0].y = 0; + this.nodes[0].px = 0; + this.nodes[0].py = 0; + this.nodes[0].w = 2 * Math.PI; + this.nodes[0].tau = 0; + let eta = 1.5 * Math.PI; + for (let i = 0; i < this.nodes[0].children.length; i++) { + this.nodes[0].children[i].px = 0; + this.nodes[0].children[i].py = 0; + this.nodes[0].children[i].w = 2.0 * Math.PI * this.nodes[0].children[i].leafCount / nTips; + this.nodes[0].children[i].tau = eta; + eta += this.nodes[0].children[i].w; + unrootedPlaceSubtree(this.nodes[0].children[i], nTips); + } +}; + +/** + * calculates and assigns x,y coordinates for the radial layout. + * in addition to x,y, this calculates the end-points of the radial + * arcs and whether that arc is more than pi or not + * @return {null} + */ +export const radialLayout = function radialLayout() { + const nTips = this.numberOfTips; + const offset = this.nodes[0].depth; + this.nodes.forEach((d) => { + const angleCBar1 = 2.0 * 0.95 * Math.PI * d.yRange[0] / nTips; + const angleCBar2 = 2.0 * 0.95 * Math.PI * d.yRange[1] / nTips; + d.angle = 2.0 * 0.95 * Math.PI * d.n.yvalue / nTips; + d.y = (d.depth - offset) * Math.cos(d.angle); + d.x = (d.depth - offset) * Math.sin(d.angle); + d.py = d.y * (d.pDepth - offset) / (d.depth - offset + 1e-15); + d.px = d.x * (d.pDepth - offset) / (d.depth - offset + 1e-15); + d.yCBarStart = (d.depth - offset) * Math.cos(angleCBar1); + d.xCBarStart = (d.depth - offset) * Math.sin(angleCBar1); + d.yCBarEnd = (d.depth - offset) * Math.cos(angleCBar2); + d.xCBarEnd = (d.depth - offset) * Math.sin(angleCBar2); + d.smallBigArc = Math.abs(angleCBar2 - angleCBar1) > Math.PI * 1.0; + }); +}; + +/* + * set the property that is used as distance along branches + * this is set to "depth" of each node. depth is later used to + * calculate coordinates. Parent depth is assigned as well. + */ +export const setDistance = function setDistance(distanceAttribute) { + this.nodes.forEach((d) => {d.update = true;}); + if (typeof distanceAttribute === "undefined") { + this.distance = "div"; // default is "div" for divergence + } else { + this.distance = distanceAttribute; + } + // assign node and parent depth + const tmp_dist = this.distance; + this.nodes.forEach((d) => { + d.depth = d.n.attr[tmp_dist]; + d.pDepth = d.n.parent.attr[tmp_dist]; + if (d.n.attr[tmp_dist + "_confidence"]) { + d.conf = d.n.attr[tmp_dist + "_confidence"]; + } else { + d.conf = [d.depth, d.depth]; + } + }); +}; + + +/** + * sets the range of the scales 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) { + const width = parseInt(this.svg.attr("width"), 10); + const height = parseInt(this.svg.attr("height"), 10); + if (this.layout === "radial" || this.layout === "unrooted") { + // Force Square: TODO, harmonize with the map to screen + const xExtend = width - (margins["left"] || 0) - (margins["right"] || 0); + const yExtend = height - (margins["top"] || 0) - (margins["top"] || 0); + const minExtend = min([xExtend, yExtend]); + const xSlack = xExtend - minExtend; + const ySlack = yExtend - minExtend; + this.xScale.range([0.5 * xSlack + margins["left"] || 0, width - 0.5 * xSlack - (margins["right"] || 0)]); + 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 + if (this.params.orientation[0] > 0) { + this.xScale.range([margins["left"] || 0, width - (margins["right"] || 0)]); + } else { + this.xScale.range([width - (margins["right"] || 0), margins["left"] || 0]); + } + if (this.params.orientation[1] > 0) { + this.yScale.range([margins["top"] || 0, height - (margins["bottom"] || 0)]); + } else { + this.yScale.range([height - (margins["bottom"] || 0), margins["top"] || 0]); + } + } +}; diff --git a/src/components/tree/phyloTree/phyloTree.js b/src/components/tree/phyloTree/phyloTree.js new file mode 100644 index 000000000..2eddd8af6 --- /dev/null +++ b/src/components/tree/phyloTree/phyloTree.js @@ -0,0 +1,107 @@ +import _debounce from "lodash/debounce"; +import { max } from "d3-array"; +import { scaleLinear } from "d3-scale"; +import { defaultParams } from "./defaultParams"; +import { addLeafCount, createChildrenAndParents } from "./helpers"; + +/* PROTOTYPES */ +import * as renderers from "./renderers"; +import * as layouts from "./layouts"; +import * as zoom from "./zoom"; +import * as grid from "./grid"; +import * as confidence from "./confidence"; +import * as labels from "./labels"; +import * as generalUpdates from "./generalUpdates"; + + +/* phylogenetic tree drawing function - the actual tree is rendered by the render prototype */ +const PhyloTree = function PhyloTree(reduxNodes) { + this.grid = false; + this.attributes = ['r', 'cx', 'cy', 'id', 'class', 'd']; + this.params = defaultParams; + + /* create this.nodes, which is an array of nodes with properties used by phylotree for drawing. + this.nodes is the same length as reduxNodes such that this.nodes[i] is related to reduxNodes[i] + Furthermore, these objects are linked: + -- this.nodes[i].n = reduxNodes[i] + -- reduxNodes[i].shell = this.nodes[i] */ + this.nodes = reduxNodes.map((d) => { + const phyloNode = { + n: d, /* a back link to the redux node */ + x: 0, + y: 0, + terminal: (typeof d.children === "undefined"), + inView: true, /* each node is visible */ + r: this.params.tipRadius /* default */ + }; + d.shell = phyloNode; /* set the link from the redux node to the phylotree node */ + return phyloNode; + }); + this.numberOfTips = max(this.nodes.map((d) => d.n.yvalue)); // total number of tips (we kinda cheat by finding the maximal yvalue, made by augur) + createChildrenAndParents(this.nodes); + this.xScale = scaleLinear(); + this.yScale = scaleLinear(); + this.zoomNode = this.nodes[0]; + addLeafCount(this.nodes[0]); + + /* debounced functions (AFAIK you can't define these as normal prototypes as they need "this") */ + this.debouncedMapToScreen = _debounce(this.mapToScreen, this.params.mapToScreenDebounceTime, + {leading: false, trailing: true, maxWait: this.params.mapToScreenDebounceTime}); +}; + +/* I N I T I A L R E N D E R E T C */ +PhyloTree.prototype.render = renderers.render; +PhyloTree.prototype.rerenderAllElements = renderers.rerenderAllElements; +PhyloTree.prototype.clearSVG = renderers.clearSVG; + +/* D R A W I N G F U N C T I O N S */ +PhyloTree.prototype.drawTips = renderers.drawTips; +PhyloTree.prototype.drawBranches = renderers.drawBranches; +PhyloTree.prototype.drawVaccines = renderers.drawVaccines; +PhyloTree.prototype.drawRegression = renderers.drawRegression; + +/* 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.rectangularLayout = layouts.rectangularLayout; +PhyloTree.prototype.timeVsRootToTip = layouts.timeVsRootToTip; +PhyloTree.prototype.unrootedLayout = layouts.unrootedLayout; +PhyloTree.prototype.radialLayout = layouts.radialLayout; +PhyloTree.prototype.setScales = layouts.setScales; + +/* C O N F I D E N C E I N T E R V A L S */ +PhyloTree.prototype.removeConfidence = confidence.removeConfidence; +PhyloTree.prototype.drawConfidence = confidence.drawConfidence; +PhyloTree.prototype.drawSingleCI = confidence.drawSingleCI; +PhyloTree.prototype.updateConfidence = confidence.updateConfidence; + +/* G E N E R A L U P D A T E S */ +PhyloTree.prototype.updateDistance = generalUpdates.updateDistance; +PhyloTree.prototype.updateLayout = generalUpdates.updateLayout; +PhyloTree.prototype.updateGeometry = generalUpdates.updateGeometry; +PhyloTree.prototype.updateGeometryFade = generalUpdates.updateGeometryFade; +PhyloTree.prototype.updateTimeBar = () => {}; +PhyloTree.prototype.updateMultipleArray = generalUpdates.updateMultipleArray; +PhyloTree.prototype.updateStyleOrAttribute = generalUpdates.updateStyleOrAttribute; +PhyloTree.prototype.updateStyleOrAttributeArray = generalUpdates.updateStyleOrAttributeArray; +PhyloTree.prototype.redrawAttribute = generalUpdates.redrawAttribute; +PhyloTree.prototype.redrawStyle = generalUpdates.redrawStyle; + +/* L A B E L S ( T I P , B R A N C H , C O N F I D E N C E ) */ +PhyloTree.prototype.drawCladeLabels = labels.drawCladeLabels; +PhyloTree.prototype.updateBranchLabels = labels.updateBranchLabels; +PhyloTree.prototype.updateTipLabels = labels.updateTipLabels; +PhyloTree.prototype.hideBranchLabels = labels.hideBranchLabels; +PhyloTree.prototype.showBranchLabels = labels.showBranchLabels; + +/* G R I D */ +PhyloTree.prototype.hideGrid = grid.hideGrid; +PhyloTree.prototype.removeGrid = grid.removeGrid; +PhyloTree.prototype.addGrid = grid.addGrid; + +/* Z O O M , F I T TO S C R E E N , E T C */ +PhyloTree.prototype.zoomIntoClade = zoom.zoomIntoClade; +PhyloTree.prototype.zoomToParent = zoom.zoomToParent; +PhyloTree.prototype.mapToScreen = zoom.mapToScreen; + +export default PhyloTree; diff --git a/src/components/tree/phyloTree/renderers.js b/src/components/tree/phyloTree/renderers.js new file mode 100644 index 000000000..1a448037f --- /dev/null +++ b/src/components/tree/phyloTree/renderers.js @@ -0,0 +1,199 @@ +import { darkGrey } from "../../../globalStyles"; + + +/** + * @param svg -- the svg into which the tree is drawn + * @param layout -- the layout to be used, e.g. "rect" + * @param distance -- the property used as branch length, e.g. div or num_date + * @param options -- an object that contains options that will be added to this.params + * @param callbacks -- an object with call back function defining mouse behavior + * @param branchThickness (OPTIONAL) -- array of branch thicknesses + * @param visibility (OPTIONAL) -- array of "visible" or "hidden" + * @return {null} + */ +export const render = function render(svg, layout, distance, options, callbacks, branchThickness, visibility, drawConfidence, vaccines) { + if (branchThickness) { + this.nodes.forEach((d, i) => { + d["stroke-width"] = branchThickness[i]; + }); + } + this.svg = svg; + this.params = Object.assign(this.params, options); + this.callbacks = callbacks; + this.vaccines = vaccines ? vaccines.map((d) => d.shell) : undefined; + + this.clearSVG(); + this.setDistance(distance); + this.setLayout(layout); + this.mapToScreen(); + if (this.params.showGrid) {this.addGrid();} + if (this.params.branchLabels) {this.drawBranches();} + this.drawTips(); + if (this.params.showVaccines) {this.drawVaccines();} + this.drawCladeLabels(); + if (visibility) { + this.nodes.forEach((d, i) => { + d["visibility"] = visibility[i]; + }); + this.svg.selectAll(".tip").style("visibility", (d) => d["visibility"]); + } + + // setting branchLabels and tipLabels to false above in params is not working for some react-dimensions + // hence the commenting here + // if (this.params.branchLabels){ + // this.drawBranchLabels(); + // } + /* don't even bother initially - there will be too many! */ + // if (this.params.tipLabels){ + // this.updateTipLabels(100); + // } + + this.updateGeometry(10); + + this.svg.selectAll(".regression").remove(); + if (this.layout === "clock" && this.distance === "num_date") { + this.drawRegression(); + } + this.removeConfidence(); + if (drawConfidence) { + this.drawConfidence(); + } +}; + +/** + * adds crosses to the vaccines + * @return {null} + */ +export const drawVaccines = function drawVaccines() { + this.tipElements = this.svg.append("g").selectAll(".vaccine") + .data(this.vaccines) + .enter() + .append("text") + .attr("class", "vaccine") + .attr("x", (d) => d.xTip) + .attr("y", (d) => d.yTip) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .style("font-family", this.params.fontFamily) + .style("font-size", "20px") + .style("stroke", "#fff") + .style("fill", darkGrey) + .text('\u2716'); + // .style("cursor", "pointer") + // .on("mouseover", (d) => console.warn("vaccine mouseover", d)); +}; + + +/** + * adds all the tip circles to the svg, they have class tip + * @return {null} + */ +export const drawTips = function drawTips() { + const params = this.params; + this.tipElements = this.svg.append("g").selectAll(".tip") + .data(this.nodes.filter((d) => d.terminal)) + .enter() + .append("circle") + .attr("class", "tip") + .attr("id", (d) => "tip_" + d.n.clade) + .attr("cx", (d) => d.xTip) + .attr("cy", (d) => d.yTip) + .attr("r", (d) => d.r) + .on("mouseover", (d) => this.callbacks.onTipHover(d, event.pageX, event.pageY)) + .on("mouseout", (d) => this.callbacks.onTipLeave(d)) + .on("click", (d) => this.callbacks.onTipClick(d)) + .style("pointer-events", "auto") + .style("fill", (d) => d.fill || params.tipFill) + .style("stroke", (d) => d.stroke || params.tipStroke) + .style("stroke-width", () => params.tipStrokeWidth) /* don't want branch thicknesses applied */ + .style("cursor", "pointer"); +}; + + +/** + * adds all branches to the svg, these are paths with class branch + * @return {null} + */ +export const drawBranches = function drawBranches() { + const params = this.params; + this.Tbranches = this.svg.append("g").selectAll('.branch') + .data(this.nodes.filter((d) => !d.terminal)) + .enter() + .append("path") + .attr("class", "branch T") + .attr("id", (d) => "branch_T_" + d.n.clade) + .attr("d", (d) => d.branch[1]) + .style("stroke", (d) => d.stroke || params.branchStroke) + .style("stroke-width", (d) => d['stroke-width'] || params.branchStrokeWidth) + .style("fill", "none") + .style("pointer-events", "auto"); + + this.branches = this.svg.append("g").selectAll('.branch') + .data(this.nodes) + .enter() + .append("path") + .attr("class", "branch S") + .attr("id", (d) => "branch_S_" + d.n.clade) + .attr("d", (d) => d.branch[0]) + .style("stroke", (d) => d.stroke || params.branchStroke) + .style("stroke-linecap", "round") + .style("stroke-width", (d) => d['stroke-width'] || params.branchStrokeWidth) + .style("fill", "none") + .style("cursor", "pointer") + .style("pointer-events", "auto") + .on("mouseover", (d) => this.callbacks.onBranchHover(d, event.pageX, event.pageY)) + .on("mouseout", (d) => this.callbacks.onBranchLeave(d)) + .on("click", (d) => this.callbacks.onBranchClick(d)); +}; + + +/* this need a bit more work as the quickdraw functionality improves */ +export const rerenderAllElements = function rerenderAllElements() { + // console.log("rerenderAllElements") + this.mapToScreen(); + this.svg.selectAll(".branch") + .transition().duration(0) + .style("stroke-width", (d) => d["stroke-width"]); + this.svg.selectAll(".branch") + .transition().duration(0) + .filter(".S") + .attr("d", (d) => d.branch[0]); +}; + +/** + * draws the regression line in the svg and adds a text with the rate estimate + * @return {null} + */ +export const drawRegression = function drawRegression() { + const leftY = this.yScale(this.regression.intercept + this.xScale.domain()[0] * this.regression.slope); + const rightY = this.yScale(this.regression.intercept + this.xScale.domain()[1] * this.regression.slope); + + const path = "M " + this.xScale.range()[0].toString() + " " + leftY.toString() + + " L " + this.xScale.range()[1].toString() + " " + rightY.toString(); + this.svg.append("path") + .attr("d", path) + .attr("class", "regression") + .style("fill", "none") + .style("visibility", "visible") + .style("stroke", this.params.regressionStroke) + .style("stroke-width", this.params.regressionWidth); + this.svg.append("text") + .text("rate estimate: " + this.regression.slope.toFixed(4) + ' / year') + .attr("class", "regression") + .attr("x", this.xScale.range()[1] / 2 - 75) + .attr("y", this.yScale.range()[0] + 50) + .style("fill", this.params.regressionStroke) + .style("font-size", this.params.tickLabelSize + 8) + .style("font-weight", 400) + .style("font-family", this.params.fontFamily); +}; + +/* + * add and remove elements from tree, initial render + */ +export const clearSVG = function clearSVG() { + this.svg.selectAll('.tip').remove(); + this.svg.selectAll('.branch').remove(); + this.svg.selectAll('.branchLabel').remove(); + this.svg.selectAll(".vaccine").remove(); +}; diff --git a/src/components/tree/phyloTree/zoom.js b/src/components/tree/phyloTree/zoom.js new file mode 100644 index 000000000..591c54e19 --- /dev/null +++ b/src/components/tree/phyloTree/zoom.js @@ -0,0 +1,135 @@ +/* eslint-disable space-infix-ops */ +import { min, max } from "d3-array"; +import { applyToChildren } from "./helpers"; + +/** + * zoom such that a particular clade fills the svg + * @param clade -- branch/node at the root of the clade to zoom into + * @param dt -- time of the transition in milliseconds + * @return {null} + */ +export const zoomIntoClade = function zoomIntoClade(clade, dt) { + // assign all nodes to inView false and force update + this.zoomNode = clade; + this.nodes.forEach((d) => { + d.inView = false; + d.update = true; + }); + // assign all child nodes of the chosen clade to inView=true + // if clade is terminal, apply to parent + if (clade.terminal) { + applyToChildren(clade.parent, (d) => {d.inView = true;}); + } else { + applyToChildren(clade, (d) => {d.inView = true;}); + } + // redraw + this.mapToScreen(); + this.updateGeometry(dt); + if (this.grid) this.addGrid(this.layout); + this.svg.selectAll(".regression").remove(); + if (this.layout === "clock" && this.distance === "num_date") this.drawRegression(); + if (this.params.branchLabels) { + this.updateBranchLabels(dt); + } + this.updateTipLabels(dt); +}; + +/** + * zoom out a little by using the parent of the current clade + * as a zoom focus. + * @param {int} dt [transition time] + */ +export const zoomToParent = function zoomToParent(dt) { + if (this.zoomNode) { + this.zoomIntoClade(this.zoomNode.parent, dt); + } +}; + + +/** +* this function sets the xScale, yScale domains and maps precalculated x,y +* coordinates to their places on the screen +* @return {null} +*/ +export const mapToScreen = function mapToScreen() { + this.setScales(this.params.margins); + // determine x,y values of visibile nodes + const tmp_xValues = this.nodes.filter((d) => {return d.inView;}).map((d) => d.x); + const tmp_yValues = this.nodes.filter((d) => {return d.inView;}).map((d) => d.y); + this.nNodesInView = this.nodes.filter((d) => {return d.inView && d.terminal;}).length; + + 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 minX = min(tmp_xValues); + const minY = min(tmp_yValues); + const spanX = max(tmp_xValues)-minX; + const spanY = max(tmp_yValues)-minY; + const maxSpan = max([spanY, spanX]); + const ySlack = (spanX>spanY) ? (spanX-spanY)*0.5 : 0.0; + const xSlack = (spanX {d.xTip = tmp_xScale(d.x);}); + this.nodes.forEach((d) => {d.yTip = tmp_yScale(d.y);}); + this.nodes.forEach((d) => {d.xBase = tmp_xScale(d.px);}); + this.nodes.forEach((d) => {d.yBase = tmp_yScale(d.py);}); + if (this.params.confidence && this.layout==="rect") { + this.nodes.forEach((d) => {d.xConf = [tmp_xScale(d.conf[0]), tmp_xScale(d.conf[1])];}); + } + + // assign the branches as path to each node for the different layouts + if (this.layout==="clock" || this.layout==="unrooted") { + this.nodes.forEach((d) => { + d.branch = [" M "+d.xBase.toString()+","+d.yBase.toString()+" L "+d.xTip.toString()+","+d.yTip.toString(), ""]; + }); + } else if (this.layout==="rect") { + this.nodes.forEach((d) => {d.cBarStart = tmp_yScale(d.yRange[0]);}); + this.nodes.forEach((d) => {d.cBarEnd = tmp_yScale(d.yRange[1]);}); + const stem_offset = this.nodes.map((d) => {return (0.5*(d.parent["stroke-width"] - d["stroke-width"]) || 0.0);}); + this.nodes.forEach((d, i) => { + d.branch =[" M "+(d.xBase - stem_offset[i]).toString() + +","+d.yBase.toString()+ + " L "+d.xTip.toString()+","+d.yTip.toString(), + " M "+d.xTip.toString()+","+d.cBarStart.toString()+ + " L "+d.xTip.toString()+","+d.cBarEnd.toString()]; + }); + if (this.params.confidence) { + this.nodes.forEach((d) => { + d.confLine =" M "+d.xConf[0].toString()+","+d.yBase.toString()+" L "+d.xConf[1].toString()+","+d.yTip.toString(); + }); + } + } else if (this.layout==="radial") { + const offset = this.nodes[0].depth; + const stem_offset_radial = this.nodes.map((d) => {return (0.5*(d.parent["stroke-width"] - d["stroke-width"]) || 0.0);}); + this.nodes.forEach((d) => {d.cBarStart = tmp_yScale(d.yRange[0]);}); + this.nodes.forEach((d) => {d.cBarEnd = tmp_yScale(d.yRange[1]);}); + this.nodes.forEach((d, i) => { + d.branch =[ + " M "+(d.xBase-stem_offset_radial[i]*Math.sin(d.angle)).toString() + + " "+(d.yBase-stem_offset_radial[i]*Math.cos(d.angle)).toString() + + " L "+d.xTip.toString()+" "+d.yTip.toString(), "" + ]; + if (!d.terminal) { + d.branch[1] =[" M "+tmp_xScale(d.xCBarStart).toString()+" "+tmp_yScale(d.yCBarStart).toString()+ + " A "+(tmp_xScale(d.depth)-tmp_xScale(offset)).toString()+" " + +(tmp_yScale(d.depth)-tmp_yScale(offset)).toString() + +" 0 "+(d.smallBigArc?"1 ":"0 ") +" 1 "+ + " "+tmp_xScale(d.xCBarEnd).toString()+","+tmp_yScale(d.yCBarEnd).toString()]; + } + }); + } +}; diff --git a/src/components/tree/processNodes.js b/src/components/tree/processNodes.js deleted file mode 100644 index deda1508d..000000000 --- a/src/components/tree/processNodes.js +++ /dev/null @@ -1,80 +0,0 @@ -import { calcFullTipCounts, calcBranchLength, calcDates } from "./treeHelpers"; - -export const processNodes = (nodes) => { - const rootNode = nodes[0]; - nodes.forEach((d) => {if (typeof d.attr === "undefined") {d.attr = {};} }); - calcFullTipCounts(rootNode); - calcBranchLength(rootNode); - calcDates(nodes); - nodes.forEach((d) => {d.hasChildren = typeof d.children !== "undefined";}); - /* set an index so that we can access visibility / nodeColors if needed */ - nodes.map((d, idx) => {d.arrayIdx = idx;}); - return nodes; -}; - -const rectangularLayout = (node, distanceMeasure) => { - return {'xVal':(distanceMeasure=='div')?node.xvalue:node.attr[distanceMeasure], - 'yVal':node.yvalue, - 'xValMidpoint':(distanceMeasure=='div')?node.parent.xvalue:node.parent.attr[distanceMeasure], - 'yValMidpoint':node.yvalue - }; -}; - -const vsDateLayout = (node, distanceMeasure) => { - return {'xVal':node.attr['num_date'], 'yVal':node.attr[distanceMeasure], - 'xValMidpoint':node.attr['num_date'], 'yValMidpoint':node.attr[distanceMeasure] - }; -}; - -const radialLayout = (node, distanceMeasure, nTips, rootVal) => { - const circleFraction = -0.9; - const circleStart = Math.PI; - const radius = (distanceMeasure=='div')?node.xvalue:(node.attr[distanceMeasure]-rootVal); - const parentRadius = (distanceMeasure=='div')?node.parent.xvalue:(node.parent.attr[distanceMeasure]-rootVal); - const angle = circleStart + circleFraction*2.0*Math.PI*(nTips-node.yvalue)/nTips; - const parentAngle = circleStart + circleFraction*2.0*Math.PI*(nTips-node.parent.yvalue)/nTips; - const leftRight = node.yvalue>node.parent.yvalue; - const smallBigArc = Math.abs(angle - parentAngle)>Math.Pi*0.5; - return {'xVal':radius*Math.sin(angle), 'yVal':radius*Math.cos(angle), - 'xValMidpoint':parentRadius*Math.sin(angle), - 'yValMidpoint':parentRadius*Math.cos(angle), - 'radius':radius,'radiusInner':parentRadius, - 'angle':angle, 'smallBigArc':smallBigArc, 'leftRight':leftRight}; -}; - -/* Calculate layout geometry for radial and rectangular layouts - * nodes: array of nodes for which x/y coordinates are to be calculated - * nTips: total number of tips (optional) - * distanceMeasures: the different types of distances used to measure - distances on the tree (date, mutations, etc) -*/ -export const calcLayouts = (nodes, distanceMeasures, nTips) => { - if (typeof nTips==='undefined'){ - nTips = nodes.filter((d) => {return !d.hasChildren;} ).length; - } - nodes.forEach( (node, ni) => { - node.geometry = {}; - distanceMeasures.forEach((distanceMeasure, di) => { - const rootVal = (distanceMeasure === "div") ? nodes[0].xvalue : nodes[0].attr[distanceMeasure]; - node.geometry[distanceMeasure]={}; - node.geometry[distanceMeasure]["rect"] = rectangularLayout(node, distanceMeasure); - node.geometry[distanceMeasure]['radial'] = radialLayout(node, distanceMeasure, nTips, rootVal); - node.geometry[distanceMeasure]['vsDate'] = vsDateLayout(node, distanceMeasure); - }); - }); -}; - - -/* Map the precomputed geometries to the coordinates in the SVG - * nodes: array of nodes for which x/y coordinates are to be mapped - * xScale: map of tree layout to coordinate space - * yScale: map of tree layout to coordinate space - * layout: type of layout to use (radial vs rectangular) - * distanceMeasure: data type used to determine tree distances (date, mutations, etc) -*/ -export const mapToCoordinates = (nodes, xScale, yScale, layout, distanceMeasure) => { - nodes.forEach( (node, ni) => { - node.geometry[distanceMeasure][layout]['x'] = xScales(node.geometry[distanceMeasure][layout]['xVal']); - node.geometry[distanceMeasure][layout]['y'] = yScales(node.geometry[distanceMeasure][layout]['yVal']); - }); -} diff --git a/src/components/tree/reactD3Interface/callbacks.js b/src/components/tree/reactD3Interface/callbacks.js new file mode 100644 index 000000000..0fd076706 --- /dev/null +++ b/src/components/tree/reactD3Interface/callbacks.js @@ -0,0 +1,227 @@ +import { rgb } from "d3-color"; +import { interpolateRgb } from "d3-interpolate"; +import { updateVisibleTipsAndBranchThicknesses} from "../../../actions/treeProperties"; +import { mediumTransitionDuration } from "../../../util/globals"; +import { branchOpacityFunction } from "../treeHelpers"; + +/* Callbacks used by the tips / branches when hovered / selected */ + +export const onTipHover = function onTipHover(d, x, y) { + this.state.tree.svg.select("#tip_" + d.n.clade) + .attr("r", (e) => e["r"] + 4); + this.setState({ + hovered: {d, type: ".tip", x, y} + }); +}; + +export const onTipClick = function onTipClick(d) { + // console.log("tip click", d) + this.setState({ + hovered: null, + selectedTip: d + }); + this.props.dispatch(updateVisibleTipsAndBranchThicknesses({tipSelectedIdx: d.n.arrayIdx})); +}; + + +export const onBranchHover = function onBranchHover(d, x, y) { + /* emphasize the color of the branch */ + for (const id of ["#branch_S_" + d.n.clade, "#branch_T_" + d.n.clade]) { + if (this.props.colorByConfidence) { + this.state.tree.svg.select(id) + .style("stroke", (el) => { // eslint-disable-line no-loop-func + const ramp = branchOpacityFunction(this.props.tree.nodes[el.n.arrayIdx].attr[this.props.colorBy + "_entropy"]); + const raw = this.props.tree.nodeColors[el.n.arrayIdx]; + const base = el["stroke"]; + return rgb(interpolateRgb(raw, base)(ramp)).toString(); + }); + } else { + this.state.tree.svg.select(id) + .style("stroke", (el) => this.props.tree.nodeColors[el.n.arrayIdx]); + } + } + if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) { + this.state.tree.svg.append("g").selectAll(".conf") + .data([d]) + .enter() + .call((sel) => this.state.tree.drawSingleCI(sel, 0.5)); + } + this.setState({ + hovered: {d, type: ".branch", x, y} + }); +}; + +export const onBranchClick = function onBranchClick(d) { + this.Viewer.fitToViewer(); + this.state.tree.zoomIntoClade(d, mediumTransitionDuration); + /* to stop multiple phyloTree updates potentially clashing, + we change tipVis after geometry update + transition */ + window.setTimeout( + () => this.props.dispatch(updateVisibleTipsAndBranchThicknesses({idxOfInViewRootNode: d.n.arrayIdx})), + mediumTransitionDuration + ); + this.setState({ + hovered: null, + selectedBranch: d + }); +}; + +/* onBranchLeave called when mouse-off, i.e. anti-hover */ +export const onBranchLeave = function onBranchLeave(d) { + for (const id of ["#branch_T_" + d.n.clade, "#branch_S_" + d.n.clade]) { + this.state.tree.svg.select(id) + .style("stroke", (el) => el["stroke"]); + } + if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) { + this.state.tree.removeConfidence(mediumTransitionDuration); + } + if (this.state.hovered) { + this.setState({hovered: null}); + } +}; + +export const onTipLeave = function onTipLeave(d) { + if (!this.state.selectedTip) { + this.state.tree.svg.select("#tip_" + d.n.clade) + .attr("r", (dd) => dd["r"]); + } + if (this.state.hovered) { + this.setState({hovered: null}); + } +}; + + +/* clearSelectedTip when clicking to go away */ +export const clearSelectedTip = function clearSelectedTip(d) { + this.state.tree.svg.select("#tip_" + d.n.clade) + .attr("r", (dd) => dd["r"]); + this.setState({selectedTip: null, hovered: null}); + /* restore the tip visibility! */ + this.props.dispatch(updateVisibleTipsAndBranchThicknesses()); +}; + + +const visibleArea = function visibleArea(Viewer) { + const V = Viewer.getValue(); + return { + left: -V.e / V.a, + top: -V.f / V.d, + right: (V.viewerWidth - V.e) / V.a, + bottom: (V.viewerHeight - V.f) / V.d + }; +}; + +const resetGrid = function resetGrid() { + const layout = this.props.layout; + if (this.props.layout !== "unrooted") { + const tree = this.state.tree; + // const visibleArea = .visibleArea; + const viewer = this.Viewer; + window.setTimeout(() => { + const view = visibleArea(viewer); + tree.addGrid(layout, view.bottom, view.top); + }, 200); + } +}; + + +export const onViewerChange = function onViewerChange() { + if (this.Viewer && this.state.tree) { + const V = this.Viewer.getValue(); + if (V.mode === "panning") { + resetGrid.bind(this)(); + } else if (V.mode === "idle") { + resetGrid.bind(this); + } + } +}; + +export const resetView = function resetView() { + this.Viewer.fitToViewer(); +}; + + +/* viewEntireTree: go back to the root! */ +export const viewEntireTree = function viewEntireTree() { + /* reset the SVGPanZoom */ + this.Viewer.fitToViewer(); + /* imperitively manipulate SVG tree elements */ + this.state.tree.zoomIntoClade(this.state.tree.nodes[0], mediumTransitionDuration); + /* update branch thicknesses / tip vis after SVG tree elemtents have moved */ + window.setTimeout( + () => this.props.dispatch(updateVisibleTipsAndBranchThicknesses({idxOfInViewRootNode: 0})), + mediumTransitionDuration + ); + this.setState({selectedBranch: null, selectedTip: null}); +}; + +export const handleIconClickHOF = function handleIconClickHOF(tool) { + return () => { + const V = this.Viewer.getValue(); + if (tool === "zoom-in") { + this.Viewer.zoomOnViewerCenter(1.4); + } else if (V.a > 1.0) { // if there is room to zoom out via the SVGPanZoom, do + this.Viewer.zoomOnViewerCenter(0.71); + } else { // otherwise reset view to have SVG fit the viewer + resetView.bind(this)(); + // if we have clade zoom, zoom out to the parent clade + if (this.state.selectedBranch && this.state.selectedBranch.n.arrayIdx) { + const dispatch = this.props.dispatch; + const arrayIdx = this.state.tree.zoomNode.parent.n.arrayIdx; + // reset the "clicked" branch, unset if we zoomed out all the way to the root + this.setState({ + hovered: null, + selectedBranch: (arrayIdx) ? this.state.tree.zoomNode.parent : null + }); + // clear previous timeout bc they potentially mess with the geometry update + if (this.timeout) { + clearTimeout(this.timeout); + } + // call phyloTree to zoom out, this rerenders the geometry + this.state.tree.zoomToParent(mediumTransitionDuration); + // wait and reset visibility + this.timeout = setTimeout(() => { + dispatch(updateVisibleTipsAndBranchThicknesses()); + }, mediumTransitionDuration); + } + } + resetGrid.bind(this)(); + }; +}; + +/** + * @param {node} d tree node object + * @return {string} displayed as label on the branch corresponding to the node + */ +export const branchLabel = function branchLabel(d) { + if (d.n.muts) { + if (d.n.muts.length > 5) { + return d.n.muts.slice(0, 5).join(", ") + "..."; + } + return d.n.muts.join(", "); + } + return ""; +}; + +/** + * @param {node} d tree node object + * @param {int} n total number of nodes in current view + * @return {int} font size of the branch label + */ +export const branchLabelSize = (d, n) => + d.leafCount > n / 10.0 ? 12 : 0; + +/** + * @param {node} d tree node object + * @param {int} n total number of nodes in current view + * @return {int} font size of the tip label + */ +export const tipLabelSize = (d, n) => { + if (n > 70) { + return 0; + } else if (n < 20) { + return 14; + } + const fs = 6 + 8 * (70 - n) / (70 - 20); + return fs; +}; diff --git a/src/components/tree/reactD3Interface/index.js b/src/components/tree/reactD3Interface/index.js new file mode 100644 index 000000000..b9323add0 --- /dev/null +++ b/src/components/tree/reactD3Interface/index.js @@ -0,0 +1,129 @@ +import { rgb } from "d3-color"; +import { mediumTransitionDuration } from "../../../util/globals"; +import { calcStrokeCols } from "../treeHelpers"; +import { viewEntireTree } from "./callbacks"; +/** + * function to help determine what parts of phylotree should update + * @param {obj} props redux props + * @param {obj} nextProps next redux props + * @param {obj} tree phyloTree object (stored in the state of the Tree component) + * @return {obj} values are mostly bools, but not always + */ +export const salientPropChanges = (props, nextProps, tree) => { + const dataInFlux = !nextProps.tree.loaded; + const newData = tree === null && nextProps.tree.loaded; + const visibility = !!nextProps.tree.visibilityVersion && props.tree.visibilityVersion !== nextProps.tree.visibilityVersion; + const tipRadii = !!nextProps.tree.tipRadiiVersion && props.tree.tipRadiiVersion !== nextProps.tree.tipRadiiVersion; + const colorBy = !!nextProps.tree.nodeColorsVersion && + (props.tree.nodeColorsVersion !== nextProps.tree.nodeColorsVersion || + nextProps.colorByConfidence !== props.colorByConfidence); + const branchThickness = props.tree.branchThicknessVersion !== nextProps.tree.branchThicknessVersion; + const layout = props.layout !== nextProps.layout; + const distanceMeasure = props.distanceMeasure !== nextProps.distanceMeasure; + const rerenderAllElements = nextProps.quickdraw === false && props.quickdraw === true; + const resetViewToRoot = props.tree.idxOfInViewRootNode !== 0 && nextProps.tree.idxOfInViewRootNode === 0; + /* branch labels & confidence use 0: no change, 1: turn off, 2: turn on */ + const branchLabels = props.showBranchLabels === nextProps.showBranchLabels ? 0 : nextProps.showBranchLabels ? 2 : 1; + const confidence = props.temporalConfidence.on === nextProps.temporalConfidence.on && props.temporalConfidence.display === nextProps.temporalConfidence.display ? 0 : + (props.temporalConfidence.on === false && nextProps.temporalConfidence.on === false) ? 0 : + (nextProps.temporalConfidence.display === false || nextProps.temporalConfidence.on === false) ? 1 : + (nextProps.temporalConfidence.display === true && nextProps.temporalConfidence.on === true) ? 2 : 0; + + /* sometimes we may want smooth transitions */ + let branchTransitionTime = false; /* false = no transition. Use when speed is critical */ + const tipTransitionTime = false; + if (nextProps.colorByConfidence !== props.colorByConfidence) { + branchTransitionTime = mediumTransitionDuration; + } + + return { + dataInFlux, + newData, + visibility, + tipRadii, + colorBy, + layout, + distanceMeasure, + branchThickness, + branchTransitionTime, + tipTransitionTime, + branchLabels, + resetViewToRoot, + confidence, + quickdraw: nextProps.quickdraw, + rerenderAllElements + }; +}; + +/** + * effect (in phyloTree) the necessary style + attr updates + * @param {obj} changes see salientPropChanges above + * @param {obj} nextProps next redux props + * @param {obj} tree phyloTree object + * @return {null} causes side-effects via phyloTree object + */ +export const updateStylesAndAttrs = (that, changes, nextProps, tree) => { + /* the objects storing the changes to make to the tree */ + const tipAttrToUpdate = {}; + const tipStyleToUpdate = {}; + const branchAttrToUpdate = {}; + const branchStyleToUpdate = {}; + + if (changes.visibility) { + tipStyleToUpdate["visibility"] = nextProps.tree.visibility; + } + if (changes.tipRadii) { + tipAttrToUpdate["r"] = nextProps.tree.tipRadii; + } + if (changes.colorBy) { + tipStyleToUpdate["fill"] = nextProps.tree.nodeColors.map((col) => { + return rgb(col).brighter([0.65]).toString(); + }); + const branchStrokes = calcStrokeCols(nextProps.tree, nextProps.colorByConfidence, nextProps.colorBy); + branchStyleToUpdate["stroke"] = branchStrokes; + tipStyleToUpdate["stroke"] = branchStrokes; + } + if (changes.branchThickness) { + // console.log("branch width change detected - update branch stroke-widths") + branchStyleToUpdate["stroke-width"] = nextProps.tree.branchThickness; + } + /* implement style * attr changes */ + if (Object.keys(branchAttrToUpdate).length || Object.keys(branchStyleToUpdate).length) { + // console.log("applying branch attr", Object.keys(branchAttrToUpdate), "branch style changes", Object.keys(branchStyleToUpdate)) + tree.updateMultipleArray(".branch", branchAttrToUpdate, branchStyleToUpdate, changes.branchTransitionTime, changes.quickdraw); + } + if (Object.keys(tipAttrToUpdate).length || Object.keys(tipStyleToUpdate).length) { + // console.log("applying tip attr", Object.keys(tipAttrToUpdate), "tip style changes", Object.keys(tipStyleToUpdate)) + tree.updateMultipleArray(".tip", tipAttrToUpdate, tipStyleToUpdate, changes.tipTransitionTime, changes.quickdraw); + } + + if (changes.layout) { /* swap layouts */ + tree.updateLayout(nextProps.layout, mediumTransitionDuration); + } + if (changes.distanceMeasure) { /* change distance metrics */ + tree.updateDistance(nextProps.distanceMeasure, mediumTransitionDuration); + } + if (changes.branchLabels === 2) { + tree.showBranchLabels(); + } else if (changes.branchLabels === 1) { + tree.hideBranchLabels(); + } + if (changes.confidence === 1) { + tree.removeConfidence(mediumTransitionDuration); + } else if (changes.confidence === 2) { + if (changes.layout) { /* setTimeout else they come back in before the branches have transitioned */ + setTimeout(() => tree.drawConfidence(mediumTransitionDuration), mediumTransitionDuration * 1.5); + } else { + tree.drawConfidence(mediumTransitionDuration); + } + } else if (nextProps.temporalConfidence.on && (changes.branchThickness || changes.colorBy)) { + /* some updates may necessitate an updating of the CIs (e.g. ∆ branch thicknesses) */ + tree.updateConfidence(changes.tipTransitionTime); + } + if (changes.resetViewToRoot) { + viewEntireTree.bind(that)(); + } + if (changes.rerenderAllElements) { + tree.rerenderAllElements(); + } +}; diff --git a/src/components/tree/treeHelpers.js b/src/components/tree/treeHelpers.js index 040534fd8..81f068f28 100644 --- a/src/components/tree/treeHelpers.js +++ b/src/components/tree/treeHelpers.js @@ -1,5 +1,5 @@ -/* eslint-disable */ - +import { rgb } from "d3-color"; +import { interpolateRgb } from "d3-interpolate"; import { scalePow } from "d3-scale"; import { tipRadius, freqScale, tipRadiusOnLegendMatch } from "../../util/globals"; @@ -70,69 +70,11 @@ export const appendParentsToTree = (root) => { }; -export const gatherTips = (node, tips) => { - - if (typeof node.children !== "undefined") { - for (let i = 0, c = node.children.length; i < c; i++) { - gatherTips(node.children[i], tips); - } - } else { - tips.push(node); - } - return tips; -}; - -export const getVaccines = (tips) => { - const v = []; - tips.forEach((tip) => { - if (vaccineStrains.indexOf(tip.strain) !== -1) { - tip.choice = vaccineChoice[tip.strain]; - v.push(tip); - } - }); - return v; -}; - -export const calcDates = (nodes) => { - nodes.forEach((d) => { - d.dateval = new Date(d.date); - }); -}; - -export const minimumAttribute = (node, attr, min) => { - if (typeof node.children !== "undefined") { - for (let i = 0, c = node.children.length; i < c; i++) { - min = minimumAttribute(node.children[i], attr, min); - } - } else if (node[attr] < min) { - min = node[attr]; - } - return min; -}; - -export const maximumAttribute = (node, attr, max) => { - if (typeof node.children !== "undefined") { - for (let i = 0, c = node.children.length; i < c; i++) { - max = maximumAttribute(node.children[i], attr, max); - } - } else if (node[attr] > max) { - max = node[attr]; - } - return max; -}; - -export const calcBranchLength = (node) => { - if (typeof node.children !== "undefined") { - for (let i = 0, c = node.children.length; i < c; i++) { - calcBranchLength(node.children[i]); - node.children[i].branch_length = node.children[i].xvalue - node.xvalue; - } - } -}; - /** - * for each node, calculate the number of subtending tips (alive or dead) -**/ +* for each node, calculate the number of subtending tips (alive or dead) +* side effects: n.fullTipCount for each node +* @param root - deserialized JSON root to begin traversal +*/ export const calcFullTipCounts = (node) => { node.fullTipCount = 0; if (typeof node.children !== "undefined") { @@ -145,10 +87,11 @@ export const calcFullTipCounts = (node) => { } }; - /** - * for each node, calculate the number of tips in view. -**/ +* for each node, calculate the number of subtending tips which are visible +* side effects: n.tipCount for each node +* @param root - deserialized JSON root to begin traversal +*/ export const calcTipCounts = (node, visibility) => { node.tipCount = 0; if (typeof node.children !== "undefined") { @@ -162,41 +105,15 @@ export const calcTipCounts = (node, visibility) => { }; /** -sets each node in the tree to alive=true if it has at least one descendent with current=true -**/ -export const setNodeAlive = (node) => { - if (typeof node.children !== "undefined") { - let aliveChildren = false; - for (let i = 0, c = node.children.length; i < c; i++) { - setNodeAlive(node.children[i]); - aliveChildren = aliveChildren || node.children[i].alive; - } - node.alive = aliveChildren; - } else { - node.alive = node.current; - } -}; - - -export const adjust_freq_by_date = (nodes, rootNode) => { - // console.log("all nodes and root node", nodes, rootNode) - return nodes.map((d) => { - // console.log("tipcount & rootnodeTipcount", d.tipCount, rootNode.tipCount) - // d.frequency = (d.tipCount) / rootNode.tipCount; - }); -}; - -// export const arrayInEquality = function(a,b) { -// if (a&&b){ -// const eq = a.map((d,i)=>d!==b[i]); -// return eq.some((d)=>d); -// }else{ -// return true; -// } -// }; - -// branch thickness is from clade frequencies -export const calcBranchThickness = function (nodes, visibility, rootIdx) { +* calculates (and returns) an array of node (branch) thicknesses. +* If the node isn't visible, the thickness is 1. +* No side effects. +* @param nodes - JSON nodes +* @param visibility - visibility array (1-1 with nodes) +* @param rootIdx - nodes index of the currently in-view root +* @returns array of thicknesses (numeric) +*/ +export const calcBranchThickness = (nodes, visibility, rootIdx) => { let maxTipCount = nodes[rootIdx].tipCount; /* edge case: no tips selected */ if (!maxTipCount) { @@ -207,6 +124,8 @@ export const calcBranchThickness = function (nodes, visibility, rootIdx) { )); }; +/* a getter for the value of the colour attribute of the node provided for the currently set colour +note this is not the colour HEX */ export const getTipColorAttribute = (node, colorScale) => { if (colorScale.colorBy.slice(0, 3) === "gt-" && colorScale.genotype) { return node.currentGt; @@ -214,6 +133,8 @@ export const getTipColorAttribute = (node, colorScale) => { return node.attr[colorScale.colorBy]; }; +/* generates and returns an array of colours (HEXs) for the nodes under the given colorScale */ +/* takes around 2ms on a 2000 tip tree */ export const calcNodeColor = (tree, colorScale) => { if (tree && tree.nodes && colorScale && colorScale.colorBy) { const nodeColorAttr = tree.nodes.map((n) => getTipColorAttribute(n, colorScale)); @@ -223,11 +144,18 @@ export const calcNodeColor = (tree, colorScale) => { return null; }; +/** +* equates a single tip and a legend element +* exact match is required for categorical qunantities such as genotypes, regions +* continuous variables need to fall into the interal (lower_bound[leg], leg] +* @param selectedLegendItem - value of the selected tip attribute (numeric or string) +* @param node - node (tip) in question +* @param legendBoundsMap - if falsey, then exact match required. Else contains bounds for match. +* @param colorScale - used to get the value of the attribute being used for colouring +* @returns bool +*/ const determineLegendMatch = (selectedLegendItem, node, legendBoundsMap, colorScale) => { const nodeAttr = getTipColorAttribute(node, colorScale); - // equates a tip and a legend element - // exact match is required for categorical qunantities such as genotypes, regions - // continuous variables need to fall into the interal (lower_bound[leg], leg] if (legendBoundsMap) { return (nodeAttr <= legendBoundsMap.upper_bound[selectedLegendItem]) && (nodeAttr > legendBoundsMap.lower_bound[selectedLegendItem]); @@ -235,27 +163,27 @@ const determineLegendMatch = (selectedLegendItem, node, legendBoundsMap, colorSc return nodeAttr === selectedLegendItem; }; +/** +* produces the array of tip radii - if nothing's selected this is the hardcoded tipRadius +* if there's a selectedLegendItem, then values will be small (like normal) or big (for those tips selected) +* @param selectedLegendItem - value of the selected tip attribute (numeric or string) +* @param colorScale - node (tip) in question +* @param tree +* @returns null (if data not ready) or array of tip radii +*/ export const calcTipRadii = (selectedLegendItem, colorScale, tree) => { if (selectedLegendItem && tree && tree.nodes) { const legendMap = colorScale.continuous ? colorScale.legendBoundsMap : false; return tree.nodes.map((d) => determineLegendMatch(selectedLegendItem, d, legendMap, colorScale) ? tipRadiusOnLegendMatch : tipRadius); } else if (tree && tree.nodes) { - return tree.nodes.map((d) => tipRadius); + return tree.nodes.map(() => tipRadius); } return null; // fallthrough }; -const parseFilterQuery = function (query) { - const tmp = query.split("-").map((d) => d.split(".")); - return { - "fields": tmp.map((d) => d[0]), - "filters": tmp.map((d) => d[d.length - 1].split(",")) - }; -}; - /* recursively mark the parents of a given node active by setting the node idx to true in the param visArray */ -const makeParentVisible = function (visArray, node) { +const makeParentVisible = (visArray, node) => { if (node.arrayIdx === 0 || visArray[node.parent.arrayIdx]) { return; // this is the root of the tree or the parent was already visibile } @@ -300,22 +228,21 @@ FILTERS: - filterPairs is a list of lists. Each list defines the filtering to do. i.e. [ [ region, [...values]], [authors, [...values]]] */ -export const calcVisibility = function (tree, controls, dates) { +export const calcVisibility = (tree, controls, dates) => { if (tree.nodes) { /* reset visibility */ - let visibility = tree.nodes.map((d) => { - return true; - }); + let visibility = tree.nodes.map(() => true); // if we have an analysis slider active, then we must filter on that as well // note that min date for analyis doesnt apply - if (controls.analysisSlider && controls.analysisSlider.valid) { - /* extra slider is numerical rounded to 2dp */ - const valid = tree.nodes.map((d) => - d.attr[controls.analysisSlider.key] ? Math.round(d.attr[controls.analysisSlider.key] * 100) / 100 <= controls.analysisSlider.value : true - ); - visibility = visibility.map((cv, idx) => (cv && valid[idx])); - } + // commented out as analysis slider will probably be removed soon! + // if (controls.analysisSlider && controls.analysisSlider.valid) { + // /* extra slider is numerical rounded to 2dp */ + // const valid = tree.nodes.map((d) => + // d.attr[controls.analysisSlider.key] ? Math.round(d.attr[controls.analysisSlider.key] * 100) / 100 <= controls.analysisSlider.value : true + // ); + // visibility = visibility.map((cv, idx) => (cv && valid[idx])); + // } // IN VIEW FILTERING (internal + terminal nodes) /* edge case: this fn may be called before the shell structure of the nodes @@ -324,7 +251,7 @@ export const calcVisibility = function (tree, controls, dates) { let inView; try { inView = tree.nodes.map((d) => d.shell.inView); - } catch(e) { + } catch (e) { inView = tree.nodes.map(() => true); } /* intersect visibility and inView */ @@ -332,7 +259,7 @@ export const calcVisibility = function (tree, controls, dates) { // FILTERS const filterPairs = []; - Object.keys(controls.filters).map((key) => { + Object.keys(controls.filters).forEach((key) => { if (controls.filters[key].length) { filterPairs.push([key, controls.filters[key]]); } @@ -355,7 +282,7 @@ export const calcVisibility = function (tree, controls, dates) { } // TIME FILTERING (internal + terminal nodes) - const timeFiltered = tree.nodes.map((d, idx) => { + const timeFiltered = tree.nodes.map((d) => { return !(d.attr.num_date < dates.dateMinNumeric || d.parent.attr.num_date > dates.dateMaxNumeric); }); visibility = visibility.map((cv, idx) => (cv && timeFiltered[idx])); @@ -366,9 +293,9 @@ export const calcVisibility = function (tree, controls, dates) { return "visible"; }; -export const branchInterpolateColour = "#BBB"; -export const branchOpacityConstant = 0.4; -export const branchOpacityFunction = scalePow() +const branchInterpolateColour = "#BBB"; +const branchOpacityConstant = 0.4; +const branchOpacityFunction = scalePow() .exponent([0.3]) .domain([0, 1]) .range([branchOpacityConstant, 1]) @@ -377,6 +304,27 @@ export const branchOpacityFunction = scalePow() // export const calcEntropyOfValues = (vals) => // vals.map((v) => v * Math.log(v + 1E-10)).reduce((a, b) => a + b, 0) * -1 / Math.log(vals.length); + +/** + * calculate array of HEXs to actually be displayed. + * (colorBy) confidences manifest as opacity ramps + * @param {obj} tree phyloTree object + * @param {bool} confidence enabled? + * @return {array} array of hex's. 1-1 with nodes. + */ +export const calcStrokeCols = (tree, confidence, colorBy) => { + if (confidence === true) { + return tree.nodeColors.map((col, idx) => { + const entropy = tree.nodes[idx].attr[colorBy + "_entropy"]; + return rgb(interpolateRgb(col, branchInterpolateColour)(branchOpacityFunction(entropy))).toString(); + }); + } + return tree.nodeColors.map((col) => { + return rgb(interpolateRgb(col, branchInterpolateColour)(branchOpacityConstant)).toString(); + }); +}; + + /** * if the metadata JSON defines vaccine strains then create an array of the nodes * @param nodes - nodes @@ -388,6 +336,25 @@ export const processVaccines = (nodes, vaccineChoices) => { if (!vaccineChoices) {return false;} const names = Object.keys(vaccineChoices); const vaccines = nodes.filter((d) => names.indexOf(d.strain) !== -1); - vaccines.forEach((d) => d.vaccineDate = vaccineChoices[d.strain]); + vaccines.forEach((d) => {d.vaccineDate = vaccineChoices[d.strain];}); return vaccines; -} +}; + +/** + * Adds certain properties to the nodes array - for each node in nodes it adds + * node.fullTipCount - see calcFullTipCounts() description + * node.hasChildren {bool} + * node.arrayIdx {integer} - the index of the node in the nodes array + * @param {array} nodes redux tree nodes + * @return {array} input array (kinda unneccessary) + * side-effects: node.hasChildren (bool) and node.arrayIdx (INT) for each node in nodes + */ +export const processNodes = (nodes) => { + const rootNode = nodes[0]; + nodes.forEach((d) => {if (typeof d.attr === "undefined") {d.attr = {};} }); + calcFullTipCounts(rootNode); + nodes.forEach((d) => {d.hasChildren = typeof d.children !== "undefined";}); + /* set an index so that we can access visibility / nodeColors if needed */ + nodes.forEach((d, idx) => {d.arrayIdx = idx;}); + return nodes; +}; diff --git a/src/components/tree/treeViewFunctions.js b/src/components/tree/treeViewFunctions.js deleted file mode 100644 index d51945b62..000000000 --- a/src/components/tree/treeViewFunctions.js +++ /dev/null @@ -1,378 +0,0 @@ -import { rgb } from "d3-color"; -import { interpolateRgb } from "d3-interpolate"; -import { updateVisibleTipsAndBranchThicknesses} from "../../actions/treeProperties"; -import { branchOpacityConstant, - branchOpacityFunction, - branchInterpolateColour } from "./treeHelpers"; -import { mediumTransitionDuration } from "../../util/globals"; - -export const visibleArea = function (Viewer) { - const V = Viewer.getValue(); - return { - left: -V.e / V.a, - top: -V.f / V.d, - right: (V.viewerWidth - V.e) / V.a, - bottom: (V.viewerHeight - V.f) / V.d - }; -}; - -export const resetGrid = function () { - const layout = this.props.layout; - if (this.props.layout !== "unrooted") { - const tree = this.state.tree; - // const visibleArea = .visibleArea; - const viewer = this.Viewer; - const delayedRedraw = function () { - return function () { - const view = visibleArea(viewer); - tree.addGrid(layout, view.bottom, view.top); - }; - }; - window.setTimeout(delayedRedraw(), 200); - } -}; - - -export const onViewerChange = function () { - if (this.Viewer && this.state.tree) { - const V = this.Viewer.getValue(); - if (V.mode === "panning") { - resetGrid.bind(this)(); - }else if (V.mode === "idle") { - resetGrid.bind(this); - } - } -}; - -export const resetView = function () { - this.Viewer.fitToViewer(); -}; - -/* Callbacks used by the tips / branches when hovered / selected */ -export const onTipHover = function (d, x, y) { - this.state.tree.svg.select("#tip_" + d.n.clade) - .attr("r", (e) => e["r"] + 4); - this.setState({ - hovered: {d, type: ".tip", x, y} - }); -}; - -export const onTipClick = function (d) { - // console.log("tip click", d) - this.setState({ - hovered: null, - selectedTip: d - }); - this.props.dispatch(updateVisibleTipsAndBranchThicknesses({tipSelectedIdx: d.n.arrayIdx})); -}; - -export const onBranchHover = function (d, x, y) { - /* emphasize the color of the branch */ - for (const id of ["#branch_S_" + d.n.clade, "#branch_T_" + d.n.clade]) { - if (this.props.colorByConfidence) { - this.state.tree.svg.select(id) - .style("stroke", (el) => { - const ramp = branchOpacityFunction(this.props.tree.nodes[el.n.arrayIdx].attr[this.props.colorBy + "_entropy"]); - const raw = this.props.tree.nodeColors[el.n.arrayIdx]; - const base = el["stroke"]; - return rgb(interpolateRgb(raw, base)(ramp)).toString(); - }); - } else { - this.state.tree.svg.select(id) - .style("stroke", (el) => this.props.tree.nodeColors[el.n.arrayIdx]); - } - } - if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) { - this.state.tree.svg.append("g").selectAll(".conf") - .data([d]) - .enter() - .call((sel) => this.state.tree.drawSingleCI(sel, 0.5)); - } - this.setState({ - hovered: {d, type: ".branch", x, y} - }); -}; - -export const onBranchClick = function (d) { - this.Viewer.fitToViewer(); - this.state.tree.zoomIntoClade(d, mediumTransitionDuration); - /* to stop multiple phyloTree updates potentially clashing, - we change tipVis after geometry update + transition */ - window.setTimeout(() => - this.props.dispatch(updateVisibleTipsAndBranchThicknesses({idxOfInViewRootNode: d.n.arrayIdx})), - mediumTransitionDuration - ); - this.setState({ - hovered: null, - selectedBranch: d - }); -}; - -/* onBranchLeave called when mouse-off, i.e. anti-hover */ -export const onBranchLeave = function (d) { - for (const id of ["#branch_T_" + d.n.clade, "#branch_S_" + d.n.clade]) { - this.state.tree.svg.select(id) - .style("stroke", (el) => el["stroke"]); - } - if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) { - this.state.tree.removeConfidence(mediumTransitionDuration); - } - if (this.state.hovered) { - this.setState({hovered: null}); - } -}; - -export const onTipLeave = function (d) { - if (!this.state.selectedTip) { - this.state.tree.svg.select("#tip_" + d.n.clade) - .attr("r", (dd) => dd["r"]); - } - if (this.state.hovered) { - this.setState({hovered: null}); - } -}; - -/* viewEntireTree: go back to the root! */ -const viewEntireTree = function () { - /* reset the SVGPanZoom */ - this.Viewer.fitToViewer(); - /* imperitively manipulate SVG tree elements */ - this.state.tree.zoomIntoClade(this.state.tree.nodes[0], mediumTransitionDuration); - /* update branch thicknesses / tip vis after SVG tree elemtents have moved */ - window.setTimeout( - () => this.props.dispatch(updateVisibleTipsAndBranchThicknesses({idxOfInViewRootNode: 0})), - mediumTransitionDuration - ); - this.setState({selectedBranch: null, selectedTip: null}); -}; - -/* clearSelectedTip when clicking to go away */ -export const clearSelectedTip = function (d) { - this.state.tree.svg.select("#tip_" + d.n.clade) - .attr("r", (dd) => dd["r"]); - this.setState({selectedTip: null, hovered: null}); - /* restore the tip visibility! */ - this.props.dispatch(updateVisibleTipsAndBranchThicknesses()); -}; - -export const handleIconClick = function (tool) { - return () => { - const V = this.Viewer.getValue(); - if (tool === "zoom-in") { - this.Viewer.zoomOnViewerCenter(1.4); - } else if (V.a > 1.0) { // if there is room to zoom out via the SVGPanZoom, do - this.Viewer.zoomOnViewerCenter(0.71); - } else { // otherwise reset view to have SVG fit the viewer - resetView.bind(this)(); - // if we have clade zoom, zoom out to the parent clade - if (this.state.selectedBranch && this.state.selectedBranch.n.arrayIdx) { - const dp = this.props.dispatch; - const arrayIdx = this.state.tree.zoomNode.parent.n.arrayIdx; - // reset the "clicked" branch, unset if we zoomed out all the way to the root - this.setState({ - hovered: null, - selectedBranch: (arrayIdx) ? this.state.tree.zoomNode.parent : null - }); - const makeCallBack = function () { - return function () { - dp(updateVisibleTipsAndBranchThicknesses()); - }; - }; - // clear previous timeout bc they potentially mess with the geometry update - if (this.timeout) { - clearTimeout(this.timeout); - } - // call phyloTree to zoom out, this rerenders the geometry - this.state.tree.zoomToParent(mediumTransitionDuration); - // wait and reset visibility - this.timeout = setTimeout(makeCallBack(), mediumTransitionDuration); - } - } - resetGrid.bind(this)(); - }; -}; - - -/* functions to do with tip / branch labels */ - -/** - * @param {node} d tree node object - * @return {string} displayed as label on the branch corresponding to the node - */ -export const branchLabel = function (d) { - if (d.n.muts) { - if (d.n.muts.length > 5) { - return d.n.muts.slice(0, 5).join(", ") + "..."; - } - return d.n.muts.join(", "); - } - return ""; -}; - -/** - * @param {node} d tree node object - * @param {int} n total number of nodes in current view - * @return {int} font size of the branch label - */ -export const branchLabelSize = (d, n) => - d.leafCount > n / 10.0 ? 12 : 0; - -/** - * @param {node} d tree node object - * @param {int} n total number of nodes in current view - * @return {int} font size of the tip label - */ -export const tipLabelSize = function (d, n) { - if (n > 70) { - return 0; - } else if (n < 20) { - return 14; - } - const fs = 6 + 8 * (70 - n) / (70 - 20); - return fs; -}; - -/** - * calculate array of HEXs to actually be displayed. - * (colorBy) confidences manifest as opacity ramps - * @param {obj} tree phyloTree object - * @param {bool} confidence enabled? - * @return {array} array of hex's. 1-1 with nodes. - */ -const calcStrokeCols = (tree, confidence, colorBy) => { - if (confidence === true) { - return tree.nodeColors.map((col, idx) => { - const entropy = tree.nodes[idx].attr[colorBy + "_entropy"]; - return rgb(interpolateRgb(col, branchInterpolateColour)(branchOpacityFunction(entropy))).toString(); - }); - } - return tree.nodeColors.map((col) => { - return rgb(interpolateRgb(col, branchInterpolateColour)(branchOpacityConstant)).toString(); - }); -}; - -/** - * function to help determine what parts of phylotree should update - * @param {obj} props redux props - * @param {obj} nextProps next redux props - * @param {obj} tree phyloTree object (stored in the state of treeView) - * @return {obj} values are mostly bools, but not always - */ -export const salientPropChanges = (props, nextProps, tree) => { - const dataInFlux = !nextProps.tree.loaded; - const newData = tree === null && nextProps.tree.loaded; - const visibility = !!nextProps.tree.visibilityVersion && props.tree.visibilityVersion !== nextProps.tree.visibilityVersion - const tipRadii = !!nextProps.tree.tipRadiiVersion && props.tree.tipRadiiVersion !== nextProps.tree.tipRadiiVersion; - const colorBy = !!nextProps.tree.nodeColorsVersion && - (props.tree.nodeColorsVersion !== nextProps.tree.nodeColorsVersion || - nextProps.colorByConfidence !== props.colorByConfidence); - const branchThickness = props.tree.branchThicknessVersion !== nextProps.tree.branchThicknessVersion; - const layout = props.layout !== nextProps.layout; - const distanceMeasure = props.distanceMeasure !== nextProps.distanceMeasure; - const rerenderAllElements = nextProps.quickdraw === false && props.quickdraw === true; - const resetViewToRoot = props.tree.idxOfInViewRootNode !== 0 && nextProps.tree.idxOfInViewRootNode === 0; - /* branch labels & confidence use 0: no change, 1: turn off, 2: turn on */ - const branchLabels = props.showBranchLabels === nextProps.showBranchLabels ? 0 : nextProps.showBranchLabels ? 2 : 1; - const confidence = props.temporalConfidence.on === nextProps.temporalConfidence.on && props.temporalConfidence.display === nextProps.temporalConfidence.display ? 0 : - (props.temporalConfidence.on === false && nextProps.temporalConfidence.on === false) ? 0 : - (nextProps.temporalConfidence.display === false || nextProps.temporalConfidence.on === false) ? 1 : - (nextProps.temporalConfidence.display === true && nextProps.temporalConfidence.on === true) ? 2 : 0; - - /* sometimes we may want smooth transitions */ - let branchTransitionTime = false; /* false = no transition. Use when speed is critical */ - let tipTransitionTime = false; - if (nextProps.colorByConfidence !== props.colorByConfidence) { - branchTransitionTime = mediumTransitionDuration; - } - - return { - dataInFlux, - newData, - visibility, - tipRadii, - colorBy, - layout, - distanceMeasure, - branchThickness, - branchTransitionTime, - tipTransitionTime, - branchLabels, - resetViewToRoot, - confidence, - quickdraw: nextProps.quickdraw, - rerenderAllElements - }; -}; - -/** - * effect (in phyloTree) the necessary style + attr updates - * @param {obj} changes see salientPropChanges above - * @param {obj} nextProps next redux props - * @param {obj} tree phyloTree object - * @return {null} causes side-effects via phyloTree object - */ -export const updateStylesAndAttrs = (that, changes, nextProps, tree) => { - /* the objects storing the changes to make to the tree */ - const tipAttrToUpdate = {}; - const tipStyleToUpdate = {}; - const branchAttrToUpdate = {}; - const branchStyleToUpdate = {}; - - if (changes.visibility) { - tipStyleToUpdate["visibility"] = nextProps.tree.visibility; - } - if (changes.tipRadii) { - tipAttrToUpdate["r"] = nextProps.tree.tipRadii; - } - if (changes.colorBy) { - tipStyleToUpdate["fill"] = nextProps.tree.nodeColors.map((col) => { - return rgb(col).brighter([0.65]).toString(); - }); - const branchStrokes = calcStrokeCols(nextProps.tree, nextProps.colorByConfidence, nextProps.colorBy); - branchStyleToUpdate["stroke"] = branchStrokes; - tipStyleToUpdate["stroke"] = branchStrokes; - } - if (changes.branchThickness) { - // console.log("branch width change detected - update branch stroke-widths") - branchStyleToUpdate["stroke-width"] = nextProps.tree.branchThickness; - } - /* implement style * attr changes */ - if (Object.keys(branchAttrToUpdate).length || Object.keys(branchStyleToUpdate).length) { - // console.log("applying branch attr", Object.keys(branchAttrToUpdate), "branch style changes", Object.keys(branchStyleToUpdate)) - tree.updateMultipleArray(".branch", branchAttrToUpdate, branchStyleToUpdate, changes.branchTransitionTime, changes.quickdraw); - } - if (Object.keys(tipAttrToUpdate).length || Object.keys(tipStyleToUpdate).length) { - // console.log("applying tip attr", Object.keys(tipAttrToUpdate), "tip style changes", Object.keys(tipStyleToUpdate)) - tree.updateMultipleArray(".tip", tipAttrToUpdate, tipStyleToUpdate, changes.tipTransitionTime, changes.quickdraw); - } - - if (changes.layout) { /* swap layouts */ - tree.updateLayout(nextProps.layout, mediumTransitionDuration); - } - if (changes.distanceMeasure) { /* change distance metrics */ - tree.updateDistance(nextProps.distanceMeasure, mediumTransitionDuration); - } - if (changes.branchLabels === 2) { - tree.showBranchLabels(); - } else if (changes.branchLabels === 1) { - tree.hideBranchLabels(); - } - if (changes.confidence === 1) { - tree.removeConfidence(mediumTransitionDuration); - } else if (changes.confidence === 2) { - if (changes.layout) { /* setTimeout else they come back in before the branches have transitioned */ - setTimeout(() => tree.drawConfidence(mediumTransitionDuration), mediumTransitionDuration * 1.5); - } else { - tree.drawConfidence(mediumTransitionDuration); - } - } else if (nextProps.temporalConfidence.on && (changes.branchThickness || changes.colorBy)) { - /* some updates may necessitate an updating of the CIs (e.g. ∆ branch thicknesses) */ - tree.updateConfidence(changes.tipTransitionTime); - } - if (changes.resetViewToRoot) { - viewEntireTree.bind(that)(); - } - if (changes.rerenderAllElements) { - tree.rerenderAllElements(); - } -}; diff --git a/src/reducers/tree.js b/src/reducers/tree.js index 0a1a62a91..ca9241f8f 100644 --- a/src/reducers/tree.js +++ b/src/reducers/tree.js @@ -1,5 +1,4 @@ -import { flattenTree, appendParentsToTree, processVaccines } from "../components/tree/treeHelpers"; -import { processNodes, calcLayouts } from "../components/tree/processNodes"; +import { flattenTree, appendParentsToTree, processVaccines, processNodes } from "../components/tree/treeHelpers"; import { getValuesAndCountsOfVisibleTraitsFromTree, getAllValuesAndCountsOfTraitsFromTree } from "../util/treeTraversals"; import * as types from "../actions/types"; @@ -45,7 +44,6 @@ const Tree = (state = getDefaultState(), action) => { const nodesArray = flattenTree(action.tree); const nodes = processNodes(nodesArray); const vaccines = processVaccines(nodes, action.meta.vaccine_choices); - calcLayouts(nodes, ["div", "num_date"]); return Object.assign({}, getDefaultState(), { nodes, vaccines,