UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

327 lines (287 loc) 11.2 kB
import { select, mouse } from "d3-selection"; import 'd3-transition'; import { scaleLinear } from "d3-scale"; import { axisBottom, axisLeft } from "d3-axis"; import { rgb } from "d3-color"; import { area } from "d3-shape"; import { format } from "d3-format"; import { dataFont } from "../../globalStyles"; import { unassigned_label } from "../../util/processFrequencies"; import { isColorByGenotype, decodeColorByGenotype } from "../../util/getGenotype"; /* C O N S T A N T S */ const opacity = 0.85; export const areListsEqual = (a, b) => { if (a.length !== b.length) return false; return !a.filter((el, idx) => el !== b[idx]).length; }; export const parseColorBy = (colorBy, colorOptions) => { if (colorOptions && colorOptions[colorBy]) { return colorOptions[colorBy].title; } else if (isColorByGenotype(colorBy)) { const genotype = decodeColorByGenotype(colorBy); return genotype.aa ? `Genotype at ${genotype.gene} pos ${genotype.positions.join(", ")}` : `Genotype at Nuc. ${genotype.positions.join(", ")}`; } return colorBy; }; const getOrderedCategories = (matrixCategories, colorScale) => { /* get the colorBy's in the same order as in the tree legend */ const orderedCategories = colorScale.legendValues .filter((d) => d !== undefined) .reverse() .map((v) => v.toString()); /* remove categories that (for whatever reason) are in the legend but aren't in the matrix */ for (let i = orderedCategories.length - 1; i >= 0; --i) { if (matrixCategories.indexOf(orderedCategories[i]) === -1) { orderedCategories.splice(i, 1); } } /* add in categories that (for whatever reason) aren't in the legend */ if (matrixCategories.length > orderedCategories.length) { matrixCategories.forEach((v) => { if (orderedCategories.indexOf(v) === -1) { orderedCategories.push(v); } }); } return orderedCategories; }; export const calcXScale = (chartGeom, pivots, ticks) => { const x = scaleLinear() .domain([pivots[0], pivots[pivots.length - 1]]) .range([chartGeom.spaceLeft, chartGeom.width - chartGeom.spaceRight]); return {x, numTicksX: ticks.length}; }; export const calcYScale = (chartGeom, maxY) => { const y = scaleLinear() .domain([0, maxY]) .range([chartGeom.height - chartGeom.spaceBottom, chartGeom.spaceTop]); return {y, numTicksY: 5}; }; const removeXAxis = (svg) => { svg.selectAll(".x.axis").remove(); }; const removeYAxis = (svg) => { svg.selectAll(".y.axis").remove(); }; const removeProjectionInfo = (svg) => { svg.selectAll(".projection-pivot").remove(); svg.selectAll(".projection-text").remove(); }; export const drawXAxis = (svg, chartGeom, scales) => { removeXAxis(svg); svg.append("g") .attr("class", "x axis") .attr("transform", `translate(0,${chartGeom.height - chartGeom.spaceBottom})`) .style("font-family", dataFont) .style("font-size", "12px") .call(axisBottom(scales.x).ticks(scales.numTicksX, ".1f")); }; export const drawYAxis = (svg, chartGeom, scales) => { removeYAxis(svg); const formatPercent = format(".0%"); svg.append("g") .attr("class", "y axis") .attr("transform", `translate(${chartGeom.spaceLeft},0)`) .style("font-family", dataFont) .style("font-size", "12px") .call(axisLeft(scales.y).ticks(scales.numTicksY).tickFormat(formatPercent)); }; export const drawProjectionInfo = (svg, scales, projection_pivot) => { if (projection_pivot) { removeProjectionInfo(svg); svg.append("g") .attr("class", "projection-pivot") .append("line") .attr("x1", scales.x(parseFloat(projection_pivot))) .attr("x2", scales.x(parseFloat(projection_pivot))) .attr("y1", scales.y(1)) .attr("y2", scales.y(0)) .style("visibility", "visible") .style("stroke", "rgba(55,55,55,0.9)") .style("stroke-width", "2") .style("stroke-dasharray", "4 4"); const midPoint = 0.5 * (scales.x(parseFloat(projection_pivot)) + scales.x.range()[1]); svg.append("g") .attr("class", "projection-text") .append("text") .attr("x", midPoint) .attr("y", scales.y(1) - 3) .style("pointer-events", "none") .style("fill", "#555") .style("font-family", dataFont) .style("font-size", 12) .style("alignment-baseline", "bottom") .style("text-anchor", "middle") .text("Projection"); } }; const turnMatrixIntoSeries = (categories, nPivots, matrix) => { /* WHAT IS A SERIES? this is the data structure demanded by d3 for a stream graph. it is often produced by the d3.stack function - see https://github.com/d3/d3-shape/blob/master/README.md#_stack but it's faster to create this ourselves. THIS IS THE STRUCTURE: [x1, x2, ... xn] where n is the number of categories xi = [y1, y2, ..., ym] where m is the number of pivots yi = [z1, z2]: the (y0, y1) values of the categorie at that pivot point. TO DO: this should / could be in the reducer. But what if we want to re-order things?!?! */ const series = []; for (let i = 0; i < categories.length; i++) { const x = []; for (let j = 0; j < nPivots; j++) { if (i === 0) { x.push([0, matrix[categories[i]][j]]); } else { const prevY1 = series[i - 1][j][1]; x.push([prevY1, matrix[categories[i]][j] + prevY1]); } } series.push(x); } return series; }; const getMeaningfulLabels = (categories, colorScale) => { if (colorScale.continuous) { return categories.map((name) => name === unassigned_label ? unassigned_label : `${colorScale.legendBounds[name][0].toFixed(2)} - ${colorScale.legendBounds[name][1].toFixed(2)}` ); } return categories.slice(); }; export const removeStream = (svg) => { svg.selectAll("path").remove(); svg.selectAll("line").remove(); svg.selectAll("text").remove(); }; const generateColorScaleD3 = (categories, colorScale) => (d, i) => categories[i] === unassigned_label ? "rgb(190, 190, 190)" : rgb(colorScale.scale(categories[i])).toString(); function handleMouseOver() { select(this).attr("opacity", 1); } function handleMouseOut() { select(this).attr("opacity", opacity); select("#freqinfo").style("visibility", "hidden"); select("#vline").style("visibility", "hidden"); } /* returns [[xval, yval], [xval, yval], ...] order: that of {series} */ const calcBestXYPositionsForLabels = (series, pivots, scales, lookahead) => series.map((d) => { const maxY = scales.y.domain()[1]; const displayThresh = 0.15 * maxY; for (let pivotIdx = 0; pivotIdx < d.length - lookahead; pivotIdx++) { const nextIdx = pivotIdx + lookahead; if (d[pivotIdx][1] - d[pivotIdx][0] > displayThresh && d[nextIdx][1] - d[nextIdx][0] > displayThresh) { return [ scales.x(pivots[pivotIdx + 1]), (scales.y((d[pivotIdx][1] + d[pivotIdx][0]) / 2) + scales.y((d[nextIdx][1] + d[nextIdx][0]) / 2)) / 2 ]; } } return [undefined, undefined]; /* don't display text! */ }); const drawLabelsOverStream = (svgStreamGroup, series, pivots, labels, scales) => { const xyPos = calcBestXYPositionsForLabels(series, pivots, scales, 3); svgStreamGroup.selectAll(".streamLabels") .data(labels) .enter() .append("text") .attr("x", (d, i) => xyPos[i][0]) .attr("y", (d, i) => xyPos[i][1]) .style("pointer-events", "none") .style("fill", "white") .style("font-family", dataFont) .style("font-size", 14) .style("alignment-baseline", "middle") .text((d, i) => xyPos[i][0] ? d : ""); }; const calcMaxYValue = (series) => { return series[series.length - 1].reduce((curMax, el) => Math.max(curMax, el[1]), 0); }; export const processMatrix = ({matrix, pivots, colorScale}) => { const categories = getOrderedCategories(Object.keys(matrix), colorScale); const series = turnMatrixIntoSeries(categories, pivots.length, matrix); const maxY = calcMaxYValue(series); return {categories, series, maxY}; }; export const drawStream = ( svgStreamGroup, scales, {categories, series}, {colorBy, colorScale, colorOptions, pivots, projection_pivot} ) => { removeStream(svgStreamGroup); const colourer = generateColorScaleD3(categories, colorScale); const labels = getMeaningfulLabels(categories, colorScale); /* https://github.com/d3/d3-shape/blob/master/README.md#areas */ const areaObj = area() .x((d, i) => scales.x(pivots[i])) .y0((d) => scales.y(d[0])) .y1((d) => scales.y(d[1])); /* define handleMouseMove inside drawStream so it can access the provided arguments */ function handleMouseMove(d, i) { const [mousex] = mouse(this); // [x, y] x starts from left, y starts from top /* what's the closest pivot? */ const date = scales.x.invert(mousex); const pivotIdx = pivots.reduce((closestIdx, val, idx, arr) => Math.abs(val - date) < Math.abs(arr[closestIdx] - date) ? idx : closestIdx, 0); const freqVal = Math.round((d[pivotIdx][1] - d[pivotIdx][0]) * 100) + "%"; const xValueOfPivot = scales.x(pivots[pivotIdx]); const y1ValueOfPivot = scales.y(d[pivotIdx][1]); const y2ValueOfPivot = scales.y(d[pivotIdx][0]); select("#vline") .style("visibility", "visible") .attr("x1", xValueOfPivot) .attr("x2", xValueOfPivot) .attr("y1", y1ValueOfPivot) .attr("y2", y2ValueOfPivot); const left = xValueOfPivot > 0.5 * scales.x.range()[1] ? "" : `${xValueOfPivot + 25}px`; const right = xValueOfPivot > 0.5 * scales.x.range()[1] ? `${scales.x.range()[1] - xValueOfPivot + 25}px` : ""; const top = y1ValueOfPivot > 0.5 * scales.y(0) ? `${scales.y(0) - 50}px` : `${y1ValueOfPivot + 25}px`; let frequencyText = "Frequency"; if (projection_pivot) { if (pivots[pivotIdx] > projection_pivot) { frequencyText = "Projected frequency"; } } select("#freqinfo") .style("left", left) .style("right", right) .style("top", top) .style("padding-left", "10px") .style("padding-right", "10px") .style("padding-top", "0px") .style("padding-bottom", "0px") .style("visibility", "visible") .style("background-color", "rgba(55,55,55,0.9)") .style("color", "white") .style("font-family", dataFont) .style("font-size", 18) .style("line-height", 1) .style("font-weight", 300) .html( `<p>${parseColorBy(colorBy, colorOptions)}: ${labels[i]}</p> <p>Time point: ${pivots[pivotIdx]}</p> <p>${frequencyText}: ${freqVal}</p>` ); } /* the streams */ svgStreamGroup.selectAll(".stream") .data(series) .enter() .append("path") .attr("d", areaObj) .attr("fill", colourer) .attr("opacity", opacity) .on("mouseover", handleMouseOver) .on("mouseout", handleMouseOut) .on("mousemove", handleMouseMove); /* the vertical line to indicate the highlighted frequency interval */ svgStreamGroup.append("line") .attr("id", "vline") .style("visibility", "hidden") .style("pointer-events", "none") .style("stroke", "rgba(55,55,55,0.9)") .style("stroke-width", 4); drawLabelsOverStream(svgStreamGroup, series, pivots, labels, scales); };