auspice
Version:
Web app for visualizing pathogen evolution
531 lines (486 loc) • 22 kB
JavaScript
/* eslint-disable space-infix-ops */
import { min, max } from "d3-array";
import { transition } from "d3-transition";
import { easeLinear } from "d3-ease";
import { timerStart, timerEnd } from "../../../util/perf";
import { animationInterpolationDuration } from "../../../util/globals";
import { guessAreMutationsPerSite } from "./helpers";
import { numericToDateObject, calendarToNumeric, getPreviousDate, getNextDate, dateToString, prettifyDate } from "../../../util/dateHelpers";
export const hideGrid = function hideGrid() {
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');
}
if ("axisText" in this.groups) {
this.groups.axisText.selectAll("*").style('visibility', 'hidden');
}
};
const addSVGGroupsIfNeeded = (groups, svg) => {
if (!("temporalWindow" in groups)) {
groups.temporalWindow = svg.append("g").attr("id", "temporalWindow");
// Technically rects aren't groups, but store them to avoid searching for them on each "showTemporalSlice" render.
groups.temporalWindowStart = groups.temporalWindow.append('rect')
.attr('class', 'temporalWindowStart');
groups.temporalWindowEnd = groups.temporalWindow.append('rect')
.attr('class', 'temporalWindowEnd');
}
if (!("majorGrid" in groups)) {
groups.majorGrid = svg.append("g").attr("id", "majorGrid").attr("clip-path", "url(#treeClip)");
}
if (!("minorGrid" in groups)) {
groups.minorGrid = svg.append("g").attr("id", "minorGrid").attr("clip-path", "url(#treeClip)");
}
if (!("gridText" in groups)) {
groups.gridText = svg.append("g").attr("id", "gridText");
}
if (!("axisText" in groups)) {
groups.axisText = svg.append("g").attr("id", "axisText");
}
};
/**
* Create the separation between major & minor grid lines for numeric scales.
* @param {numeric} range e.g. amount of divergence (subs/site/year _or_ num mutations) present in current view
* @param {numeric} minorTicks num of minor ticks desired between each major step
* @returns {object}
* - property `majorStep` {numeric}: space between major x-axis gridlines (measure of divergence)
* - property `minorStep` {numeric}: space between minor x-axis gridlines (measure of divergence)
*/
const calculateNumericGridSeparation = (range, minorTicks) => {
/* make an informed guess of the step size to start with.
E.g. 0.07 => step of 0.01, 70 => step size of 10 */
const logRange = Math.floor(Math.log10(range));
let majorStep = Math.pow(10, logRange);
if (range/majorStep < 2) { // if step > 0.5*range then make more fine-grained steps
majorStep /= 5;
} else if (range/majorStep <5) { // if step > 0.2*range then make more fine grained steps
majorStep /= 2;
}
let numMinorTicks = minorTicks;
if (majorStep===5 || majorStep===10) {
numMinorTicks = 5;
}
const minorStep = majorStep / numMinorTicks;
return {majorStep, minorStep};
};
/**
* Return `{majorGridPoints, minorGridPoints}` for numeric scales (e.g. divergence)
*/
const computeNumericGridPoints = (minVal, maxVal, layout, nMinorTicks, axis) => {
const majorGridPoints = [];
const minorGridPoints = [];
/* step is the amount (same units of minVal, maxVal) of separation between major grid lines */
const {majorStep, minorStep} = calculateNumericGridSeparation(maxVal-minVal, nMinorTicks);
const gridMin = Math.floor(minVal/majorStep)*majorStep;
const minVis = layout==="radial" ? minVal : gridMin;
const maxVis = maxVal;
for (let ii = 0; ii <= (maxVal - gridMin)/majorStep+3; ii++) {
const pos = gridMin + majorStep*ii;
majorGridPoints.push({
position: pos,
name: pos.toFixed(Math.max(0, -Math.floor(Math.log10(majorStep)))),
visibility: ((pos<minVis) || (pos>maxVis)) ? "hidden" : "visible",
axis
});
for (let minorPos=pos+minorStep; minorPos<(pos+majorStep) && minorPos<maxVal; minorPos+=minorStep) {
minorGridPoints.push({
position: minorPos,
visibility: ((minorPos<minVis) || (minorPos>maxVis+minorStep)) ? "hidden" : "visible",
axis
});
}
}
return {majorGridPoints, minorGridPoints};
};
/**
* Calculate the spacing between Major and Minor grid points. This is computed via a
* heuristic which takes into account (a) the available space (pixels) and (b) the
* time range to display.
* As major grid lines are (usually) labelled, we wish these to represent a consistent
* spacing of time, e.g. "3 months" or "7 years". Note that this means the actual time between
* grids may be very slightly different, as months, years etc can have different numbers of days.
* @param {numeric} timeRange numeric date range in current view (between right-most tip & left-most node)
* @param {numeric} pxAvailable number of pixels available
* @returns {object}
*/
const calculateTemporalGridSeperation = (timeRange, pxAvailable) => {
const [majorStep, minorStep] = [{unit: "DAY", n: 1}, {unit: "DAY", n: 0}];
const minPxBetweenMajorGrid = (pxAvailable < 1000 ? 130 : 180);
const timeBetweenMajorGrids = timeRange/(Math.floor(pxAvailable / minPxBetweenMajorGrid));
const levels = {
CENTURY: {t: 100, max: undefined},
DECADE: {t: 10, max: 5}, // i.e. spacing of 50 years is ok, but 60 jumps up to 100y spacing
FIVEYEAR: {t: 5, max: 1},
YEAR: {t: 1, max: 3}, // 4 year spacing not allowed (will use 5 year instead)
MONTH: {t: 1/12, max: 6}, // 7 month spacing not allowed
WEEK: {t: 1/52, max: 1}, // 2 week spacing not allowed - prefer months
DAY: {t: 1/365, max: 3}
};
const levelsKeys = Object.keys(levels);
/* calculate the best unit of time to fit into the allowed range */
majorStep.unit = "DAY"; // fallback value
for (let i=0; i<levelsKeys.length-1; i++) {
if (timeBetweenMajorGrids > levels[levelsKeys[i]].t) {
majorStep.unit = levelsKeys[i];
break;
}
}
/* how many of those "units" should ideally fit into each major grid separation? */
majorStep.n = Math.floor(timeBetweenMajorGrids/levels[majorStep.unit].t) || 1;
/* if the number of units (per major grid) is above the allowed max, use a bigger unit */
if (levels[majorStep.unit].max && majorStep.n > levels[majorStep.unit].max) {
majorStep.unit = levelsKeys[levelsKeys.indexOf(majorStep.unit)-1];
majorStep.n = Math.floor(timeBetweenMajorGrids/levels[majorStep.unit].t) || 1;
}
/* Calculate best unit of time for the minor grid spacing */
if (majorStep.n > 1 || majorStep.unit === "DAY") {
minorStep.unit = majorStep.unit;
} else {
minorStep.unit = levelsKeys[levelsKeys.indexOf(majorStep.unit)+1];
}
/* how many of those "units" should form the separation of the minor grids? */
const majorSpacing = majorStep.n * levels[majorStep.unit].t;
minorStep.n = Math.ceil(levels[minorStep.unit].t/majorSpacing);
return {majorStep, minorStep};
};
/**
* Compute the major & minor temporal grid points for display.
* @param {numeric} numDateMin numeric date of minimum value in view
* @param {numeric} numDateMax numeric date of maximum value in view
* @param {numeric} pxAvailable pixels in which to display the date range (xmin, xmax)
* @param {string} axis "x" or "y"
* @returns {Object} properties: `majorGridPoints`, `minorGridPoints`
*/
export const computeTemporalGridPoints = (numDateMin, numDateMax, pxAvailable, axis) => {
const [majorGridPoints, minorGridPoints] = [[], []];
const {majorStep, minorStep} = calculateTemporalGridSeperation(numDateMax-numDateMin, Math.abs(pxAvailable));
/* Major Grid Points */
const overallStopDate = getNextDate(majorStep.unit, numericToDateObject(numDateMax));
let proposedDate = getPreviousDate(majorStep.unit, numericToDateObject(numDateMin));
while (proposedDate < overallStopDate) {
majorGridPoints.push({
date: proposedDate,
position: calendarToNumeric(dateToString(proposedDate)),
name: prettifyDate(majorStep.unit, proposedDate),
visibility: 'visible',
axis
});
for (let i=0; i<majorStep.n; i++) {
proposedDate = getNextDate(majorStep.unit, proposedDate);
}
}
/* Minor Grid Points between each pair of major grid points */
if (minorStep.n) {
majorGridPoints.forEach((majorGridPoint, majorIdx) => {
proposedDate = getNextDate(minorStep.unit, majorGridPoint.date);
for (let i=0; i<minorStep.n-1; i++) {
proposedDate = getNextDate(minorStep.unit, proposedDate);
}
const stopDate = majorIdx===majorGridPoints.length-1 ? overallStopDate : majorGridPoints[majorIdx+1].date;
while (proposedDate < stopDate) {
minorGridPoints.push({
position: calendarToNumeric(dateToString(proposedDate)),
visibility: 'visible',
axis
});
for (let i=0; i<minorStep.n; i++) {
proposedDate = getNextDate(minorStep.unit, proposedDate);
}
}
});
}
return {majorGridPoints, minorGridPoints};
};
/**
* add a grid to the svg
* @param {layout}
*/
export const addGrid = function addGrid() {
const layout = this.layout;
addSVGGroupsIfNeeded(this.groups, this.svg);
if (layout==="unrooted") return;
timerStart("addGrid");
/* [xmin, xmax] is the domain of the x-axis (rectangular & clock layouts) or polar-axis (radial layouts)
[ymin, ymax] for rectangular layouts is [1, n] where n is the number of tips (in the view)
clock layouts is [min_divergence_in_view, max_divergence_in_view]
radial layouts is the radial domain (negative means "left of north") measured in radians */
const ymin = min(this.yScale.domain());
const ymax = max(this.yScale.domain());
const xmin = layout==="radial" ? this.nodes[0].depth : this.xScale.domain()[0];
const xmax = layout==="radial" ?
xmin + max([this.xScale.domain()[1], this.yScale.domain()[1], -this.xScale.domain()[0], -this.yScale.domain()[0]]) :
this.xScale.domain()[1];
const xAxisPixels = this.xScale.range()[1] - this.xScale.range()[0];
/* determine grid points (i.e. on the x/polar axis where lines/circles will be drawn through)
Major grid points are thicker and have text
Minor grid points have no text */
let xGridPoints;
if (
(this.layout==="scatter" && this.scatterVariables.x==="num_date") ||
this.layout==="clock" ||
(this.layout!=="scatter" && this.distance==="num_date")
) {
xGridPoints = computeTemporalGridPoints(xmin, xmax, xAxisPixels, "x");
} else if (this.layout==="scatter" && !this.scatterVariables.xContinuous) {
xGridPoints = {
majorGridPoints: this.xScale.domain().map((name) => ({
name, visibility: "visible", axis: "x", position: name
})),
minorGridPoints: []
};
} else {
xGridPoints = computeNumericGridPoints(xmin, xmax, layout, this.params.minorTicks, "x");
}
const {majorGridPoints, minorGridPoints} = xGridPoints;
/* HOF, which returns the fn which constructs the SVG path string
to draw the axis lines (circles for radial trees).
"gridPoint" is an element from majorGridPoints or minorGridPoints */
const gridline = (xScale, yScale, layoutShadow) => (gridPoint) => {
let svgPath="";
if (gridPoint.axis === "x") {
if (layoutShadow==="rect" || layoutShadow==="clock" || layoutShadow==="scatter") {
const xPos = xScale(gridPoint.position);
svgPath = 'M'+xPos.toString() +
" " +
yScale.range()[1].toString() +
" L " +
xPos.toString() +
" " +
yScale.range()[0].toString();
} else if (layoutShadow==="radial") {
const relativePosition = gridPoint.position - xmin;
const xPos = xScale(Math.max(relativePosition, 0));
svgPath = 'M '+xPos.toString() +
" " +
yScale(0).toString() +
" A " +
(xPos - xScale(0)).toString() +
" " +
(yScale(gridPoint.position) - yScale(xmin)).toString() +
" 0 1 0 " +
xPos.toString() +
" " +
(yScale(0)+0.001).toString();
}
} else if (gridPoint.axis === "y") {
const yPos = yScale(gridPoint.position);
svgPath = `M${xScale(xmin) + 20} ${yPos} L ${xScale(xmax)} ${yPos}`;
}
return svgPath;
};
/* add text labels to the major grid points */
/* HOF which returns a function which calculates the x position of text labels */
const xTextPos = (xScale, layoutShadow) => (gridPoint) => {
if (gridPoint.axis === "x") { // "normal" labels on the x-axis / polar-axis
return layoutShadow==="radial" ? xScale(0) : xScale(gridPoint.position);
}
// clock layout y positions (which display divergence)
return xScale.range()[0]-15;
};
/* same as xTextPos HOF, but for y-values */
const yTextPos = (yScale, layoutShadow) => (gridPoint) => {
if (gridPoint.axis === "x") {
const relativePosition = gridPoint.position - xmin;
return layoutShadow === "radial" ? yScale(Math.max(relativePosition, 0))-5 : yScale.range()[1] + 18;
}
return yScale(gridPoint.position);
};
/* HOF which returns a function which calculates the text anchor string */
const textAnchor = (layoutShadow) => (gridPoint) => {
if (gridPoint.axis === "x") {
return layoutShadow === "radial" ? "end" : "middle";
}
return "start";
};
/* for scatterplot-like layouts, add grid points for the y-axis (rendered as horizontal lines) */
if (this.layout==="clock" || this.layout==="scatter") {
if (this.layout==="scatter" && this.scatterVariables.y==="num_date") {
const yAxisPixels = this.yScale.range()[1] - this.yScale.range()[0];
const temporalGrid = computeTemporalGridPoints(ymin, ymax, yAxisPixels, "y");
majorGridPoints.push(...temporalGrid.majorGridPoints);
} else if (this.layout==="scatter" && !this.scatterVariables.yContinuous) {
majorGridPoints.push(...this.yScale.domain().map((name) => ({
name, visibility: "visible", axis: "y", position: name
})));
} else {
const numericGrid = computeNumericGridPoints(ymin, ymax, layout, 1, "y");
majorGridPoints.push(...numericGrid.majorGridPoints);
}
}
/* Add grid lines (horizontal & vertical) to the DOM + text for major lines
Note that the groups were created the first time this function was called */
// add major grid to svg
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.visibility)
.style("stroke", this.params.majorGridStroke)
.style("stroke-width", this.params.majorGridWidth);
// add minor grid to SVG
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.visibility)
.style("stroke", this.params.minorGridStroke)
.style("stroke-width", this.params.minorGridWidth);
/* draw the text labels for majorGridPoints */
this.groups.gridText.selectAll("*").remove();
this.svg.selectAll(".gridText").remove();
this.groups.gridText
.selectAll('.gridText')
.data(majorGridPoints)
.enter()
.append("text")
.text((d) => d.name)
.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.visibility)
.attr("x", xTextPos(this.xScale, layout))
.attr("y", yTextPos(this.yScale, layout));
/* add axis labels */
this.groups.axisText.selectAll("*").remove();
this.svg.selectAll(".axisText").remove();
let yAxisLabel, xAxisLabel; // not all views define axes labels. `undefined` => don't draw.
if (layout==="clock") {
xAxisLabel = "Date";
yAxisLabel = guessAreMutationsPerSite(this.yScale) ? "Divergence" : "Mutations";
} else if (layout==="scatter") {
xAxisLabel = this.scatterVariables.xLabel;
if (xAxisLabel==="div") xAxisLabel = guessAreMutationsPerSite(this.xScale) ? "Divergence" : "Mutations";
yAxisLabel = this.scatterVariables.yLabel;
if (yAxisLabel==="div") yAxisLabel = guessAreMutationsPerSite(this.yScale) ? "Divergence" : "Mutations";
} else if (layout==="rect") {
xAxisLabel = this.distance === "num_date" ? "Date" :
guessAreMutationsPerSite(this.xScale) ? "Divergence" : "Mutations";
}
if (xAxisLabel) {
this.groups.axisText
.append("text")
.text(xAxisLabel)
.attr("class", "gridText")
.style("font-size", this.params.tickLabelSize)
.style("font-family", this.params.fontFamily)
.style("fill", this.params.tickLabelFill)
.style("text-anchor", "middle")
.attr("x", Math.abs(this.xScale.range()[1]-this.xScale.range()[0]) / 2)
.attr("y", parseInt(this.svg.attr("height"), 10) - 1);
}
if (yAxisLabel) {
this.groups.axisText
.append("text")
.text(yAxisLabel)
.attr("class", "gridText")
.style("font-size", this.params.tickLabelSize)
.style("font-family", this.params.fontFamily)
.style("fill", this.params.tickLabelFill)
.style("text-anchor", "middle")
.attr('transform', 'translate(' + 10 + ',' + (this.yScale.range()[1] / 2) + ') rotate(-90)');
}
this.grid=true;
timerEnd("addGrid");
};
export const hideTemporalSlice = function hideTemporalSlice() {
this.groups.temporalWindowStart.attr('opacity', 0);
this.groups.temporalWindowEnd.attr('opacity', 0);
};
// d3-transition to ensure both rectangles move at the same rate
export const temporalWindowTransition = transition('temporalWindowTransition')
.duration(animationInterpolationDuration)
.ease(easeLinear); // the underlying animation uses linear interpolation, let's override the default easeCubic
/**
* add background grey rectangles to demarcate the temporal slice
*/
export const showTemporalSlice = function showTemporalSlice() {
if (this.layout !== "rect" || this.distance !== "num_date" || this.focus) {
this.hideTemporalSlice();
return;
}
const xWindow = [this.xScale(this.dateRange[0]), this.xScale(this.dateRange[1])];
const height = this.yScale.range()[1];
const fill = "#EEE"; // this.params.minorGridStroke
const minPxThreshold = 30;
const rightHandTree = this.params.orientation[0] === -1;
const rootXPos = this.xScale(this.nodes[0].x);
let totalWidth = rightHandTree ? this.xScale.range()[0] : this.xScale.range()[1];
totalWidth += (this.margins.left + this.margins.right);
/* the gray region between the root (ish) and the minimum date */
if (Math.abs(xWindow[0]-rootXPos) > minPxThreshold) { /* don't render anything less than this num of px */
let width_startRegion = xWindow[0];
let translateX_startRegion = 0;
// With right hand tree, the coordinate system flips (right to left)
if (rightHandTree) {
width_startRegion = totalWidth - xWindow[0];
translateX_startRegion = xWindow[0];
}
const wasStartRegionVisible = this.groups.temporalWindowStart.attr('opacity') === '1';
this.groups.temporalWindowStart
.attr('opacity', 1)
.attr("height", height)
.attr("transform", `translate(${translateX_startRegion},0)`)
.attr("fill", fill);
// Only apply animation if rectangle was already visible in the previous frame.
if (wasStartRegionVisible) {
this.groups.temporalWindowStart.transition('temporalWindowTransition')
.attr("width", width_startRegion);
} else {
this.groups.temporalWindowStart
.attr("width", width_startRegion);
}
} else {
this.groups.temporalWindowStart.attr('opacity', 0);
}
/* the gray region between the maximum selected date and the last tip */
let xStart_endRegion = xWindow[1]; // starting X coordinate of the "end" rectangle
let width_endRegion = totalWidth - this.margins.right - xWindow[1];
let transform_endRegion = `translate(${totalWidth - this.margins.right},0) scale(-1,1)`;
// With a right hand tree, the coordinate system flips (right to left)
if (rightHandTree) {
xStart_endRegion = this.margins.right;
width_endRegion = xWindow[1] - this.margins.right;
transform_endRegion = `translate(${xStart_endRegion},0)`;
}
if (width_endRegion > minPxThreshold) {
const wasEndRegionVisible = this.groups.temporalWindowEnd.attr('opacity') === '1';
this.groups.temporalWindowEnd
.attr('opacity', 1)
.attr("height", height)
.attr("fill", fill)
.attr("transform", transform_endRegion);
// Only apply animation if rectangle was already visible in the previous frame.
// Unlike the startingRegion, this panel cannot depend
// on letting the SVG boundaries clip part of the rectangle.
// As a result, we'll have to animate width instead of position
// If performance becomes an issue, try add a custom clip-path with
// a fixed-width region instead.
if (wasEndRegionVisible) {
this.groups.temporalWindowEnd
.transition('temporalWindowTransition')
.attr("width", width_endRegion);
} else {
this.groups.temporalWindowEnd
.attr("width", width_endRegion);
}
} else {
this.groups.temporalWindowEnd.attr('opacity', 0);
}
};