auspice
Version:
Web app for visualizing pathogen evolution
415 lines (386 loc) • 15.9 kB
JavaScript
/* eslint no-restricted-syntax: 0 */
import React from "react";
import { infoNotification, warningNotification } from "../../actions/notifications";
import { prettyString, formatURLString, authorString } from "../../util/stringHelpers";
import { spaceBetweenTrees } from "../tree/tree";
export const isPaperURLValid = (d) => {
return (
Object.prototype.hasOwnProperty.call(d, "paper_url") &&
!d.paper_url.endsWith('/') &&
d.paper_url !== "?"
);
};
export const getAuthor = (info, k) => {
if (info === undefined || k === undefined) {
return (
<span>Not Available</span>
);
}
// TODO: improve this block
// if (isPaperURLValid(info[k])) {
// return (
// <a href={formatURLString(info[k].paper_url)} target="_blank">
// {authorString(k)}
// </a>
// );
// }
return authorString(k);
};
/* this function based on https://github.com/daviddao/biojs-io-newick/blob/master/src/newick.js */
const treeToNewick = (root, temporal) => {
function recurse(node, parentX) {
let subtree = "";
if (node.hasChildren) {
const children = [];
node.children.forEach((child) => {
const subsubtree = recurse(child, temporal ? node.attr.num_date : node.attr.div);
children.push(subsubtree);
});
subtree += "(" + children.join(",") + ")" + node.strain + ":";
subtree += (temporal ? node.attr.num_date : node.attr.div) - parentX;
} else { /* terminal node */
let leaf = node.strain + ":";
leaf += (temporal ? node.attr.num_date : node.attr.div) - parentX;
subtree += leaf;
}
return subtree;
}
return recurse(root, 0) + ";";
};
const MIME = {
text: "text/plain;charset=utf-8;",
csv: 'text/csv;charset=utf-8;',
tsv: `text/tab-separated-values;charset=utf-8;`,
svg: "image/svg+xml;charset=utf-8"
};
const write = (filename, type, content) => {
/* https://stackoverflow.com/questions/18848860/javascript-array-to-csv/18849208#comment59677504_18849208 */
const blob = new Blob([content], { type: type });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export const authorTSV = (dispatch, filePrefix, metadata, tree) => {
const lineArray = [["Author", "n (strains)", "publication title", "journal", "publication URL", "strains"].join("\t")];
const filename = filePrefix + "_authors.tsv";
const authors = {};
tree.nodes.filter((n) => !n.hasChildren && n.attr.authors).forEach((n) => {
if (!authors[n.attr.authors]) {
authors[n.attr.authors] = [n.strain];
} else {
authors[n.attr.authors].push(n.strain);
}
});
const body = [];
if (metadata.author_info) {
for (const author of Object.keys(metadata.author_info)) {
body.push([
prettyString(author, {camelCase: false}),
metadata.author_info[author].n,
prettyString(metadata.author_info[author].title, {removeComma: true}),
prettyString(metadata.author_info[author].journal, {removeComma: true}),
isPaperURLValid(metadata.author_info[author]) ? formatURLString(metadata.author_info[author].paper_url) : "unknown",
authors[author].join(",")
]);
}
}
body.forEach((line) => { lineArray.push(line.join("\t")); });
write(filename, MIME.tsv, lineArray.join("\n"));
dispatch(infoNotification({message: "Author metadata exported", details: filename}));
};
export const turnAttrsIntoHeaderArray = (attrs) => {
return ["Strain"].concat(attrs.map((v) => prettyString(v)));
};
export const strainTSV = (dispatch, filePrefix, nodes, rawAttrs) => {
// dont need to traverse the tree - can just loop the nodes
const filename = filePrefix + "_metadata.tsv";
const data = [];
const includeAttr = (v) => (!(v.includes("entropy") || v.includes("confidence") || v === "div" || v === "paper_url"));
const attrs = ["accession", "date", "region", "country", "division", "authors", "journal", "title", "url"];
attrs.filter((v) => rawAttrs.indexOf(v) !== -1); // remove those "ideal" atttrs not actually present
rawAttrs.forEach((v) => {
if (attrs.indexOf(v) === -1 && includeAttr(v)) {
attrs.push(v);
}
});
for (const node of nodes) {
if (node.hasChildren) {
continue;
}
const line = [node.strain];
// console.log(node.attr)
for (const field of attrs) {
if (Object.keys(node.attr).indexOf(field) === -1) {
line.push("unknown");
} else {
const value = node.attr[field];
if (typeof value === 'string') {
if (value.lastIndexOf("http", 0) === 0) {
line.push(formatURLString(value));
} else {
line.push(prettyString(value, {removeComma: true}));
}
} else if (typeof value === "number") {
line.push(parseFloat(value).toFixed(2));
} else if (typeof value === "object") {
if (Array.isArray(value)) {
if (typeof value[0] === "number") {
line.push(value.map((v) => parseFloat(v).toFixed(2)).join(" - "));
} else {
line.push(value.map((v) => prettyString(v, {removeComma: true})).join(" - "));
}
} else { /* not an array, but a relational object */
let x = "";
for (const k of Object.keys(value)) {
const v = typeof value[k] === "number" ? parseFloat(value[k]).toFixed(2) : prettyString(value[k], {removeComma: true});
x += prettyString(k, {removeComma: true}) + ": " + v + ";";
}
line.push(x);
}
} else {
console.warn("Tried to save " + value + " of type " + typeof value);
line.push("unknown");
}
}
}
data.push(line);
}
const lineArray = [turnAttrsIntoHeaderArray(attrs).join("\t")];
data.forEach((line) => {
const lineString = line.join("\t");
lineArray.push(lineString);
});
const tsvContent = lineArray.join("\n");
write(filename, MIME.tsv, tsvContent);
dispatch(infoNotification({message: "Metadata exported to " + filename}));
};
export const newick = (dispatch, filePrefix, root, temporal) => {
const fName = temporal ? filePrefix + "_timetree.nwk" : filePrefix + "_tree.nwk";
const message = temporal ? "TimeTree" : "Tree";
write(fName, MIME.text, treeToNewick(root, temporal));
dispatch(infoNotification({message: message + " written to " + fName}));
};
const processXMLString = (input) => {
/* split into bounding tag, and inner paths / shapes etc */
const parts = input.match(/^(<.+?>)(.+)<\/.+?>$/);
if (!parts) return undefined;
/* extract width & height from the initial <g> bounding group */
const dimensions = parts[1].match(/width.+?([0-9.]+).+height.+?([0-9.]+)/);
if (!dimensions) return undefined;
/* the map uses transform3d & viewbox */
const viewbox = parts[1].match(/viewBox="([0-9-]+)\s([0-9-]+)\s([0-9-]+)\s([0-9-]+)"/);
return {
x: 0,
y: 0,
viewbox: viewbox ? viewbox.slice(1) : undefined,
width: parseFloat(dimensions[1]),
height: parseFloat(dimensions[2]),
inner: parts[2]
};
};
/* take the panels (see processXMLString for struct) and calculate the overall size of the SVG
as well as the offsets (x, y) to position panels appropriately within this */
const createBoundingDimensionsAndPositionPanels = (panels, panelLayout, numLinesOfText) => {
const padding = 50;
let width = 0;
let height = 0;
/* calculating the width of the tree panel is harder if there are two trees */
if (panels.secondTree) {
panels.secondTree.x = spaceBetweenTrees + panels.tree.width;
panels.tree.width += (spaceBetweenTrees + panels.secondTree.width);
}
if (panels.tree && panels.mapD3 && panels.mapTiles) {
if (panelLayout === "grid") {
width = panels.tree.width + padding + panels.mapTiles.width;
height = Math.max(panels.tree.height, panels.mapTiles.height);
panels.mapD3.x = panels.tree.width + padding;
} else {
width = Math.max(panels.tree.width, panels.mapTiles.width);
height = panels.tree.height + padding + panels.mapTiles.height;
panels.mapD3.y = panels.tree.height + padding;
}
panels.mapTiles.x = panels.mapD3.x;
panels.mapTiles.y = panels.mapD3.y;
} else if (panels.tree) {
width = panels.tree.width;
height = panels.tree.height;
} else if (panels.mapD3 && panels.mapTiles) {
width = panels.mapTiles.width;
height = panels.mapTiles.height;
}
/* need to adjust map demes & transmissions to account for panning */
if (panels.mapD3) {
// console.log("adding offsets to mapD3 x,y ", panels.mapD3._panOffsets.x, panels.mapD3._panOffsets.y);
panels.mapD3.x += panels.mapD3._panOffsets.x;
panels.mapD3.y += panels.mapD3._panOffsets.y;
}
if (panels.entropy) {
if (width < panels.entropy.width) {
width = panels.entropy.width;
} else {
panels.entropy.x = (width - panels.entropy.width) / 2;
}
if (height) {
panels.entropy.y = height + padding;
height += padding + panels.entropy.height;
} else {
height = panels.entropy.height;
}
}
if (panels.frequencies) {
if (width < panels.frequencies.width) {
width = panels.frequencies.width;
} else {
panels.frequencies.x = (width - panels.frequencies.width) / 2;
}
if (height) {
panels.frequencies.y = height + padding;
height += padding + panels.frequencies.height;
} else {
height = panels.frequencies.height;
}
}
/* add top&left padding */
for (let key in panels) { // eslint-disable-line
if (panels[key]) {
panels[key].x += padding;
panels[key].y += padding;
}
}
width += padding*2;
height += padding*2;
const textHeight = numLinesOfText * 36 + 20;
height += textHeight;
return {
width,
height,
padding,
textY: height - textHeight,
textHeight
};
};
const injectAsSVGStrings = (output, key, data) => {
const svgTag = `<svg id="${key}" width="${data.width}" height="${data.height}" x="${data.x}" y="${data.y}">`;
// if (data.viewbox) svgTag = svgTag.replace(">", ` viewBox="${data.viewbox.join(" ")}">`);
output.push(svgTag);
output.push(data.inner);
output.push("</svg>");
};
/* define actual writer as a closure, because it may need to be triggered asyncronously */
const writeSVGPossiblyIncludingMapPNG = (dispatch, filePrefix, panelsInDOM, panelLayout, textStrings, mapTiles) => {
const errors = [];
/* for each panel present in the DOM, create a data structure with the dimensions & the paths/shapes etc */
const panels = {tree: undefined, mapTiles: undefined, mapD3: undefined, entropy: undefined, frequencies: undefined};
if (panelsInDOM.indexOf("tree") !== -1) {
try {
panels.tree = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("MainTree")));
panels.treeLegend = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("TreeLegendContainer")));
} catch (e) {
panels.tree = undefined;
errors.push("tree");
console.error("Tree SVG save error:", e);
}
if (panels.tree && document.getElementById('SecondTree')) {
try {
panels.secondTree = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("SecondTree")));
if (document.getElementById('Tangle')) {
panels.tangle = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("Tangle")));
}
} catch (e) {
errors.push("second tree / tanglegram");
console.error("Second Tree / tanglegram SVG save error:", e);
}
}
}
if (panelsInDOM.indexOf("entropy") !== -1) {
try {
panels.entropy = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("d3entropyParent")));
panels.entropy.inner = panels.entropy.inner.replace(/<text/g, `<text class="txt"`);
panels.entropy.inner = `<style>.txt { font-family: "Lato", "Helvetica Neue", "Helvetica", "sans-serif"; }</style>${panels.entropy.inner}`;
} catch (e) {
panels.entropy = undefined;
errors.push("entropy");
console.error("Entropy SVG save error:", e);
}
}
if (panelsInDOM.indexOf("frequencies") !== -1) {
try {
panels.frequencies = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("d3frequenciesSVG")));
} catch (e) {
panels.frequencies = undefined;
errors.push("frequencies");
console.error("Frequencies SVG save error:", e);
}
}
if (panelsInDOM.indexOf("map") !== -1 && mapTiles) {
panels.mapTiles = {
x: 0,
y: 0,
viewbox: undefined,
width: parseFloat(mapTiles.mapDimensions.x),
height: parseFloat(mapTiles.mapDimensions.y),
inner: `<image width="${mapTiles.mapDimensions.x}" height="${mapTiles.mapDimensions.y}" xlink:href="${mapTiles.base64map}"/>`
};
try {
panels.mapD3 = processXMLString((new XMLSerializer()).serializeToString(document.getElementById("d3DemesTransmissions")));
// modify the width & height of the mapD3 to match the tiles (not sure how this actually works in the DOM)
panels.mapD3.width = panels.mapTiles.width;
panels.mapD3.height = panels.mapTiles.height;
panels.mapD3._panOffsets = mapTiles.panOffsets;
} catch (e) {
panels.mapD3 = undefined;
panels.mapTiles = undefined;
errors.push("map");
console.error("Map demes & tranmisions SVG save error:", e);
}
}
/* collect all panels as individual <svg> elements inside a bounding <svg> tag, and write to file */
const output = [];
/* logic for extracting the overall width etc */
const overallDimensions = createBoundingDimensionsAndPositionPanels(panels, panelLayout, textStrings.length);
output.push(`<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="${overallDimensions.width}" height="${overallDimensions.height}">`);
for (let key in panels) { // eslint-disable-line
if (panels[key]) {
injectAsSVGStrings(output, key, panels[key]); // modifies output in place
}
}
/* add text to bottom of SVG in HTML format */
output.push(`<foreignObject x="${overallDimensions.padding}" y="${overallDimensions.height - overallDimensions.textHeight}" height="${overallDimensions.textHeight}" width="${overallDimensions.width - 2*overallDimensions.padding}">`);
textStrings.forEach((s) => {
output.push(`<p xmlns="http://www.w3.org/1999/xhtml" style="font-family:lato,sans-serif;">`);
output.push(s);
output.push("</p>");
});
output.push("</foreignObject>");
output.push("</svg>");
// console.log(panels)
// console.log(output)
write(filePrefix + ".svg", MIME.svg, output.join("\n"));
if (!errors.length) {
dispatch(infoNotification({
message: "Vector image saved",
details: filePrefix + ".svg"
}));
} else {
dispatch(warningNotification({
message: "Vector image saved",
details: `Saved to ${filePrefix}.svg, however there were errors with ${errors.join(", ")}`
}));
}
};
const getMapTilesErrorCallback = (e) => {
console.warn("getMapTiles errorCallback", e);
};
export const SVG = (dispatch, filePrefix, panelsInDOM, panelLayout, textStrings) => {
/* downloading the map tiles is an async call */
if (panelsInDOM.indexOf("map") !== -1) {
window.L.getMapTiles(writeSVGPossiblyIncludingMapPNG.bind(this, dispatch, filePrefix, panelsInDOM, panelLayout, textStrings), getMapTilesErrorCallback);
} else {
writeSVGPossiblyIncludingMapPNG(dispatch, filePrefix, panelsInDOM, panelLayout, textStrings, undefined);
}
};