UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

354 lines (323 loc) 11.9 kB
import _findIndex from "lodash/findIndex"; import _findLastIndex from "lodash/findLastIndex"; import _max from "lodash/max"; import { line, curveBasis, arc } from "d3-shape"; import { easeLinear } from "d3-ease"; import { demeCountMultiplier, demeCountMinimum } from "../../util/globals"; import { updateTipRadii } from "../../actions/tree"; /* util */ export const pathStringGenerator = line() .x((d) => { return d.x; }) .y((d) => { return d.y; }) .curve(curveBasis); const extractLineSegmentForAnimationEffect = ( numDateMin, numDateMax, originCoords, destinationCoords, originNumDate, destinationNumDate, visible, bezierCurve, bezierDates ) => { if (visible === "hidden") { return []; } // want to slice out all points that lie between numDateMin and numDateMax // and append interpolated start and end points // initial data // bezierDates = [ 2015.1 2015.2 2015.3 2015.4 2015.5 ] // bezierCurve = [ x0,y0 x1,y1 x2,y2 x3,y3 x4,y4 ] // // scenario A: numDateMin = 2015.25, numDateMax = 2015.45 // bezierDates = [ 2015.25 2015.3 2015.4 2015.45 ] // bezierCurve = [ x12,y12 x2,y2 x3,y3 x34,y34 ] // startIndex is 2 in scenario A // endIndex is 3 in scenario A // // scenario B: numDateMin = 2014.5, numDateMax = 2015.45 // bezierDates = [ 2015.1 2015.2 2015.3 2015.4 2015.45 ] // bezierCurve = [ x0,y0 x1,y1 x2,y2 x3,y3 x34,y34 ] // startIndex is 0 in scenario B // endIndex is 3 in scenario B // // scenario C: numDateMin = 2015.25, numDateMax = 2015.7 // bezierDates = [ 2015.25 2015.3 2015.4 2015.5 ] // bezierCurve = [ x12,y12 x2,y2 x3,y3 x4,y4 ] // startIndex is 2 in scenario C // endIndex is 4 in scenario C // // scenario D: numDateMin = 2015.6, numDateMax = 2015.9 // bezierDates = [ ] // bezierCurve = [ ] // startIndex is -1 in scenario D // endIndex is 4 in scenario D // find start walking forwards from left of array const startIndex = _findIndex(bezierDates, (d) => { return d > numDateMin; }); // find end walking backwards from right of array const endIndex = _findLastIndex(bezierDates, (d) => { return d < numDateMax; }); // startIndex and endIndex is -1 if not found // this indicates a slice of time that lies outside the bounds of BCurve // return empty array if (startIndex === -1 || endIndex === -1) { return []; } // get curve // slice takes index at begin // slice extracts up to but not including end const curve = bezierCurve.slice(startIndex, endIndex + 1); // if possible construct and prepend interpolated start let newStart; if (startIndex > 0) { // determine weighting of positions at startIndex and startIndex-1 const dateDiff = bezierDates[startIndex] - bezierDates[startIndex - 1]; const weightRight = (numDateMin - bezierDates[startIndex - 1]) / dateDiff; const weightLeft = (bezierDates[startIndex] - numDateMin) / dateDiff; // construct interpolated new start newStart = { x: (weightLeft * bezierCurve[startIndex - 1].x) + (weightRight * bezierCurve[startIndex].x), y: (weightLeft * bezierCurve[startIndex - 1].y) + (weightRight * bezierCurve[startIndex].y) }; // will break indexing, so wait to prepend } // if possible construct and prepend interpolated start let newEnd; if (endIndex < bezierCurve.length - 1) { // determine weighting of positions at startIndex and startIndex-1 const dateDiff = bezierDates[endIndex + 1] - bezierDates[endIndex]; const weightRight = (numDateMax - bezierDates[endIndex]) / dateDiff; const weightLeft = (bezierDates[endIndex + 1] - numDateMax) / dateDiff; // construct interpolated new end newEnd = { x: (weightLeft * bezierCurve[endIndex].x) + (weightRight * bezierCurve[endIndex + 1].x), y: (weightLeft * bezierCurve[endIndex].y) + (weightRight * bezierCurve[endIndex + 1].y) }; // will break indexing, so wait to append } // prepend / append interpolated points if they exist if (newStart) { curve.unshift(newStart); } if (newEnd) { curve.push(newEnd); } return curve; }; const createArcsFromDemes = (demeData) => { const individualArcs = []; demeData.forEach((demeInfo) => { demeInfo.arcs.forEach((slice) => { individualArcs.push(slice); }); }); return individualArcs; }; export const drawDemesAndTransmissions = ( demeData, transmissionData, g, map, nodes, numDateMin, numDateMax, pieChart, /* bool */ geoResolution, dispatch ) => { // add transmission lines const transmissions = g.selectAll("transmissions") .data(transmissionData) .enter() .append("path") /* instead of appending a geodesic path from the leaflet plugin data, we now draw a line directly between two points */ .attr("d", (d) => { return pathStringGenerator( extractLineSegmentForAnimationEffect( numDateMin, numDateMax, d.originCoords, d.destinationCoords, d.originNumDate, d.destinationNumDate, d.visible, d.bezierCurve, d.bezierDates ) ); }) .attr("fill", "none") .attr("stroke-opacity", 0.6) .attr("stroke-linecap", "round") .attr("stroke", (d) => { return d.color; }) .attr("stroke-width", 1); const visibleTips = nodes[0].tipCount; const demeMultiplier = demeCountMultiplier / Math.sqrt(_max([Math.sqrt(visibleTips * nodes.length), demeCountMinimum])); let demes; // determine whether to draw pieChart or not (sensible for categorical data) if (pieChart) { /* each datapoint in `demeData` contains `arcs` which comprise n individual "slices" * we need to create an array of all of these slices for d3 to render */ const individualArcs = createArcsFromDemes(demeData); /* add `outerRadius` to all slices */ // TODO - move this to initial arc creation in setupDemeData as it's only ever done once individualArcs.forEach((a) => { a.outerRadius = Math.sqrt(demeData[a.demeDataIdx].count)*demeMultiplier; }); demes = g.selectAll('demes') // add individual arcs ("slices") to this selection .data(individualArcs) .enter().append("path") .attr("d", (d) => arc()(d)) /* following calls are (almost) the same for pie charts & circles */ .style("stroke", "none") .style("fill-opacity", 0.65) .style("fill", (d) => { return d.color; }) .style("stroke-opacity", 0.85) .style("stroke", (d) => { return d.color; }) .style("pointer-events", "all") .attr("transform", (d) => "translate(" + demeData[d.demeDataIdx].coords.x + "," + demeData[d.demeDataIdx].coords.y + ")" ) .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, demeData[d.demeDataIdx].name]})); }) .on("mouseout", () => { dispatch(updateTipRadii()); }); } else { demes = g.selectAll("demes") // add deme circles to this selection .data(demeData) .enter().append("circle") .attr("r", (d) => { return demeMultiplier * Math.sqrt(d.count); }) /* following calls are (almost) the same for pie charts & circles */ .style("stroke", "none") .style("fill-opacity", 0.65) .style("fill", (d) => { return d.color; }) .style("stroke-opacity", 0.85) .style("stroke", (d) => { return d.color; }) .style("pointer-events", "all") .attr("transform", (d) => "translate(" + d.coords.x + "," + d.coords.y + ")") .on("mouseover", (d) => { dispatch(updateTipRadii({geoFilter: [geoResolution, d.name]})); }) .on("mouseout", () => { dispatch(updateTipRadii()); }); } return { demes, transmissions }; }; export const updateOnMoveEnd = (demeData, transmissionData, d3elems, numDateMin, numDateMax, pieChart) => { /* map has moved or rescaled, make demes and transmissions line up */ if (!d3elems) { return; } /* move the pie charts differently to the color-blended circles */ if (pieChart) { const individualArcs = createArcsFromDemes(demeData); d3elems.demes .data(individualArcs) .attr("transform", (d) => /* copied from above. TODO. */ "translate(" + demeData[d.demeDataIdx].coords.x + "," + demeData[d.demeDataIdx].coords.y + ")" ); } else { d3elems.demes .data(demeData) .attr("transform", (d) => /* copied from above. TODO. */ "translate(" + d.coords.x + "," + d.coords.y + ")" ); } d3elems.transmissions .data(transmissionData) .attr("d", (d) => { return pathStringGenerator( extractLineSegmentForAnimationEffect( numDateMin, numDateMax, d.originCoords, d.destinationCoords, d.originNumDate, d.destinationNumDate, d.visible, d.bezierCurve, d.bezierDates ) ); }); // other attrs remain the same as they were }; export const updateVisibility = ( demeData, transmissionData, d3elems, map, nodes, numDateMin, numDateMax, pieChart ) => { if (!d3elems) { console.error("d3elems is not defined!"); return; } const visibleTips = nodes[0].tipCount; const demeMultiplier = demeCountMultiplier / Math.sqrt(_max([Math.sqrt(visibleTips * nodes.length), demeCountMinimum])); if (pieChart) { const individualArcs = createArcsFromDemes(demeData); /* add `outerRadius` to all slices */ // TODO - move this to initial arc creation in setupDemeData as it's only ever done once individualArcs.forEach((a) => { a.outerRadius = Math.sqrt(demeData[a.demeDataIdx].count)*demeMultiplier; }); d3elems.demes .data(individualArcs) .attr("d", (d) => arc()(d)) .style("fill", (d) => { return d.color; }) .style("stroke", (d) => { return d.color; }); } else { /* for colour blended circles we just have to update the colours & size (radius) */ d3elems.demes .data(demeData) .transition() .duration(200) .ease(easeLinear) .style("stroke", (d) => { return d.count > 0 ? d.color : "white"; }) .style("fill", (d) => { return d.count > 0 ? d.color : "white"; }) .attr("r", (d) => { return demeMultiplier * Math.sqrt(d.count); }); } /* update the path and stroke colour of transmission lines */ d3elems.transmissions .data(transmissionData) .attr("d", (d) => { return pathStringGenerator( extractLineSegmentForAnimationEffect( numDateMin, numDateMax, d.originCoords, d.destinationCoords, d.originNumDate, d.destinationNumDate, d.visible, d.bezierCurve, d.bezierDates ) ); }) /* with the interpolation in the function above pathStringGenerator */ .attr("stroke", (d) => { return d.color; }); }; /* template for an update helper */ export const updateFoo = (d3elems, latLongs) => { d3elems.demes .data(latLongs.demes); d3elems.transmissions .data(latLongs.transmissions); }; /* http://gis.stackexchange.com/questions/49114/d3-geo-path-to-draw-a-path-from-gis-coordinates https://bl.ocks.org/mbostock/3916621 // Compute point-interpolators at each distance. http://bl.ocks.org/mbostock/5851933 // draw line on map https://gist.github.com/mikeatlas/0b69b354a8d713989147 // polyline split if we don't use leaflet http://bl.ocks.org/mbostock/5928813 http://bl.ocks.org/duopixel/4063326 // animate path in https://bl.ocks.org/mbostock/1705868 // point along path interpolation https://bl.ocks.org/mbostock/1313857 // point along path interpolation https://github.com/d3/d3-shape/blob/master/README.md#curves https://github.com/d3/d3-shape/blob/master/README.md#lines */