UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

821 lines (734 loc) 31.9 kB
import { timerStart, timerEnd } from "../../../util/perf"; import { NODE_VISIBLE } from "../../../util/globals"; import { getDomId, setDisplayOrder } from "./helpers"; import { makeRegressionText } from "./regression"; import { getEmphasizedColor } from "../../../util/colorHelpers"; import { Callbacks, Distance, Params, PhyloNode, PhyloTreeType, Ripple } from "./types"; import { select, Selection } from "d3-selection"; import { area } from "d3-shape"; import { Focus, Layout, ScatterVariables } from "../../../reducers/controls"; import { ReduxNode, Visibility, StreamSummary, TreeState, FocusNodes} from "../../../reducers/tree/types"; export const render = function render( this: PhyloTreeType, { svg, layout, distance, focus, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables, measurementsColorGrouping, streams, focusNodes, }: { /** the SVG element into which the tree is drawn */ svg: Selection<SVGGElement | null, unknown, null, unknown> /** the layout to be used, e.g. "rect" */ layout: Layout /** the property used as branch length, e.g. div or num_date */ distance: Distance /** how to focus on nodes */ focus: Focus /** an object that contains options that will be added to this.params */ parameters: Partial<Params> /** an object with call back function defining mouse behavior */ callbacks: Callbacks /** array of branch thicknesses (same ordering as tree nodes) */ branchThickness: number[] /** array of visibility of nodes(same ordering as tree nodes) */ visibility: Visibility[] /** should confidence intervals be drawn? */ drawConfidence: boolean /** should vaccine crosses (and dotted lines if applicable) be drawn? */ vaccines: ReduxNode[] | false /** branch stroke colour for each node (set onto each node) */ branchStroke: string[] /** tip stroke colour for each node (set onto each node) */ tipStroke: string[] /** tip fill colour for each node (set onto each node) */ tipFill: string[] /** array of tip radius' */ tipRadii: number[] | null dateRange: [number, number] /** {x, y} properties to map nodes => scatterplot (only used if layout="scatter") */ scatterVariables: ScatterVariables measurementsColorGrouping: string | undefined streams: Record<string, StreamSummary> focusNodes: FocusNodes }): void { timerStart("phyloTree render()"); this.svg = svg; this.params = { ...this.params, ...parameters }; this.callbacks = callbacks; this.vaccines = vaccines ? vaccines.map((d) => d.shell) : undefined; this.measurementsColorGrouping = measurementsColorGrouping; this.dateRange = dateRange; this.streams = streams; this.focus = focus; this.focusNodes = focusNodes; /* set nodes stroke / fill */ this.nodes.forEach((d, i) => { d.branchStroke = branchStroke[i]; d.tipStroke = tipStroke[i]; d.fill = tipFill[i]; d.visibility = visibility[i]; d["stroke-width"] = branchThickness[i]; d.r = tipRadii ? tipRadii[i] : this.params.tipRadius; }); /* set x, y values & scale them to the screen */ setDisplayOrder({nodes: this.nodes, focus: this.focus, streams: this.params.showStreamTrees && streams}); this.setDistance(distance); this.setLayout(layout, scatterVariables); this.mapToScreen(); /* draw functions */ this.setClipMask(); if (this.params.showGrid) { this.addGrid(); if (!this.focus) { this.showTemporalSlice(); } } this.drawBranches(); this.updateTipLabels(); this.drawTips(); this.drawStreams(); if (this.params.branchLabelKey) this.drawBranchLabels(this.params.branchLabelKey); if (this.vaccines) this.drawVaccines(); if (this.measurementsColorGrouping) this.drawMeasurementsColoringCrosshair(); if (this.regression) this.drawRegression(); this.confidencesInSVG = false; if (drawConfidence) this.drawConfidence(); this.timeLastRenderRequested = Date.now(); timerEnd("phyloTree render()"); }; /** * adds crosses to the vaccines */ export const drawVaccines = function drawVaccines(this: PhyloTreeType): void { let vaccineData = this.vaccines || []; if (this.params.showStreamTrees) { /* Filter out vaccines which are within a rendered streamtree */ vaccineData = vaccineData.filter((d) => !d.n.inStream); } if (!vaccineData.length) return; if (!("vaccines" in this.groups)) { this.groups.vaccines = this.svg.append("g").attr("id", "vaccines"); } this.groups.vaccines .selectAll(".vaccineCross") .data(vaccineData) .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); }; export const removeMeasurementsColoringCrosshair = function removeMeasurementsColoringCrosshair(this: PhyloTreeType): void { if ("measurementsColoringCrosshair" in this.groups) { this.groups.measurementsColoringCrosshair.selectAll("*").remove(); } } /** * Adds crosshair to tip matching the measurements coloring group */ export const drawMeasurementsColoringCrosshair = function drawMeasurementsColoringCrosshair(this: PhyloTreeType): void { if ("measurementsColoringCrosshair" in this.groups) { this.removeMeasurementsColoringCrosshair(); } else { this.groups.measurementsColoringCrosshair = this.svg.append("g").attr("id", "measurementsColoringCrosshairId"); } const matchingStrains = this.nodes.filter((d) => !d.n.hasChildren && d.n.name === this.measurementsColorGrouping); if (matchingStrains.length === 1) { this.groups.measurementsColoringCrosshair .selectAll(".crosshair") .data(matchingStrains) .enter() .append("svg") .attr("stroke", "currentColor") .attr("fill", "currentColor") .attr("strokeWidth", "0") .attr("viewBox", "0 0 256 256") .attr("height", (d) => d.r * 5) .attr("width", (d) => d.r * 5) .attr("x", (d) => d.xTip - (d.r * 5 / 2)) .attr("y", (d) => d.yTip - (d.r * 5 / 2)) .style("cursor", "pointer") .style("pointer-events", "auto") .on("mouseover", this.callbacks.onTipHover) .on("mouseout", this.callbacks.onTipLeave) .on("click", this.callbacks.onTipClick) .append("path") // path copied from react-icons/pi/PiCrosshairSimpleBold .attr("d", "M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm12,191.13V184a12,12,0,0,0-24,0v27.13A84.18,84.18,0,0,1,44.87,140H72a12,12,0,0,0,0-24H44.87A84.18,84.18,0,0,1,116,44.87V72a12,12,0,0,0,24,0V44.87A84.18,84.18,0,0,1,211.13,116H184a12,12,0,0,0,0,24h27.13A84.18,84.18,0,0,1,140,211.13Z"); } else if (matchingStrains.length === 0) { console.warn(`Measurements coloring group ${this.measurementsColorGrouping} doesn't match any tip names`); } else { console.warn(`Measurements coloring group ${this.measurementsColorGrouping} matches multiple tips`); } } /** * adds all the tip circles to the svg, they have class tip */ export const drawTips = function drawTips(this: PhyloTreeType): void { timerStart("drawTips"); const params = this.params; if (!("tips" in this.groups)) { this.groups.tips = this.svg.append("g").attr("id", "tips").attr("clip-path", "url(#treeClip)"); } const nodes = (this.params.showStreamTrees ? this.nodes.filter((d) => !d.n.inStream) : this.nodes) .filter((d) => !d.n.hasChildren); this.groups.tips .selectAll(".tip") .data(nodes) .enter() .append("circle") .attr("class", "tip") .attr("id", (d) => getDomId("tip", d.n.name)) .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 === NODE_VISIBLE ? "visible" : "hidden") .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"); }; /** * given a tree node, decide whether the branch should be rendered * This enforces the "hidden" property set on `node.node_attrs.hidden` * in the dataset JSON */ export const getBranchVisibility = (d: PhyloNode): "visible" | "hidden" => { const hiddenSetting = d.n.node_attrs && d.n.node_attrs.hidden; if (hiddenSetting && ( hiddenSetting === "always" || (hiddenSetting === "timetree" && d.that.distance === "num_date") || (hiddenSetting === "divtree" && d.that.distance === "div") ) ) { return "hidden"; } return "visible"; }; /** Calculate the stroke for a given branch. May return a hex or a `url` referring to * a SVG gradient definition */ export const strokeForBranch = ( d: PhyloNode, /** branch type -- either "T" (tee) or "S" (stem) */ _b?: "T" | "S", ): string => { /* Due to errors rendering gradients on SVG branches on some browsers/OSs which would cause the branches to not appear, we're falling back to the previous solution which doesn't use gradients. The commented code remains & hopefully a solution can be found which reinstates gradients! James, April 4 2020. */ return d.branchStroke; // const id = `T${d.that.id}_${d.parent.n.arrayIdx}_${d.n.arrayIdx}`; // if (d.branchStroke === d.parent.branchStroke || b === "T") { // return d.branchStroke; // } // return `url(#${id})`; }; /** * adds all branches to the svg, these are paths with class branch, which comprise two groups */ export const drawBranches = function drawBranches(this: PhyloTreeType): void { timerStart("drawBranches"); const params = this.params; const nodes = (this.params.showStreamTrees ? this.nodes.filter((d) => !d.n.inStream) : this.nodes) .filter((d) => d.displayOrder !== undefined); /* 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").attr("clip-path", "url(#treeClip)"); } if (this.layout === "clock" || this.layout === "scatter" || this.layout === "unrooted") { this.groups.branchTee.selectAll("*").remove(); } else { this.groups.branchTee .selectAll('.branch') .data(nodes.filter((d) => d.n.hasChildren)) // only want internal nodes for the tee .enter() .append("path") .attr("class", "branch T") .attr("id", (d) => getDomId("branchT", d.n.name)) .attr("d", (d) => d.branch[1]) .style("stroke", (d) => d.branchStroke || params.branchStroke) .style("stroke-width", (d) => d['stroke-width'] || params.branchStrokeWidth) .style("visibility", getBranchVisibility) .style("fill", "none") .style("pointer-events", "auto") .on("mouseover", this.callbacks.onBranchHover) .on("mouseout", this.callbacks.onBranchLeave) .on("click", this.callbacks.onBranchClick); } /* PART 2: draw the branch stems (i.e. the actual branches) */ /* PART 2a: Create linear gradient definitions which can be applied to branch stems for which the start & end stroke colour is different */ if (!this.groups.branchGradientDefs) { this.groups.branchGradientDefs = this.svg.append("defs"); } this.groups.branchGradientDefs.selectAll("*").remove(); // TODO -- explore if duplicate <def> elements (e.g. same colours on each end) slow things down this.updateColorBy(); /* PART 2b: Draw the stems */ if (!("branchStem" in this.groups)) { this.groups.branchStem = this.svg.append("g").attr("id", "branchStem").attr("clip-path", "url(#treeClip)"); } this.groups.branchStem .selectAll('.branch') .data(nodes) .enter() .append("path") .attr("class", "branch S") .attr("id", (d) => getDomId("branchS", d.n.name)) .attr("d", (d) => d.branch[0]) .style("stroke", (d) => { if (!d.branchStroke) return params.branchStroke; return strokeForBranch(d, "S"); }) .style("stroke-linecap", "round") .style("stroke-width", (d) => d['stroke-width'] || params.branchStrokeWidth) .style("visibility", getBranchVisibility) .style("cursor", (d) => d.visibility === NODE_VISIBLE ? "pointer" : "default") .style("pointer-events", "auto") .on("mouseover", this.callbacks.onBranchHover) .on("mouseout", this.callbacks.onBranchLeave) .on("click", this.callbacks.onBranchClick); timerEnd("drawBranches"); }; /** * draws the regression line in the svg and adds a text with the rate estimate */ export const drawRegression = function drawRegression(this: PhyloTreeType): void { /* check we have computed a sensible regression before attempting to draw */ if (this.regression.slope===undefined) { return; } 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(); if (!("regression" in this.groups)) { this.groups.regression = this.svg.append("g").attr("id", "regression").attr("clip-path", "url(#treeClip)"); } this.groups.regression .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); /* Compute & draw regression text. Note that the text hasn't been created until now, as we need to wait until rendering time when the scales have been calculated */ this.groups.regression .append("text") .text(makeRegressionText(this.regression, this.layout, this.yScale)) .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); }; export const removeRegression = function removeRegression(this: PhyloTreeType): void { if ("regression" in this.groups) { this.groups.regression.selectAll("*").remove(); } }; /* * add and remove elements from tree, initial render */ export const clearSVG = function clearSVG(this: PhyloTreeType): void { this.svg.selectAll("*").remove(); }; /* Due to errors rendering gradients on SVG branches on some browsers/OSs which would cause the branches to not appear, we're falling back to the previous solution which doesn't use gradients. Calls to `updateColorBy` are therefore unnecessary. James, April 4 2020. */ // eslint-disable-next-line @typescript-eslint/no-empty-function export const updateColorBy = function updateColorBy(): void {}; // export const updateColorBy = function updateColorBy() { // // console.log("updating colorBy") // this.nodes.forEach((d) => { // const a = d.parent.branchStroke; // const b = d.branchStroke; // const id = `T${this.id}_${d.parent.n.arrayIdx}_${d.n.arrayIdx}`; // if (a === b) { // not a gradient // color can be computed from d alone // this.svg.select(`#${id}`).remove(); // remove an existing gradient for this node // return; // } // if (!this.svg.select(`#${id}`).empty()) { // there an existing gradient // update its colors // // console.log("adjusting " + id + " " + d.parent.branchStroke + "=>" + d.branchStroke); // this.svg.select(`#${id}_begin`).attr("stop-color", d.parent.branchStroke); // this.svg.select(`#${id}_end`).attr("stop-color", d.branchStroke); // } else { // otherwise create a new gradient // // console.log("new gradient " + id + " " + d.parent.branchStroke + "=>" + d.branchStroke); // const linearGradient = this.svg.select("defs").append("linearGradient") // .attr("id", id); // if (d.rot && typeof d.rot === "number") { // linearGradient.attr("gradientTransform", "translate(.5,.5) rotate(" + d.rot + ") translate(-.5,-.5)"); // } // linearGradient.append("stop") // .attr("id", id + "_begin") // .attr("offset", "0") // .attr("stop-color", d.parent.branchStroke); // linearGradient.append("stop") // .attr("id", id + "_end") // .attr("offset", "1") // .attr("stop-color", d.branchStroke); // } // }); // }; /** given a node `d` which is being hovered, update it's colour to emphasize * that it's being hovered. This updates the SVG element stroke style in-place * _or_ updates the SVG gradient def in place. */ const handleBranchHoverColor = ( d: PhyloNode, /** colour of the parent (start of the branch) */ _c1: string, /** colour of the node (end of the branch) */ c2: string, ): void => { if (!d) { return; } /* We want to emphasize the colour of the branch. How we do this depends on how the branch was rendered in the first place! */ const tel = d.that.svg.select("#"+getDomId("branchT", d.n.name)); if (!tel.empty()) { // Some displays don't have S & T parts of the branch tel.style("stroke", c2); } /* If we reinstate gradient stem colours this section must be updated; see the commit which added this comment for the previous implementation */ const sel = d.that.svg.select("#"+getDomId("branchS", d.n.name)); if (!sel.empty()) { sel.style("stroke", c2); } }; export const branchStrokeForLeave = function branchStrokeForLeave(d: PhyloNode): void { if (!d) { return; } handleBranchHoverColor(d, d.n.parent.shell.branchStroke, d.branchStroke); }; export const branchStrokeForHover = function branchStrokeForHover(d: PhyloNode): void { if (!d) { return; } handleBranchHoverColor(d, getEmphasizedColor(d.n.parent.shell.branchStroke), getEmphasizedColor(d.branchStroke)); }; /** * Create / update the clipping mask which is attached to branches, tips, branch-labels * and regression lines. In theory, we can clip to exactly the {xy}Scale range, however * in practice, elements (or portions of elements) render outside this. */ export const setClipMask = function setClipMask(this: PhyloTreeType): void { const [yMin, yMax] = this.yScale.range(); // for the RHS tree (if there is one) ensure that xMin < xMax, else width<0 which some // browsers don't like. See <https://github.com/nextstrain/auspice/issues/1755> let [xMin, xMax] = this.xScale.range(); if (parseInt(xMin, 10)>parseInt(xMax, 10)) [xMin, xMax] = [xMax, xMin]; const x0 = xMin - 5; const width = xMax - xMin + 20; // RHS overflow is not problematic const y0 = yMin - 15; // some overflow at top is ok const height = yMax - yMin + 20; // extra padding to allow tips & lowest major axis line to render if (!this.groups.clipPath) { this.groups.clipPath = this.svg.append("g").attr("id", "clipGroup"); this.groups.clipPath.append("clipPath") .attr("id", "treeClip") .append("rect") .attr("x", x0) .attr("y", y0) .attr("width", width) .attr("height", height); } else { this.groups.clipPath.select('rect') .attr("x", x0) .attr("y", y0) .attr("width", width) .attr("height", height); } }; export interface LabelDatum { phyloNode: PhyloNode; streamName: string|undefined; x: number; y: number; textAnchor: string; fontSize: number; visibility: string; } export function drawStreams(this: PhyloTreeType): void { /* stream order is reversed so that stream connectors are correctly layered behind their parent streams */ const streamsToDraw = this.params.showStreamTrees ? Object.keys(this.streams).reverse() : []; /* initial set up - runs when streams are turned on / removed NOTE: we use 2 top-level groups here so that the labels are always drawn on top of any connectors / ribbons */ if (streamsToDraw.length && !("streams" in this.groups)) { this.groups.streams = this.svg.append("g").attr("id", "streams"); this.groups.streamsLabels = this.svg.append("g").attr("id", "stream-labels"); } else if (!streamsToDraw.length && "streams" in this.groups) { this.groups.streams.selectAll("*").remove(); this.groups.streamsLabels.selectAll("*").remove(); delete this.groups.streams; delete this.groups.streamsLabels; } if (!streamsToDraw.length) { return; } /** For each stream, construct a SVG group to house the stream, and within each group create * (sub)groups for the connector, ripples & labels, so the layer order is preserved when we update * individual elements. */ this.groups.streams.selectAll('.streamGroup') .data(streamsToDraw, (d) => String(d)) .join( (enter) => { const selection = enter.append('g') .attr('id', (name) => `stream${name}`) .attr('class', `streamGroup`); selection.append("g").attr("class", "connector"); selection.append("g").attr("class", "ripples"); return selection }, undefined, // no update method needed (exit) => { return exit .call((selection) => selection.transition('500') .style('opacity', 0) .remove() ) }, ); const areaGenerator: (param: Ripple) => string = area<Ripple[0]>() .x((d) => d.x) .y0((d) => d.y0) .y1((d) => d.y1); /** * Joiner lines connect the parent stream or parent branch to the start of this stream * (where start is defined by the occurrence of the first pivot, which is some amount to the left * of the first tip in the stream). * Backbone lines sit behind the stream itself spanning the pivot range. They help with the appearance * of streams in regions where the KDE drops to zero */ const connectorPath = (node: PhyloNode, lineType: 'joiner'|'backbone'): string => { // a.k.a. branch // don't draw connectors to empty streams! if (node.n.streamNodeCounts.visible===0) return ""; const y = this.yScale(node.displayOrder); if (lineType==='backbone') { const xStreamStart = node.streamRipples.at(0).at(0).x; // first category, first pivot const xStreamEnd = node.streamRipples.at(0).at(-1).x; // first category, last pivot return `M${xStreamStart},${y}H${xStreamEnd}`; } // If the stream start node is hidden (as set via the JSON) then don't show a connector! // Note - there are strange states this gets us to. For instance, imagine stream1 -> stream2, // if we hide the connector to stream1 then we may still render the connector to stream2 which // will now start in empty space... if (getBranchVisibility(node)==='hidden') { return ""; } const x1 = node.streamRipples[0][0].x; // first category, first pivot const parentStreamName = this.streams[node.n.streamName].parentStreamName; if (parentStreamName) { // draw a kinked connector const x0 = node.xBase; // represents the div/time of the stream-defining branch // NOTE: We guarantee that the the pivots do not extend beyond x0 (i.e. to the LHS) const yParent = this.yScale(this.nodes[this.streams[parentStreamName].startNode].displayOrder); return `M${x0},${yParent}L${x0},${y}L${x1},${y}`; } // no parent stream - horizontal line from the parent node. // (The parent node, unless hidden, will have been rendered as a normal branch which includes the 'tee' part which // this line will touch) return `M${node.xBase},${y}H${x1}`; } for (const name of streamsToDraw) { const node = this.nodes[this.streams[name].startNode]; const callbacks = this.callbacks; this.groups.streams.select(`#${CSS.escape(`stream${name}`)}`).select('.connector') .selectAll(`.connectorPath`) .data([node, node], (_d, i) => i===0?'joiner':'backbone') .join( (enter) => { return enter .append("path") .attr("class", `connectorPath`) .attr("d", (d, i) => connectorPath(d, i===0?'joiner':'backbone')) // fat-arrow to avoid d3 rebinding `this` .attr("stroke-width", this.params.branchStrokeWidth) .style("stroke", (d, i) => i===0 ? d.branchStroke : this.params.branchStroke) .attr("fill", 'None') .style("cursor", "pointer") .style("pointer-events", "auto") .on("mouseover", function (_d, i, paths) { /* tsc can't detect the runtime rebinding of this (within `initialRender.ts`) such that `this=TreeComponent` */ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions (callbacks.onStreamHover as OmitThisParameter<typeof callbacks.onStreamHover>)(node, i, Array.from(paths), true) }) .on("mouseout", function (_d, i, paths) { /* tsc can't detect the runtime rebinding of this (within `initialRender.ts`) such that `this=TreeComponent` */ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions (callbacks.onStreamLeave as OmitThisParameter<typeof callbacks.onStreamLeave>)(node, i, Array.from(paths), true) }) .on("click", callbacks.onBranchClick) }, (update) => { return update.call( (selection) => selection.transition("500") .attr("d", (d, i) => connectorPath(d, i===0?'joiner':'backbone')) // fat-arrow to avoid rebinding `this` .style("stroke", (d, i) => i===0 ? d.branchStroke : this.params.branchStroke) .attr("stroke-width", this.params.branchStrokeWidth) ); }, (exit) => { return exit.remove(); }, ); this.groups.streams.select(`#${CSS.escape(`stream${name}`)}`).select(`.ripples`) .selectAll<SVGPathElement, Ripple>(`.ripple`) .data(node.streamRipples, (d) => String(d.key)) .join( (enter) => { return enter .append("path") .attr("class", `ripple`) .attr("d", (d) => areaGenerator(d)) .attr("fill", (_d, i:number) => node.n.streamCategories[i].color) .on("mouseover", (_d, i, paths) => { /* tsc can't detect the runtime rebinding of this (within `initialRender.ts`) such that `this=TreeComponent` */ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions (this.callbacks.onStreamHover as OmitThisParameter<typeof this.callbacks.onStreamHover>)(node, i, Array.from(paths), false) }) .on("mouseout", (_d, i, paths) => { /* tsc can't detect the runtime rebinding of this (within `initialRender.ts`) such that `this=TreeComponent` */ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions (this.callbacks.onStreamLeave as OmitThisParameter<typeof this.callbacks.onStreamHover>)(node, i, Array.from(paths), false) }) .style("cursor", "pointer") .style("pointer-events", "auto") .on("click", () => this.callbacks.onBranchClick(node)) }, (update) => { return update.call( (selection) => selection.transition("500") .attr("d", (d) => areaGenerator(d)) ); }, (exit) => { return exit.remove(); }, ); } const labelData: LabelDatum[] = streamsToDraw .map((streamName): LabelDatum|null => { const phyloNode = this.nodes[this.streams[streamName].startNode]; if (phyloNode.n.streamNodeCounts.visible===0) { return null; } /* Text anchor -- if there's enough of the joiner branch in-view then we want to use 'end', which stops the label overlapping the stream. TODO XXX */ const textAnchor = this.zoomNode.n.name===phyloNode.n.name ? 'start' : 'middle'; const streamMaxPixels = _tallestPivot(phyloNode)[1]; const minFontSize = 10, maxFontSize = 24; const fontSize = Math.floor(Math.max(minFontSize, Math.min(streamMaxPixels/2, maxFontSize))); const visibility = fontSize===minFontSize ? 'hidden' : 'visible'; return { phyloNode, streamName, x: phyloNode.streamRipples.at(0).at(0).x, // The start of the stream / end of the joiner connector y: this.yScale(phyloNode.displayOrder), textAnchor, fontSize, visibility, } }) .filter((d) => !!d); // remove labels for non-rendered streams /* LABELS */ this.groups.streamsLabels .selectAll<SVGTextElement, LabelDatum>(`.labelText`) .data(labelData, (d) => String(d.streamName)) .join( (enter) => { return enter .append("text") .attr("class", `labelText`) .attr('id', (d) => `label${d.streamName}`) .attr("x", (d) => d.x) .attr("y", (d) => d.y) .attr("text-anchor", (d) => d.textAnchor) .attr("dominant-baseline", "ideographic") .attr("font-size", (d) => d.fontSize) .attr('visibility', (d) => d.visibility) .text((d) => d.phyloNode.n.streamName) .style("pointer-events", "none"); }, (update) => { return update.call( (selection) => selection .attr("x", (d) => d.x) .attr("y", (d) => d.y) .attr("text-anchor", (d) => d.textAnchor) .attr('visibility', (d) => d.visibility) .attr("font-size", (d) => d.fontSize) ); }, (exit) => { return exit.remove(); }, ); } function _tallestPivot(node: PhyloNode): [number, number] { return node.streamRipples[0].reduce(([i, maxH], pivotData, pivotIdx) => { const h = node.streamRipples.at(-1)[pivotIdx].y1 - pivotData.y0; if (pivotIdx===0) return [0,h]; if (h>maxH) return [pivotIdx, h]; return [i, maxH]; }, [undefined, undefined]); } export const nonHoveredRippleOpacity = 0.3; /** * Highlight stream ripples matching the *attr*. This is done by dropping the opacity * of non-matching ripples, which matches the behaviour when we hover over ripples themselves. * * but we could explore other approaches more similar to the frequency panel where we instead * darken the ripple we wish to highlight */ export function highlightStreamtreeRipples(this: PhyloTreeType, attr: TreeState['hoveredLegendSwatch']): void { const g = this.groups?.streams if (!g) return null; if (attr===false) { g.selectAll<SVGPathElement, Ripple>(".ripple") .style('opacity', 1); return; } /** * The Ripple data structure doesn't describe the underlying data but it does * include a key which is the "<attribute value>_<color by name>" which we can * match legend swatches on */ const attr_ = attr + '_'; g.selectAll<SVGPathElement, Ripple>(".ripple") .each(function (d) { if (!d.key.startsWith(attr_)) { select(this).style('opacity', nonHoveredRippleOpacity) } }) }