UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

531 lines (486 loc) 22 kB
/* 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); } };