auspice
Version:
Web app for visualizing pathogen evolution
522 lines (449 loc) • 15.9 kB
JavaScript
import _forOwn from "lodash/forOwn";
import _map from "lodash/map";
import _minBy from "lodash/minBy";
import { interpolateNumber } from "d3-interpolate";
import { averageColors } from "../../util/colorHelpers";
import { bezier } from "./transmissionBezier";
import { NODE_NOT_VISIBLE } from "../../util/globals";
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;
};
const setupDemeData = (nodes, visibility, geoResolution, nodeColors, triplicate, metadata, map) => {
const demeData = []; /* deme array */
const demeIndices = {}; /* map of name to indices in array */
// do aggregation as intermediate step
const demeMap = {};
nodes.forEach((n) => {
if (n.children) { return; }
const location = n.attr[geoResolution];
if (location) { // check for undefined
if (!demeMap[location]) {
demeMap[location] = [];
}
}
});
// second pass to fill vectors
nodes.forEach((n, i) => {
/* demes only count terminal nodes */
if (!n.children && visibility[i] !== NODE_NOT_VISIBLE) {
// if tip and visible, push
if (n.attr[geoResolution]) { // check for undefined
demeMap[n.attr[geoResolution]].push(nodeColors[i]);
}
}
});
const offsets = triplicate ? [-360, 0, 360] : [0];
const geo = metadata.geo;
let index = 0;
offsets.forEach((OFFSET) => {
/* count DEMES */
_forOwn(demeMap, (value, key) => { // value: hash color array, key: deme name
let lat = 0;
let long = 0;
let goodDeme = true;
if (geo[geoResolution][key]) {
lat = geo[geoResolution][key].latitude;
long = geo[geoResolution][key].longitude + OFFSET;
} else {
goodDeme = false;
console.warn("Warning: Lat/long missing from metadata for", key);
}
if (!(long > westBound && long < eastBound && goodDeme)) { return; }
const deme = {
name: key,
count: value.length,
color: averageColors(value),
latitude: lat, // raw latitude value
longitude: long, // raw longitude value
coords: leafletLatLongToLayerPoint(lat, long, map) // coords are x,y plotted via d3
};
demeData.push(deme);
if (!demeIndices[key]) {
demeIndices[key] = [index];
} else {
demeIndices[key].push(index);
}
index += 1;
});
});
return {
demeData: demeData,
demeIndices: demeIndices
};
};
const constructBcurve = (
originLatLongPair,
destinationLatLongPair,
extend
) => {
return bezier(originLatLongPair, destinationLatLongPair, extend);
};
const maybeConstructTransmissionEvent = (
node,
child,
metadataGeoLookupTable,
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? */
try {
// node.attr[geoResolution] is the node's location, we're looking that up in the metadata lookup table
latOrig = metadataGeoLookupTable[geoResolution][node.attr[geoResolution]].latitude;
longOrig = metadataGeoLookupTable[geoResolution][node.attr[geoResolution]].longitude;
} catch (e) {
// console.warn("No transmission lat/longs for ", node.attr[geoResolution], " -> ",child.attr[geoResolution], "If this wasn't fired in the context of a dataset change, it's probably a bug.")
demesMissingLatLongs.add(node.attr[geoResolution]);
}
try {
// node.attr[geoResolution] is the node's location, we're looking that up in the metadata lookup table
latDest = metadataGeoLookupTable[geoResolution][child.attr[geoResolution]].latitude;
longDest = metadataGeoLookupTable[geoResolution][child.attr[geoResolution]].longitude;
} catch (e) {
console.warn("No transmission lat/longs for ", node.attr[geoResolution], " -> ", child.attr[geoResolution], "If this wasn't fired in the context of a dataset change, it's probably a bug.");
return undefined;
}
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(node.attr.num_date, child.attr.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: node.attr[geoResolution],
destinationName: child.attr[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: node.attr["num_date"],
destinationNumDate: child.attr["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 metadataGeoLookupTable = metadata.geo;
const transmissionData = []; /* edges, animation paths */
const transmissionIndices = {}; /* map of transmission id to array of indices */
const demesMissingLatLongs = new Set();
const demeToDemeCounts = {};
nodes.forEach((n) => {
if (!n.children) { return; }
const nodeDeme = n.attr[geoResolution];
n.children.forEach((child) => {
const childDeme = child.attr[geoResolution];
if (!(nodeDeme && childDeme && nodeDeme !== childDeme)) { return; }
// 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,
metadataGeoLookupTable,
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,
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);
/* 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) => {
const demeDataCopy = demeData.slice();
// initialize empty map
const demeMap = {};
_forOwn(demeIndices, (value, key) => { // value: array of indices, key: deme name
demeMap[key] = [];
});
// second pass to fill vectors
nodes.forEach((n, i) => {
/* demes only count terminal nodes */
if (!n.children && visibility[i] !== NODE_NOT_VISIBLE) {
// if tip and visible, push
if (n.attr[geoResolution]) { // check for undefined
if (n.attr[geoResolution] in demeMap) {
demeMap[n.attr[geoResolution]].push(nodeColors[i]);
}
}
}
});
// update demeData, for each deme, update all elements via demeIndices lookup
_forOwn(demeMap, (value, key) => { // value: hash color array, key: deme name
const name = key;
demeIndices[name].forEach((index) => {
demeDataCopy[index].count = value.length;
demeDataCopy[index].color = averageColors(value);
});
});
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 = node.attr[geoResolution];
const childLocation = child.attr[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;
};
export const updateDemeAndTransmissionDataColAndVis = (demeData, transmissionData, demeIndices, transmissionIndices, nodes, visibility, geoResolution, nodeColors) => {
/*
walk through nodes and update attributes that can mutate
for demeData we have:
count, color
for transmissionData we have:
color, visible
*/
let newDemes;
let newTransmissions;
if (demeData && transmissionData) {
newDemes = updateDemeDataColAndVis(demeData, demeIndices, nodes, visibility, geoResolution, nodeColors);
newTransmissions = updateTransmissionDataColAndVis(transmissionData, transmissionIndices, nodes, visibility, geoResolution, nodeColors);
}
return {newDemes, newTransmissions};
};
/* ********************
**********************
ZOOM LEVEL CHANGE
**********************
********************* */
const updateDemeDataLatLong = (demeData, map) => {
// interchange for all demes
return _map(demeData, (d) => {
d.coords = leafletLatLongToLayerPoint(d.latitude, d.longitude, map);
return d;
});
};
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;
};
export const updateDemeAndTransmissionDataLatLong = (demeData, transmissionData, map) => {
/*
walk through nodes and update attributes that can mutate
for demeData we have:
count, color
for transmissionData we have:
color, visible
*/
let newDemes;
let newTransmissions;
if (demeData && transmissionData) {
newDemes = updateDemeDataLatLong(demeData, map);
newTransmissions = updateTransmissionDataLatLong(transmissionData, map);
}
return {
newDemes,
newTransmissions
};
};