auspice
Version:
Web app for visualizing pathogen evolution
821 lines (734 loc) • 31.9 kB
text/typescript
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)
}
})
}