auspice
Version:
Web app for visualizing pathogen evolution
557 lines (492 loc) • 18.9 kB
JavaScript
/* eslint-disable no-loop-func */
import _map from "lodash/map";
import _minBy from "lodash/minBy";
import { interpolateNumber } from "d3-interpolate";
import { getAverageColorFromNodes } from "../../util/colorHelpers";
import { bezier } from "./transmissionBezier";
import { NODE_NOT_VISIBLE } from "../../util/globals";
import { getTraitFromNode } from "../../util/treeMiscHelpers";
import { isColorByGenotype } from "../../util/getGenotype";
import { errorNotification } from "../../actions/notifications";
/* global L */
// L is global in scope and placed by leaflet()
// longs of original map are -180 to 180
// longs of fully triplicated map are -540 to 540
// restrict to longs between -360 to 360
const westBound = -360;
const eastBound = 360;
// interchange. this is a leaflet method that will tell d3 where to draw.
const leafletLatLongToLayerPoint = (lat, long, map) => {
return map.latLngToLayerPoint(new L.LatLng(lat, long));
};
/* if transmission pair is legal, return a leaflet LatLng origin / dest pair
otherwise return null */
const maybeGetTransmissionPair = (latOrig, longOrig, latDest, longDest, map) => {
// if either origin or destination are inside bounds, include
// transmission must be less than 180 lat difference
let pair = null;
if (
(longOrig > westBound || longDest > westBound) &&
(longOrig < eastBound || longDest < eastBound) &&
(Math.abs(longOrig - longDest) < 180)
) {
pair = [
leafletLatLongToLayerPoint(latOrig, longOrig, map),
leafletLatLongToLayerPoint(latDest, longDest, map)
];
}
return pair;
};
/**
* Traverses the tips of the tree to create a dict of
* location(deme) -> list of visible tips at that location
*/
const getVisibleNodesPerLocation = (nodes, visibility, geoResolution) => {
const locationToVisibleNodes = {};
nodes.forEach((n, i) => {
if (n.children) return; /* only consider terminal nodes */
const location = getTraitFromNode(n, geoResolution);
if (!location) return; /* ignore undefined locations */
if (!locationToVisibleNodes[location]) locationToVisibleNodes[location]=[];
if (visibility[i] !== NODE_NOT_VISIBLE) {
locationToVisibleNodes[location].push(n);
}
});
return locationToVisibleNodes;
};
/**
* Either create arcs for the current `visibleNodes`, in the order of the legendValues, or update the arcs
* (by updating we mean changing the arc start/end angles)
* @param {array} visibleNodes visible nodes for this pie chart
* @param {array} legendValues for this colorBy
* @param {string} colorBy current color by
* @param {array} nodeColors Array of colors for all nodes (not in correspondence with `visibleNodes`)
* @param {array} currentArcs only used if updating. Array of current arcs.
* @returns {array} arcs for display
*/
const createOrUpdateArcs = (visibleNodes, legendValues, colorBy, nodeColors, currentArcs=undefined) => {
const colorByIsGenotype = isColorByGenotype(colorBy);
const legendValueToArcIdx = {};
const undefinedArcIdx = legendValues.length; /* the arc which is grey to represent undefined values on tips */
let arcs;
if (currentArcs) {
/* updating arcs -- reset `_count` */
arcs = currentArcs;
legendValues.forEach((v, i) => {
legendValueToArcIdx[v] = i;
arcs[i]._count = 0;
});
arcs[undefinedArcIdx]._count = 0;
} else {
/* creating arcs */
arcs = legendValues.map((v, i) => {
legendValueToArcIdx[v] = i;
return {innerRadius: 0, _count: 0};
});
arcs.push({innerRadius: 0, _count: 0}); // for the undefined arc
}
/* traverse visible nodes (for this location) to get numbers for each arc (i.e. each slice in the pie) */
visibleNodes.forEach((n) => {
const colorByValue = colorByIsGenotype ? n.currentGt: getTraitFromNode(n, colorBy);
let arcIdx = legendValueToArcIdx[colorByValue];
if (arcIdx === undefined) arcIdx = undefinedArcIdx;
arcs[arcIdx]._count++;
if (!arcs[arcIdx].color) arcs[arcIdx].color=nodeColors[n.arrayIdx];
});
/* turn counts into arc angles (radians) */
let startAngle = 0;
arcs.forEach((a) => {
a.startAngle = startAngle;
startAngle += 2*Math.PI*a._count/visibleNodes.length;
a.endAngle = startAngle;
if (a.startAngle === a.endAngle) {
// this prevents drawing a 'line' for 'empty' slices
a.color = "";
}
});
return arcs;
};
const setupDemeData = (nodes, visibility, geoResolution, nodeColors, triplicate, metadata, map, pieChart, legendValues, colorBy) => {
const demeData = []; /* deme array */
const demeIndices = {}; /* map of name to indices in array */
const locationToVisibleNodes = getVisibleNodesPerLocation(nodes, visibility, geoResolution);
const offsets = triplicate ? [-360, 0, 360] : [0];
const demeToLatLongs = metadata.geoResolutions.filter((x) => x.key === geoResolution)[0].demes;
let index = 0;
offsets.forEach((OFFSET) => {
/* count DEMES */
for (const [location, visibleNodes] of Object.entries(locationToVisibleNodes)) {
let lat = 0;
let long = 0;
let goodDeme = true;
if (demeToLatLongs[location]) {
lat = demeToLatLongs[location].latitude;
long = demeToLatLongs[location].longitude + OFFSET;
} else {
goodDeme = false;
console.warn("Warning: Lat/long missing from metadata for", location);
}
/* get pixel coordinates. `coords`: <Point> with properties `x` & `y` */
const coords = leafletLatLongToLayerPoint(lat, long, map);
/* add entries to
* (1) `demeIndicies` -- a dict of "deme value" to the indicies of `demeData` & `arcData` where they appear
* (2) `demeData` -- an array of objects, each with {name, count etc.}
* if pie charts, then `demeData.arcs` exists, if colour-blended circles, `demeData.color` exists
*/
if (long > westBound && long < eastBound && goodDeme === true) {
/* base deme information used for pie charts & color-blended circles */
const deme = {
name: location,
count: visibleNodes.length,
latitude: lat, // raw latitude value
longitude: long, // raw longitude value
coords: coords // coords are x,y plotted via d3
};
if (pieChart) {
/* create the arcs for the pie chart. NB `demeDataIdx` is the index of the deme in `demeData` where this will be inserted */
deme.arcs = createOrUpdateArcs(visibleNodes, legendValues, colorBy, nodeColors);
/* create back links between the arcs & which index of `demeData` they (will be) stored at */
const demeDataIdx = demeData.length;
deme.arcs.forEach((a) => {a.demeDataIdx = demeDataIdx;});
} else {
/* average out the constituent colours for a blended-colour circle */
deme.color = getAverageColorFromNodes(visibleNodes, nodeColors);
}
demeData.push(deme);
if (!demeIndices[location]) {
demeIndices[location] = [index];
} else {
demeIndices[location].push(index);
}
index += 1;
}
}
});
return {
demeData: demeData,
demeIndices: demeIndices
};
};
const constructBcurve = (
originLatLongPair,
destinationLatLongPair,
extend
) => {
return bezier(originLatLongPair, destinationLatLongPair, extend);
};
const maybeConstructTransmissionEvent = (
node,
child,
geoResolutions,
geoResolution,
nodeColors,
visibility,
map,
offsetOrig,
offsetDest,
demesMissingLatLongs,
extend
) => {
let latOrig, longOrig, latDest, longDest;
let transmission;
/* checking metadata for lat longs name match - ie., does the metadata list a latlong for Thailand? */
const nodeLocation = getTraitFromNode(node, geoResolution); // we're looking this up in the metadata lookup table
const childLocation = getTraitFromNode(child, geoResolution);
const demeToLatLongs = geoResolutions.filter((x) => x.key === geoResolution)[0].demes;
try {
latOrig = demeToLatLongs[nodeLocation].latitude;
longOrig = demeToLatLongs[nodeLocation].longitude;
} catch (e) {
demesMissingLatLongs.add(nodeLocation);
}
try {
latDest = demeToLatLongs[childLocation].latitude;
longDest = demeToLatLongs[childLocation].longitude;
} catch (e) {
demesMissingLatLongs.add(childLocation);
}
const validLatLongPair = maybeGetTransmissionPair(
latOrig,
longOrig + offsetOrig,
latDest,
longDest + offsetDest,
map
);
if (validLatLongPair) {
const Bcurve = constructBcurve(validLatLongPair[0], validLatLongPair[1], extend);
/* set up interpolator with origin and destination numdates */
const interpolator = interpolateNumber(getTraitFromNode(node, "num_date"), getTraitFromNode(child, "num_date"));
/* make a Bdates array as long as Bcurve */
const Bdates = [];
Bcurve.forEach((d, i) => {
/* fill it with interpolated dates */
Bdates.push(
interpolator(i / (Bcurve.length - 1)) /* ie., 5 / 15ths of the way through = 2016.3243 */
);
});
/* build up transmissions object */
transmission = {
id: node.arrayIdx.toString() + "-" + child.arrayIdx.toString(),
originNode: node,
destinationNode: child,
bezierCurve: Bcurve,
bezierDates: Bdates,
originName: getTraitFromNode(node, geoResolution),
destinationName: getTraitFromNode(child, geoResolution),
originCoords: validLatLongPair[0], // after interchange
destinationCoords: validLatLongPair[1], // after interchange
originLatitude: latOrig, // raw latitude value
destinationLatitude: latDest, // raw latitude value
originLongitude: longOrig + offsetOrig, // raw longitude value
destinationLongitude: longDest + offsetDest, // raw longitude value
originNumDate: getTraitFromNode(node, "num_date"),
destinationNumDate: getTraitFromNode(child, "num_date"),
color: nodeColors[node.arrayIdx],
visible: visibility[child.arrayIdx] !== NODE_NOT_VISIBLE ? "visible" : "hidden", // transmission visible if child is visible
extend: extend
};
}
return transmission;
};
const maybeGetClosestTransmissionEvent = (
node,
child,
metadataGeoLookupTable,
geoResolution,
nodeColors,
visibility,
map,
offsetOrig,
demesMissingLatLongs,
extend
) => {
const possibleEvents = [];
// iterate over offsets applied to transmission destination
// even if map is not tripled - ie., don't let a line go across the whole world
[-360, 0, 360].forEach((offsetDest) => {
const t = maybeConstructTransmissionEvent(
node,
child,
metadataGeoLookupTable,
geoResolution,
nodeColors,
visibility,
map,
offsetOrig,
offsetDest,
demesMissingLatLongs,
extend
);
if (t) { possibleEvents.push(t); }
});
if (possibleEvents.length > 0) {
const closestEvent = _minBy(possibleEvents, (event) => {
return Math.abs(event.destinationCoords.x - event.originCoords.x);
});
return closestEvent;
}
return null;
};
const setupTransmissionData = (
nodes,
visibility,
geoResolution,
nodeColors,
triplicate,
metadata,
map
) => {
const offsets = triplicate ? [-360, 0, 360] : [0];
const transmissionData = []; /* edges, animation paths */
const transmissionIndices = {}; /* map of transmission id to array of indices */
const demesMissingLatLongs = new Set();
const demeToDemeCounts = {};
nodes.forEach((n) => {
const nodeDeme = getTraitFromNode(n, geoResolution);
if (n.children) {
n.children.forEach((child) => {
const childDeme = getTraitFromNode(child, geoResolution);
if (nodeDeme && childDeme && nodeDeme !== childDeme) {
// record transmission event
if ([nodeDeme, childDeme] in demeToDemeCounts) {
demeToDemeCounts[[nodeDeme, childDeme]] += 1;
} else {
demeToDemeCounts[[nodeDeme, childDeme]] = 1;
}
const extend = demeToDemeCounts[[nodeDeme, childDeme]];
// offset is applied to transmission origin
offsets.forEach((offsetOrig) => {
const t = maybeGetClosestTransmissionEvent(
n,
child,
metadata.geoResolutions,
geoResolution,
nodeColors,
visibility,
map,
offsetOrig,
demesMissingLatLongs,
extend
);
if (t) { transmissionData.push(t); }
});
}
});
}
});
transmissionData.forEach((transmission, index) => {
if (!transmissionIndices[transmission.id]) {
transmissionIndices[transmission.id] = [index];
} else {
transmissionIndices[transmission.id].push(index);
}
});
return {
transmissionData: transmissionData,
transmissionIndices: transmissionIndices,
demesMissingLatLongs
};
};
export const createDemeAndTransmissionData = (
nodes,
visibility,
geoResolution,
nodeColors,
triplicate,
metadata,
map,
pieChart,
legendValues,
colorBy,
dispatch
) => {
/*
walk through nodes and collect all data
for demeData we have:
name, coords, count, color
for transmissionData we have:
originNode, destinationNode, originCoords, destinationCoords, originName, destinationName
originNumDate, destinationNumDate, color, visible
*/
const {
demeData,
demeIndices
} = setupDemeData(nodes, visibility, geoResolution, nodeColors, triplicate, metadata, map, pieChart, legendValues, colorBy);
/* second time so that we can get Bezier */
const { transmissionData, transmissionIndices, demesMissingLatLongs } = setupTransmissionData(
nodes,
visibility,
geoResolution,
nodeColors,
triplicate,
metadata,
map
);
const filteredDemesMissingLatLongs = [...demesMissingLatLongs].filter((value) => {
return value.toLowerCase() !== "unknown";
});
if (filteredDemesMissingLatLongs.size) {
dispatch(errorNotification({
message: "The following demes are missing lat/long information",
details: [...filteredDemesMissingLatLongs].join(", ")
}));
}
return {
demeData: demeData,
transmissionData: transmissionData,
demeIndices: demeIndices,
transmissionIndices: transmissionIndices,
demesMissingLatLongs
};
};
/* ******************************
********************************
UPDATE DEMES & TRANSMISSIONS
********************************
******************************* */
const updateDemeDataColAndVis = (demeData, demeIndices, nodes, visibility, geoResolution, nodeColors, pieChart, colorBy, legendValues) => {
const demeDataCopy = demeData.slice();
const locationToVisibleNodes = getVisibleNodesPerLocation(nodes, visibility, geoResolution);
// update demeData, for each deme, update all elements via demeIndices lookup
for (const [location, visibleNodes] of Object.entries(locationToVisibleNodes)) {
if (demeIndices[location]) {
demeIndices[location].forEach((index) => {
/* both pie charts & circles need new counts (which modify the radius) */
demeDataCopy[index].count = visibleNodes.length;
if (pieChart) {
/* update the arcs */
demeDataCopy[index].arcs = createOrUpdateArcs(visibleNodes, legendValues, colorBy, nodeColors, demeDataCopy[index].arcs);
} else {
/* circle demes just require a colour update */
demeDataCopy[index].color = getAverageColorFromNodes(visibleNodes, nodeColors);
}
});
}
}
return demeDataCopy;
};
const updateTransmissionDataColAndVis = (transmissionData, transmissionIndices, nodes, visibility, geoResolution, nodeColors) => {
const transmissionDataCopy = transmissionData.slice(); /* basically, instead of _.map() since we're not mapping over the data we're mutating */
nodes.forEach((node) => {
if (!node.children) return;
node.children.forEach((child) => {
const nodeLocation = getTraitFromNode(node, geoResolution);
const childLocation = getTraitFromNode(child, geoResolution);
if (!(nodeLocation && childLocation && nodeLocation !== childLocation)) return;
// this is a transmission event from n to child
const id = node.arrayIdx.toString() + "-" + child.arrayIdx.toString();
const col = nodeColors[node.arrayIdx];
const vis = visibility[child.arrayIdx] !== NODE_NOT_VISIBLE ? "visible" : "hidden"; // transmission visible if child is visible
// update transmissionData via index lookup
try {
transmissionIndices[id].forEach((index) => {
transmissionDataCopy[index].color = col;
transmissionDataCopy[index].visible = vis;
});
} catch (err) {
console.warn(`Error trying to access ${id} in transmissionIndices. Map transmissions may be wrong.`);
}
});
});
return transmissionDataCopy;
};
/**
* walk through nodes and update attributes that can mutate
* for demeData we have: count, color
* for transmissionData we have: color, visible
*/
export const updateDemeAndTransmissionDataColAndVis = (demeData, transmissionData, demeIndices, transmissionIndices, nodes, visibility, geoResolution, nodeColors, pieChart, colorBy, legendValues) => {
const newDemes = demeData ?
updateDemeDataColAndVis(demeData, demeIndices, nodes, visibility, geoResolution, nodeColors, pieChart, colorBy, legendValues) :
demeData;
const newTransmissions = (transmissionData && transmissionData.length) ?
updateTransmissionDataColAndVis(transmissionData, transmissionIndices, nodes, visibility, geoResolution, nodeColors) :
transmissionData;
return {newDemes, newTransmissions};
};
/* ********************
**********************
ZOOM LEVEL CHANGE
**********************
********************* */
export const updateDemeDataLatLong = (demeData, map) => {
// interchange for all demes
return _map(demeData, (d) => {
d.coords = leafletLatLongToLayerPoint(d.latitude, d.longitude, map);
return d;
});
};
export const updateTransmissionDataLatLong = (transmissionData, map) => {
const transmissionDataCopy = transmissionData.slice(); /* basically, instead of _.map() since we're not mapping over the data we're mutating */
// interchange for all transmissions
transmissionDataCopy.forEach((transmission) => {
transmission.originCoords = leafletLatLongToLayerPoint(transmission.originLatitude, transmission.originLongitude, map);
transmission.destinationCoords = leafletLatLongToLayerPoint(transmission.destinationLatitude, transmission.destinationLongitude, map);
transmission.bezierCurve = constructBcurve(
transmission.originCoords,
transmission.destinationCoords,
transmission.extend
);
});
return transmissionDataCopy;
};