diff --git a/.eslintrc b/.eslintrc index ca97234ac..1b315ad9a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -46,6 +46,7 @@ rules: no-unneeded-ternary: ["error", { "defaultAssignment": true }] quote-props: ["error", "as-needed"] prefer-const: ["error", {"destructuring": "all"}] + indent: ["error", 2, {"MemberExpression": "off"}] parserOptions: ecmaVersion: 6 sourceType: module diff --git a/src/components/tree/phyloTree/change.js b/src/components/tree/phyloTree/change.js index 5e17998b4..80ccc65c6 100644 --- a/src/components/tree/phyloTree/change.js +++ b/src/components/tree/phyloTree/change.js @@ -40,9 +40,6 @@ const svgSetters = { ".vaccineCross": { d: (d) => d.vaccineCross }, - ".vaccineDottedLine": { - d: (d) => d.vaccineLine - }, ".conf": { d: (d) => d.confLine } @@ -53,9 +50,6 @@ const svgSetters = { "stroke": (d) => d.tipStroke, "visibility": (d) => d["visibility"] }, - ".vaccineDottedLine": { - opacity: (d) => d.that.distance === "num_date" ? 1 : 0 - }, ".conf": { "stroke": (d) => d.branchStroke, "stroke-width": calcConfidenceWidth @@ -159,7 +153,7 @@ export const modifySVG = function modifySVG(elemsToUpdate, svgPropsToUpdate, tra else this.hideGrid(); } if (elemsToUpdate.has('.regression')) { - this.svg.selectAll(".regression").remove(); + this.removeRegression(); if (this.layout === "clock" && this.distance === "num_date") this.drawRegression(); } @@ -179,7 +173,7 @@ export const modifySVG = function modifySVG(elemsToUpdate, svgPropsToUpdate, tra /* branch labels */ if (extras.newBranchLabellingKey) { - this.svg.selectAll('.branchLabel').remove(); + this.removeBranchLabels(); if (extras.newBranchLabellingKey !== "none") { this.drawBranchLabels(extras.newBranchLabellingKey); } diff --git a/src/components/tree/phyloTree/confidence.js b/src/components/tree/phyloTree/confidence.js index bb6bfa909..d82730f63 100644 --- a/src/components/tree/phyloTree/confidence.js +++ b/src/components/tree/phyloTree/confidence.js @@ -1,31 +1,39 @@ export const removeConfidence = function removeConfidence(dt) { this.confidencesInSVG = false; + if (!("confidenceIntervals" in this.groups)) return; + if (dt) { - this.svg.selectAll(".conf") + this.groups.confidenceIntervals + .selectAll("*") .transition().duration(dt) - .style("opacity", 0) - .remove(); + .style("opacity", 0) + .remove(); } else { - this.svg.selectAll(".conf").remove(); + this.groups.confidenceIntervals.selectAll("*").remove(); } }; export const drawConfidence = function drawConfidence(dt) { this.confidencesInSVG = true; + if (!("confidenceIntervals" in this.groups)) { + this.groups.confidenceIntervals = this.svg.append("g").attr("id", "confidenceIntervals"); + } if (dt) { - this.confidence = this.svg.append("g").selectAll(".conf") + this.groups.confidenceIntervals + .selectAll(".conf") .data(this.nodes) .enter() .call((sel) => this.drawSingleCI(sel, 0)); - this.svg.selectAll(".conf") + this.groups.confidenceIntervals .transition().duration(dt) - .style("opacity", 0.5); + .style("opacity", 0.5); } else { - this.confidence = this.svg.append("g").selectAll(".conf") + this.groups.confidenceIntervals + .selectAll(".conf") .data(this.nodes) .enter() - .call((sel) => this.drawSingleCI(sel, 0.5)); + .call((sel) => this.drawSingleCI(sel, 0.5)); } }; diff --git a/src/components/tree/phyloTree/grid.js b/src/components/tree/phyloTree/grid.js index 139d2da0d..d2e0f633b 100644 --- a/src/components/tree/phyloTree/grid.js +++ b/src/components/tree/phyloTree/grid.js @@ -3,9 +3,15 @@ import { min, max } from "d3-array"; import { timerStart, timerEnd } from "../../../util/perf"; 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'); + if ("majorGrid" in this.groups) { + this.groups.majorGrid.selectAll("*").style('visibility', 'hidden'); + } + if ("minorGrid" in this.groups) { + this.groups.minorGrid.selectAll("*").style('visibility', 'hidden'); + } + if ("gridText" in this.groups) { + this.groups.gridText.selectAll("*").style('visibility', 'hidden'); + } }; const calculateMajorGridSeperation = (range) => { @@ -144,46 +150,63 @@ export const addGrid = function addGrid(layout) { /* D3 commands to add grid + text to the DOM */ // add major grid to svg - const majorGrid = this.svg.selectAll('.majorGrid').data(majorGridPoints); - 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); + if (!("majorGrid" in this.groups)) { + this.groups.majorGrid = this.svg.append("g").attr("id", "majorGrid"); + } + this.groups.majorGrid.selectAll("*").remove(); + this.groups.majorGrid + .selectAll('.majorGrid') + .data(majorGridPoints) + .enter() + .append("path") + .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); // add minor grid to SVG - 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); + if (!("minorGrid" in this.groups)) { + this.groups.minorGrid = this.svg.append("g").attr("id", "minorGrid"); + } + this.groups.minorGrid.selectAll("*").remove(); + this.svg.selectAll(".minorGrid").remove(); + this.groups.minorGrid + .selectAll('.minorGrid') + .data(minorGridPoints) + .enter() + .append("path") + .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); /* draw the text labels for majorGridPoints */ - const gridLabels = this.svg.selectAll('.gridTick').data(majorGridPoints); const precisionX = Math.max(0, -Math.floor(Math.log10(step))); const precisionY = Math.max(0, -Math.floor(Math.log10(yStep))); - gridLabels.exit().remove(); // EXIT - gridLabels.enter().append("text") // ENTER - .merge(gridLabels) // ENTER + UPDATE - .text((d) => d[0].toFixed(d[2]==='y' ? precisionY : precisionX)) - .attr("class", "gridTick") - .style("font-size", this.params.tickLabelSize) - .style("font-family", this.params.fontFamily) - .style("fill", this.params.tickLabelFill) - .style("text-anchor", textAnchor(layout)) - .style("visibility", (d) => d[1]) - .attr("x", xTextPos(this.xScale, layout)) - .attr("y", yTextPos(this.yScale, layout)); + if (!("gridText" in this.groups)) { + this.groups.gridText = this.svg.append("g").attr("id", "gridText"); + } + this.groups.gridText.selectAll("*").remove(); + this.svg.selectAll(".gridText").remove(); + this.groups.gridText + .selectAll('.gridText') + .data(majorGridPoints) + .enter() + .append("text") + .text((d) => d[0].toFixed(d[2]==='y' ? precisionY : precisionX)) + .attr("class", "gridText") + .style("font-size", this.params.tickLabelSize) + .style("font-family", this.params.fontFamily) + .style("fill", this.params.tickLabelFill) + .style("text-anchor", textAnchor(layout)) + .style("visibility", (d) => d[1]) + .attr("x", xTextPos(this.xScale, layout)) + .attr("y", yTextPos(this.yScale, layout)); this.grid=true; timerEnd("addGrid"); diff --git a/src/components/tree/phyloTree/labels.js b/src/components/tree/phyloTree/labels.js index 98a3b32d9..32ff196a0 100644 --- a/src/components/tree/phyloTree/labels.js +++ b/src/components/tree/phyloTree/labels.js @@ -1,7 +1,12 @@ import { timerFlush } from "d3-timer"; export const updateTipLabels = function updateTipLabels(dt) { - this.svg.selectAll('.tipLabel').remove(); + if ("tipLabels" in this.groups) { + this.groups.tipLabels.selectAll("*").remove(); + } else { + this.groups.tipLabels = this.svg.append("g").attr("id", "tipLabels"); + } + const tLFunc = this.callbacks.tipLabel; const xPad = this.params.tipLabelPadX; const yPad = this.params.tipLabelPadY; @@ -20,7 +25,8 @@ export const updateTipLabels = function updateTipLabels(dt) { } window.setTimeout(() => { - this.tipLabels = this.svg.append("g").selectAll('.tipLabel') + this.groups.tipLabels + .selectAll('.tipLabel') .data(inViewTerminalNodes) .enter() .append("text") @@ -79,7 +85,8 @@ export const updateBranchLabels = function updateBranchLabels(dt) { const visibility = createBranchLabelVisibility(this.params.branchLabelKey, this.layout, this.zoomNode.n.tipCount); const labelSize = branchLabelSize(this.params.branchLabelKey); const fontWeight = branchLabelFontWeight(this.params.branchLabelKey); - this.svg.selectAll('.branchLabel') + this.groups.branchLabels + .selectAll('.branchLabel') .transition().duration(dt) .attr("x", (d) => d.xTip - 5) .attr("y", (d) => d.yTip - this.params.branchLabelPadY) @@ -89,18 +96,29 @@ export const updateBranchLabels = function updateBranchLabels(dt) { if (!dt) timerFlush(); }; +export const removeBranchLabels = function removeBranchLabels() { + if ("branchLabels" in this.groups) { + this.groups.branchLabels.selectAll("*").remove(); + } +}; + export const drawBranchLabels = function drawBranchLabels(key) { /* salient props: this.zoomNode.n.tipCount, this.zoomNode.n.fullTipCount */ this.params.branchLabelKey = key; const labelSize = branchLabelSize(key); const fontWeight = branchLabelFontWeight(key); const visibility = createBranchLabelVisibility(key, this.layout, this.zoomNode.n.tipCount); - this.svg.append("g").selectAll('.branchLabel') + + if (!("branchLabels" in this.groups)) { + this.groups.branchLabels = this.svg.append("g").attr("id", "branchLabels"); + } + this.groups.branchLabels + .selectAll('.branchLabel') .data(this.nodes.filter((d) => d.n.attr.labels && d.n.attr.labels[key])) .enter() .append("text") .attr("class", "branchLabel") - .attr("x", (d) => d.xTip + ((this.params.orientation[0]>0)?-5:5) ) + .attr("x", (d) => d.xTip + ((this.params.orientation[0]>0)?-5:5)) .attr("y", (d) => d.yTip - this.params.branchLabelPadY) .style("text-anchor", (this.params.orientation[0]>0)?"end":"start") .style("visibility", visibility) diff --git a/src/components/tree/phyloTree/layouts.js b/src/components/tree/phyloTree/layouts.js index b2a9525a7..9787eb80e 100644 --- a/src/components/tree/phyloTree/layouts.js +++ b/src/components/tree/phyloTree/layouts.js @@ -354,7 +354,6 @@ export const mapToScreen = function mapToScreen() { const xTipCross = this.xScale(d.xCross); /* x position of the center of the cross */ const yTipCross = this.yScale(d.yCross); /* x position of the center of the cross */ d.vaccineCross = ` M ${xTipCross-n},${yTipCross-n} L ${xTipCross+n},${yTipCross+n} M ${xTipCross-n},${yTipCross+n} L ${xTipCross+n},${yTipCross-n}`; - d.vaccineLine = ` M ${d.xTip},${d.yTip} L ${xTipCross},${yTipCross}`; }); } diff --git a/src/components/tree/phyloTree/phyloTree.js b/src/components/tree/phyloTree/phyloTree.js index b618e4c13..dce7b1bc3 100644 --- a/src/components/tree/phyloTree/phyloTree.js +++ b/src/components/tree/phyloTree/phyloTree.js @@ -15,6 +15,9 @@ const PhyloTree = function PhyloTree(reduxNodes, debugId) { this.grid = false; this.attributes = ['r', 'cx', 'cy', 'id', 'class', 'd']; this.params = createDefaultParams(); + this.groups = {}; + /* by storing DOM elements, we can quickly refer to groups here rather than scanning the DOM. + It also helps preserve the initial order of groups in the DOM as we are not creating new ones upon updates */ this.debugId = debugId; /* super useful when one is trying to debug multiple trees! */ /* 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] @@ -61,6 +64,7 @@ PhyloTree.prototype.drawTips = renderers.drawTips; PhyloTree.prototype.drawBranches = renderers.drawBranches; PhyloTree.prototype.drawVaccines = renderers.drawVaccines; PhyloTree.prototype.drawRegression = renderers.drawRegression; +PhyloTree.prototype.removeRegression = renderers.removeRegression; /* 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; @@ -80,6 +84,7 @@ PhyloTree.prototype.updateConfidence = confidence.updateConfidence; /* 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.drawBranchLabels = labels.drawBranchLabels; +PhyloTree.prototype.removeBranchLabels = labels.removeBranchLabels; PhyloTree.prototype.updateBranchLabels = labels.updateBranchLabels; PhyloTree.prototype.updateTipLabels = labels.updateTipLabels; diff --git a/src/components/tree/phyloTree/renderers.js b/src/components/tree/phyloTree/renderers.js index d2e6ca5eb..e2173a316 100644 --- a/src/components/tree/phyloTree/renderers.js +++ b/src/components/tree/phyloTree/renderers.js @@ -58,32 +58,26 @@ export const render = function render(svg, layout, distance, parameters, callbac * @return {null} */ export const drawVaccines = function drawVaccines() { - this.svg.append("g").selectAll(".vaccineCross") - .data(this.vaccines) - .enter() - .append("path") - .attr("class", "vaccineCross") - .attr("d", (d) => d.vaccineCross) - .style("stroke", "#333") - .style("stroke-width", 2 * this.params.branchStrokeWidth) - .style("fill", "none") - .style("cursor", "pointer") - .style("pointer-events", "auto") - .on("mouseover", this.callbacks.onTipHover) - .on("mouseout", this.callbacks.onTipLeave) - .on("click", this.callbacks.onTipClick); + if (!this.vaccines || !this.vaccines.length) return; - this.svg.append("g").selectAll('.vaccineDottedLine') + if (!("vaccines" in this.groups)) { + this.groups.vaccines = this.svg.append("g").attr("id", "vaccines"); + } + this.groups.vaccines + .selectAll(".vaccineCross") .data(this.vaccines) .enter() - .append("path") - .attr("class", "vaccineDottedLine") - .attr("d", (d) => d.vaccineLine) - .style("stroke-dasharray", "5, 5") - .style("stroke", "black") - .style("stroke-width", this.params.branchStrokeWidth) - .style("fill", "none") - .style("pointer-events", "none"); + .append("path") + .attr("class", "vaccineCross") + .attr("d", (d) => d.vaccineCross) + .style("stroke", "#333") + .style("stroke-width", 2 * this.params.branchStrokeWidth) + .style("fill", "none") + .style("cursor", "pointer") + .style("pointer-events", "auto") + .on("mouseover", this.callbacks.onTipHover) + .on("mouseout", this.callbacks.onTipLeave) + .on("click", this.callbacks.onTipClick); }; @@ -94,63 +88,86 @@ export const drawVaccines = function drawVaccines() { export const drawTips = function drawTips() { timerStart("drawTips"); const params = this.params; - this.svg.append("g").selectAll(".tip") + + if (!("tips" in this.groups)) { + this.groups.tips = this.svg.append("g").attr("id", "tips"); + } + this.groups.tips + .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", this.callbacks.onTipHover) - .on("mouseout", this.callbacks.onTipLeave) - .on("click", this.callbacks.onTipClick) - .style("pointer-events", "auto") - .style("visibility", (d) => d["visibility"]) - .style("fill", (d) => d.fill || params.tipFill) - .style("stroke", (d) => d.tipStroke || params.tipStroke) - .style("stroke-width", () => params.tipStrokeWidth) /* don't want branch thicknesses applied */ - .style("cursor", "pointer"); + .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", this.callbacks.onTipHover) + .on("mouseout", this.callbacks.onTipLeave) + .on("click", this.callbacks.onTipClick) + .style("pointer-events", "auto") + .style("visibility", (d) => d["visibility"]) + .style("fill", (d) => d.fill || params.tipFill) + .style("stroke", (d) => d.tipStroke || params.tipStroke) + .style("stroke-width", () => params.tipStrokeWidth) /* don't want branch thicknesses applied */ + .style("cursor", "pointer"); + timerEnd("drawTips"); }; /** - * adds all branches to the svg, these are paths with class branch + * adds all branches to the svg, these are paths with class branch, which comprise two groups * @return {null} */ export const drawBranches = function drawBranches() { timerStart("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.branchStroke || 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') + /* PART 1: draw the branch Ts (i.e. the bit connecting nodes parent branch ends to child branch beginnings) + Only rectangular & radial trees have this, so we remove it for clock / unrooted layouts */ + if (!("branchTee" in this.groups)) { + this.groups.branchTee = this.svg.append("g").attr("id", "branchTee"); + } + if (this.layout === "clock" || this.layout === "unrooted") { + this.groups.branchTee.selectAll("*").remove(); + } else { + this.groups.branchTee + .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.branchStroke || params.branchStroke) + .style("stroke-width", (d) => d['stroke-width'] || params.branchStrokeWidth) + .style("fill", "none") + .style("pointer-events", "auto"); + } + + /* PART 2: draw the branch stems (i.e. the actual branches) */ + if (!("branchStem" in this.groups)) { + this.groups.branchStem = this.svg.append("g").attr("id", "branchStem"); + } + this.groups.branchStem + .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.branchStroke || params.branchStroke) - .style("stroke-linecap", "round") - .style("stroke-width", (d) => d['stroke-width']+"px" || params.branchStrokeWidth) - .style("fill", "none") - .style("cursor", "pointer") - .style("pointer-events", "auto") - .on("mouseover", this.callbacks.onBranchHover) - .on("mouseout", this.callbacks.onBranchLeave) - .on("click", this.callbacks.onBranchClick); + .append("path") + .attr("class", "branch S") + .attr("id", (d) => "branch_S_" + d.n.clade) + .attr("d", (d) => d.branch[0]) + .style("stroke", (d) => d.branchStroke || params.branchStroke) + .style("stroke-linecap", "round") + .style("stroke-width", (d) => d['stroke-width']+"px" || params.branchStrokeWidth) + .style("fill", "none") + .style("cursor", "pointer") + .style("pointer-events", "auto") + .on("mouseover", this.callbacks.onBranchHover) + .on("mouseout", this.callbacks.onBranchLeave) + .on("click", this.callbacks.onBranchClick); + timerEnd("drawBranches"); }; @@ -165,14 +182,21 @@ export const drawRegression = function drawRegression() { const path = "M " + this.xScale.range()[0].toString() + " " + leftY.toString() + " L " + this.xScale.range()[1].toString() + " " + rightY.toString(); - this.svg.append("path") + + if (!("clockRegression" in this.groups)) { + this.groups.clockRegression = this.svg.append("g").attr("id", "clockRegression"); + } + + this.groups.clockRegression + .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") + this.groups.clockRegression + .append("text") .text(`rate estimate: ${this.regression.slope.toExponential(2)} subs per site per year`) .attr("class", "regression") .attr("x", this.xScale.range()[1] / 2 - 75) @@ -183,6 +207,12 @@ export const drawRegression = function drawRegression() { .style("font-family", this.params.fontFamily); }; +export const removeRegression = function removeRegression() { + if ("clockRegression" in this.groups) { + this.groups.clockRegression.selectAll("*").remove(); + } +}; + /* * add and remove elements from tree, initial render */ diff --git a/src/components/tree/reactD3Interface/callbacks.js b/src/components/tree/reactD3Interface/callbacks.js index a312dbdf4..769836ec8 100644 --- a/src/components/tree/reactD3Interface/callbacks.js +++ b/src/components/tree/reactD3Interface/callbacks.js @@ -49,10 +49,14 @@ export const onBranchHover = function onBranchHover(d) { } if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) { const tree = d.that.params.orientation[0] === 1 ? this.state.tree : this.state.treeToo; - tree.svg.append("g").selectAll(".conf") + if (!("confidenceIntervals" in tree.groups)) { + tree.groups.confidenceIntervals = tree.svg.append("g").attr("id", "confidenceIntervals"); + } + tree.groups.confidenceIntervals + .selectAll(".conf") .data([d]) .enter() - .call((sel) => this.state.tree.drawSingleCI(sel, 0.5)); + .call((sel) => tree.drawSingleCI(sel, 0.5)); } this.setState({ hovered: {d, type: ".branch"} @@ -74,7 +78,7 @@ export const onBranchLeave = function onBranchLeave(d) { } if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) { const tree = d.that.params.orientation[0] === 1 ? this.state.tree : this.state.treeToo; - tree.removeConfidence(mediumTransitionDuration); + tree.removeConfidence(); } if (this.state.hovered) { this.setState({hovered: null});