UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

529 lines (480 loc) 20.2 kB
import { Selection } from "d3-selection"; import { Transition } from "d3-transition"; import { timerFlush } from "d3-timer"; import { calcConfidenceWidth } from "./confidence"; import { applyToChildren, setDisplayOrder, setRippleDisplayOrders } from "./helpers"; import { timerStart, timerEnd } from "../../../util/perf"; import { NODE_VISIBLE } from "../../../util/globals"; import { getBranchVisibility, strokeForBranch } from "./renderers"; import { shouldDisplayTemporalConfidence } from "../../../reducers/controls"; import { makeTipLabelFunc } from "./labels"; import { ChangeParams, PhyloNode, PhyloTreeType, PropsForPhyloNodes, SVGProperty, TreeElement } from "./types"; /* loop through the nodes and update each provided prop with the new value * additionally, set d.update -> whether or not the node props changed */ const updateNodesWithNewData = ( nodes: PhyloNode[], newNodeProps: PropsForPhyloNodes, ): void => { // console.log("update nodes with data for these keys:", Object.keys(newNodeProps)); // let tmp = 0; nodes.forEach((d, i) => { d.update = false; for (const key in newNodeProps) { const val = newNodeProps[key][i]; if (val !== d[key]) { d[key] = val; d.update = true; // tmp++; } } }); // console.log("marking ", tmp, " nodes for update"); }; /* svgSetters defines how attrs & styles should be applied to which class (e.g. ".tip"). * E.g. which node attribute should be used?!? * Note that only the relevant functions are called on a transition. */ const svgSetters = { attrs: { ".tip": { r: (d: PhyloNode): number => d.r, cx: (d: PhyloNode): number => d.xTip, cy: (d: PhyloNode): number => d.yTip }, ".branch": { }, ".vaccineCross": { d: (d: PhyloNode): string => d.vaccineCross }, ".conf": { d: (d: PhyloNode): string => d.confLine } }, styles: { ".tip": { fill: (d: PhyloNode): string => d.fill, stroke: (d: PhyloNode): string => d.tipStroke, visibility: (d: PhyloNode): "visible" | "hidden" => d.visibility === NODE_VISIBLE ? "visible" : "hidden" }, ".conf": { stroke: (d: PhyloNode): string => d.branchStroke, "stroke-width": calcConfidenceWidth }, // only allow stroke to be set on individual branches ".branch": { "stroke-width": (d: PhyloNode): string => d["stroke-width"] + "px", // style - as per drawBranches() stroke: (d: PhyloNode): string => strokeForBranch(d), // TODO: revisit if we bring back SVG gradients cursor: (d: PhyloNode): "pointer" | "default" => d.visibility === NODE_VISIBLE ? "pointer" : "default", visibility: getBranchVisibility } } }; type UpdateCall = (selection: Transition<SVGGElement, PhyloNode, SVGGElement | null, unknown>) => void; /** createUpdateCall * returns a function which can be called as part of a D3 chain in order to modify * the SVG elements. * svgSetters (see above) are used to actually modify the property on the element, * so the given property must also be present there! */ function createUpdateCall( treeElem: TreeElement, /** e.g. ["visibility", "stroke-width"] */ properties: Set<SVGProperty>, ): UpdateCall { return (selection) => { // First: the properties to update via d3Selection.attr call if (svgSetters.attrs[treeElem]) { [...properties].filter((x) => svgSetters.attrs[treeElem][x]) .forEach((attrName) => { // console.log(`applying attr ${attrName} to ${treeElem}`) selection.attr(attrName, svgSetters.attrs[treeElem][attrName]); }); } // Second: the properties to update via d3Selection.style call if (svgSetters.styles[treeElem]) { [...properties].filter((x) => svgSetters.styles[treeElem][x]) .forEach((styleName) => { // console.log(`applying style ${styleName} to ${treeElem}`) selection.style(styleName, svgSetters.styles[treeElem][styleName]); }); } }; } const genericSelectAndModify = ( svg: Selection<SVGGElement | null, unknown, null, unknown>, treeElem: TreeElement, updateCall: UpdateCall, transitionTime: number, ): void => { // console.log("general svg update for", treeElem); svg.selectAll<SVGGElement, PhyloNode>(treeElem) .filter((d) => d.update) .transition().duration(transitionTime) .call(updateCall); if (!transitionTime) { timerFlush(); } }; /* use D3 to select and modify elements, such that a given element is only ever modified _once_ * @elemsToUpdate {set} - the class names to select, e.g. ".tip" or ".branch" * @svgPropsToUpdate {set} - the props (styles & attrs) to update. The respective functions are defined above * @transitionTime {INT} - in ms. if 0 then no transition (timerFlush is used) * @extras {dict} - extra keywords to tell this function to call certain phyloTree update methods. In flux. */ export const modifySVG = function modifySVG( this: PhyloTreeType, elemsToUpdate: Set<TreeElement>, svgPropsToUpdate: Set<SVGProperty>, transitionTime: number, extras: Extras, ): void { let updateCall: UpdateCall; const classesToPotentiallyUpdate: TreeElement[] = [".tip", ".vaccineDottedLine", ".vaccineCross", ".branch"]; /* order is respected */ /* treat stem / branch specially, but use these to replace a normal .branch call if that's also to be applied */ if (elemsToUpdate.has(".branch.S") || elemsToUpdate.has(".branch.T")) { const applyBranchPropsAlso = elemsToUpdate.has(".branch"); if (applyBranchPropsAlso) classesToPotentiallyUpdate.splice(classesToPotentiallyUpdate.indexOf(".branch"), 1); const ST: Array<".S" | ".T"> = [".S", ".T"]; ST.forEach((x, STidx) => { if (elemsToUpdate.has(`.branch${x}`)) { if (applyBranchPropsAlso) { updateCall = (selection): void => { createUpdateCall(".branch", svgPropsToUpdate)(selection); /* the "normal" branch changes to apply */ selection.attr("d", (d) => d.branch[STidx]); /* change the path (differs between .S and .T) */ }; } else { updateCall = (selection): void => { selection.attr("d", (d) => d.branch[STidx]); }; } genericSelectAndModify(this.svg, `.branch${x}`, updateCall, transitionTime); } }); } classesToPotentiallyUpdate.forEach((el) => { if (elemsToUpdate.has(el)) { updateCall = createUpdateCall(el, svgPropsToUpdate); genericSelectAndModify(this.svg, el, updateCall, transitionTime); } }); /* special cases not listed in classesToPotentiallyUpdate */ if (extras.hideTipLabels) { this.removeTipLabels(); } else if (elemsToUpdate.has('.tipLabel')) { this.updateTipLabels(); } if (elemsToUpdate.has('.grid')) { if (this.grid && this.layout !== "unrooted") this.addGrid(); else this.hideGrid(); } if (elemsToUpdate.has('.regression')) { this.removeRegression(); if (this.regression) this.drawRegression(); } /* confidence intervals */ if (extras.removeConfidences && this.confidencesInSVG) { this.removeConfidence(); /* do not use a transition time - it's too clunky (too many elements?) */ } else if (extras.showConfidences && !this.confidencesInSVG) { this.drawConfidence(); /* see comment above */ } else if (elemsToUpdate.has(".conf") && this.confidencesInSVG) { if (shouldDisplayTemporalConfidence(true, this.distance, this.layout)) { updateCall = createUpdateCall(".conf", svgPropsToUpdate); genericSelectAndModify(this.svg, ".conf", updateCall, transitionTime); } else { this.removeConfidence(); /* see comment above */ } } /* background temporal time slice */ if (extras.timeSliceHasPotentiallyChanged) { this.showTemporalSlice(); } /* branch labels */ if (extras.newBranchLabellingKey) { this.removeBranchLabels(); if (extras.newBranchLabellingKey !== "none") { this.drawBranchLabels(extras.newBranchLabellingKey); } } else if (elemsToUpdate.has('.branchLabel')) { this.updateBranchLabels(transitionTime); } if (this.measurementsColorGrouping) { this.drawMeasurementsColoringCrosshair(); } else { this.removeMeasurementsColoringCrosshair(); } }; /* instead of modifying the SVG the "normal" way, this is sometimes too janky (e.g. when we need to move everything) * step 1: fade out & remove everything except tips. * step 2: when step 1 has finished, move tips across the screen. * step 3: when step 2 has finished, redraw everything. No transition here. */ export const modifySVGInStages = function modifySVGInStages( this: PhyloTreeType, elemsToUpdate: Set<TreeElement>, svgPropsToUpdate: Set<SVGProperty>, transitionTimeFadeOut: number, transitionTimeMoveTips: number, extras: Extras, ): void { elemsToUpdate.delete(".tip"); this.hideGrid(); let inProgress = 0; /* counter of transitions currently in progress */ const step3 = (): void => { this.drawBranches(); if (this.params.showGrid) this.addGrid(); this.svg.selectAll(".tip").remove(); this.updateTipLabels(); this.drawTips(); if (this.vaccines) this.drawVaccines(); if (this.measurementsColorGrouping) { this.drawMeasurementsColoringCrosshair(); } this.showTemporalSlice(); if (this.regression) this.drawRegression(); if (elemsToUpdate.has(".branchLabel")) this.drawBranchLabels(extras.newBranchLabellingKey || this.params.branchLabelKey); }; /* STEP 2: move tips */ const step2 = (): void => { if (!--inProgress) { /* decrement counter. When hits 0 run block */ this.setClipMask(); const updateTips = createUpdateCall(".tip", svgPropsToUpdate); genericSelectAndModify(this.svg, ".tip", updateTips, transitionTimeMoveTips); setTimeout(step3, transitionTimeMoveTips); } }; /* STEP 1. remove everything (via opacity) */ this.confidencesInSVG = false; this.svg.selectAll([...elemsToUpdate].join(", ")) .transition().duration(transitionTimeFadeOut) .style("opacity", 0) .remove() .on("start", () => inProgress++) .on("end", step2); this.hideTemporalSlice(); this.removeMeasurementsColoringCrosshair(); if (!transitionTimeFadeOut) timerFlush(); }; interface Extras { removeConfidences: boolean showConfidences: boolean newBranchLabellingKey?: string timeSliceHasPotentiallyChanged?: boolean hideTipLabels?: boolean } /* the main interface to changing a currently rendered tree. * simply call change and tell it what should be changed. * try to do a single change() call with as many things as possible in it */ export const change = function change( this: PhyloTreeType, { changeColorBy = false, changeVisibility = false, changeTipRadii = false, changeBranchThickness = false, showConfidences = false, removeConfidences = false, zoomIntoClade = false, svgHasChangedDimensions = false, animationInProgress = false, changeNodeOrder = false, focusChange = false, newDistance = undefined, newLayout = undefined, updateLayout = undefined, newBranchLabellingKey = undefined, showAllBranchLabels = undefined, newTipLabelKey = undefined, streamDefinitionChange = undefined, branchStroke = undefined, tipStroke = undefined, fill = undefined, visibility = undefined, tipRadii = undefined, hoveredLegendSwatch = undefined, branchThickness = undefined, scatterVariables = undefined, performanceFlags = undefined, newMeasurementsColorGrouping = undefined, }: ChangeParams ): void { // console.log("\n** phylotree.change() (time since last run:", Date.now() - this.timeLastRenderRequested, "ms) **\n\n"); timerStart("phylotree.change()"); const elemsToUpdate = new Set<TreeElement>(); /* what needs updating? E.g. ".branch", ".tip" etc */ const nodePropsToModify: PropsForPhyloNodes = {}; /* which properties (keys) on the nodes should be updated (before the SVG) */ const svgPropsToUpdate = new Set<SVGProperty>(); /* which SVG properties shall be changed. E.g. "fill", "stroke" */ const useModifySVGInStages = newLayout; /* use modifySVGInStages rather than modifySVG. Not used often. */ /* calculate dt */ const idealTransitionTime = 500; let transitionTime = idealTransitionTime; if ((Date.now() - this.timeLastRenderRequested) < idealTransitionTime * 2 || performanceFlags.get("skipTreeAnimation")===true) { transitionTime = 0; } /* the logic of converting what react is telling us to change and what SVG elements, node properties, svg props we actually change */ if (changeColorBy) { /* check that fill & stroke are defined */ elemsToUpdate.add(".branch").add(".tip").add(".conf"); svgPropsToUpdate.add("stroke").add("fill"); nodePropsToModify.branchStroke = branchStroke; nodePropsToModify.tipStroke = tipStroke; nodePropsToModify.fill = fill; } if (changeVisibility) { /* check that visibility is not undefined */ /* in the future we also change the branch visibility (after skeleton merge) */ elemsToUpdate.add(".tip").add(".tipLabel").add(".branchLabel"); svgPropsToUpdate.add("visibility").add("cursor"); nodePropsToModify.visibility = visibility; } if (changeTipRadii) { elemsToUpdate.add(".tip"); svgPropsToUpdate.add("r"); nodePropsToModify.r = tipRadii; if (this.params.showStreamTrees) { /* note: this won't play nicely with other changes to the streamtrees, but we rely on the knowledge that tip radii changes are via mouse-over events _only_ and so there won't be any other SVG changes requested */ this.highlightStreamtreeRipples(hoveredLegendSwatch) } } if (changeBranchThickness) { elemsToUpdate.add(".branch").add(".conf"); svgPropsToUpdate.add("stroke-width"); nodePropsToModify["stroke-width"] = branchThickness; } if (newDistance || newLayout || updateLayout || zoomIntoClade || svgHasChangedDimensions || changeNodeOrder || changeVisibility) { elemsToUpdate.add(".tip").add(".branch.S").add(".branch.T").add(".branch"); elemsToUpdate.add(".vaccineCross").add(".vaccineDottedLine").add(".conf"); elemsToUpdate.add('.branchLabel').add('.tipLabel'); elemsToUpdate.add(".grid").add(".regression"); svgPropsToUpdate.add("cx").add("cy").add("d").add("opacity") .add("visibility"); } /* show confidences - set this param which actually adds the svg paths for confidence intervals when mapToScreen() gets called below */ if (showConfidences) this.params.confidence = true; /* keep the state of phylotree in sync with redux (more complex than it should be) */ if (showAllBranchLabels!==undefined) { this.params.showAllBranchLabels=showAllBranchLabels; elemsToUpdate.add('.branchLabel'); } /* tip label key change -> update callback used */ if (newTipLabelKey) { this.callbacks.tipLabel = makeTipLabelFunc(newTipLabelKey); elemsToUpdate.add('.tipLabel'); /* will trigger d3 commands as required */ } /* change the requested properties on the nodes */ updateNodesWithNewData(this.nodes, nodePropsToModify); // recalculate gradients here? if (changeColorBy) { this.updateColorBy(); this.measurementsColorGrouping = newMeasurementsColorGrouping; } // recalculate existing regression if needed if (changeVisibility && this.regression) { elemsToUpdate.add(".regression"); this.calculateRegression(); // Note: must come after `updateNodesWithNewData()` } /* some things need to update d.inView and/or d.update. This should be centralised */ /* TODO: list all functions which modify these */ if (zoomIntoClade) { /* must happen below updateNodesWithNewData */ this.nodes.forEach((d) => { d.inView = false; d.update = true; }); /* if clade is terminal, use the parent as the zoom node */ this.zoomNode = zoomIntoClade.n.hasChildren ? zoomIntoClade : zoomIntoClade.n.parent.shell; applyToChildren(this.zoomNode, (d: PhyloNode) => {d.inView = true;}); } if (svgHasChangedDimensions || changeNodeOrder || changeVisibility) { this.nodes.forEach((d) => {d.update = true;}); } /** PHYLOTREE METHODS * Note the order here is (often) critical! This order reflects the order in the initial tree render cycle */ /** display order calculations */ if (newDistance || updateLayout || changeNodeOrder || streamDefinitionChange) { setDisplayOrder({nodes: this.nodes, focus: this.focus, streams: this.params.showStreamTrees && this.streams}); } else if (this.params.showStreamTrees && (changeColorBy || changeVisibility)) { // rippleDisplayOrders are typically called by setDisplayOrder however for ∆{colorBy,visibility} we don't want to pay // the price of recomputing the display orders for the entire tree, we just need to recompute the // display-order-dimensions of the ripples setRippleDisplayOrders(this.nodes, this.streams) } /** set distance (temporal vs div) */ if (newDistance || updateLayout) { this.setDistance(newDistance); } if (newDistance || newLayout || updateLayout || changeNodeOrder || streamDefinitionChange) { this.setLayout(newLayout || this.layout, scatterVariables); } /** mapToScreen (recomputes scales and maps transforms values into pixel space) */ if ( svgPropsToUpdate.has("stroke-width") || newDistance || newLayout || changeNodeOrder || updateLayout || zoomIntoClade || svgHasChangedDimensions || streamDefinitionChange || changeVisibility || showConfidences ) { this.mapToScreen(); } else if (this.params.showStreamTrees && (changeColorBy || changeVisibility)) { // mapStreamsToScreen is typically called by mapToScreen however for ∆{colorBy,visibility} we don't want to pay // the price of the entire mapToScreen function but we do need to recompute the pixel-dimensions of the ripples! this.mapStreamsToScreen(); // updates the pixel coordinates } if (focusChange) { // temporal slices must come after `mapToScreen` (as that sets the scales) if (this.focus==='selected') { this.hideTemporalSlice() } else { this.showTemporalSlice() } } /** Finally modify the SVG now that all the recalculations are complete * Most of the time we use modifySVGInStages / modifySVG which update specific attrs * Other times */ if (streamDefinitionChange) { /** * Currently we draw branches, tips etc by filtering the nodes array to include/exclude * nodes in stream trees. Out d3 usage is not set up to track keys so updating the selection * as data enters/exits is not yet possible. The (unfortunate) result is that we tear everything * down and redraw it if the streams are either toggled off or the branch label defining them * changes. */ for (const name of ['branchLabels', 'branchTee', 'branchStem', 'tips', 'tipLabels', 'vaccines']) { this.groups?.[name]?.selectAll("*")?.remove(); } this.addGrid(); this.drawBranches(); this.updateTipLabels(); this.drawTips(); this.drawBranchLabels(this.params.branchLabelKey); if (this.vaccines) this.drawVaccines(); if (this.regression) this.drawRegression(); if (this.confidencesInSVG) this.removeConfidence(); this.drawStreams(); // removes streams, as appropriate } else { const extras: Extras = { removeConfidences, showConfidences, newBranchLabellingKey }; extras.timeSliceHasPotentiallyChanged = changeVisibility || newDistance !== undefined; extras.hideTipLabels = animationInProgress || newTipLabelKey === 'none'; if (useModifySVGInStages) { this.modifySVGInStages(elemsToUpdate, svgPropsToUpdate, transitionTime, 1000, extras); } else { this.modifySVG(elemsToUpdate, svgPropsToUpdate, transitionTime, extras); } if (this.params.showStreamTrees || changeColorBy) { this.drawStreams(); // removes streams, as appropriate } } this.timeLastRenderRequested = Date.now(); timerEnd("phylotree.change()"); };