UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

291 lines (275 loc) 12.3 kB
import { scaleLinear, scaleOrdinal } from "d3-scale"; import { min, max, range as d3Range } from "d3-array"; import { rgb } from "d3-color"; import { interpolateHcl } from "d3-interpolate"; import { genericDomain, colors, genotypeColors, isValueValid } from "./globals"; import { countTraitsAcrossTree } from "./treeCountingHelpers"; import { getExtraVals } from "./colorHelpers"; import { isColorByGenotype, decodeColorByGenotype } from "./getGenotype"; import { setGenotype, orderOfGenotypeAppearance } from "./setGenotype"; import { getTraitFromNode } from "./treeMiscHelpers"; const unknownColor = "#AAAAAA"; const getMinMaxFromTree = (nodes, nodesToo, attr) => { const arr = nodesToo ? nodes.concat(nodesToo) : nodes.slice(); const vals = arr.map((n) => getTraitFromNode(n, attr)) .filter((n) => n !== undefined) .filter((item, i, ar) => ar.indexOf(item) === i) .map((v) => +v); // coerce to numeric return [min(vals), max(vals)]; }; /* this creates a (ramped) list of colours this is necessary as ordinal scales can't interpololate colours. range: [a,b], the colours to go between */ const createListOfColors = (n, range) => { const scale = scaleLinear().domain([0, n]) .interpolate(interpolateHcl) .range(range); return d3Range(0, n).map(scale); }; const getDiscreteValuesFromTree = (nodes, nodesToo, attr) => { const stateCount = countTraitsAcrossTree(nodes, [attr], false, false)[attr]; if (nodesToo) { const stateCountSecondTree = countTraitsAcrossTree(nodesToo, [attr], false, false)[attr]; for (const state of stateCountSecondTree.keys()) { const currentCount = stateCount.get(state) || 0; stateCount.set(state, currentCount+1); } } const domain = Array.from(stateCount.keys()).filter((x) => isValueValid(x)); /* sorting technique depends on the colorBy */ if (attr === "clade_membership") { domain.sort(); } else { domain.sort((a, b) => stateCount[a] > stateCount[b] ? 1 : -1); } return domain; }; const createDiscreteScale = (domain, type) => { // note: colors[n] has n colors let colorList; if (type==="ordinal" || type==="categorical") { /* TODO: use different colours! */ colorList = domain.length <= colors.length ? colors[domain.length].slice() : colors[colors.length - 1].slice(); } const scale = scaleOrdinal().domain(domain).range(colorList); return (val) => ((val === undefined || domain.indexOf(val) === -1)) ? unknownColor : scale(val); }; const booleanColorScale = (val) => { if (!isValueValid(val)) return unknownColor; if (["true", "1", "yes"].includes(String(val).toLowerCase())) return "#4C90C0"; return "#CBB742"; }; const createLegendBounds = (legendValues) => { const valBetween = (x0, x1) => x0 + 0.5*(x1-x0); const len = legendValues.length; const legendBounds = {}; legendBounds[legendValues[0]] = [0, valBetween(legendValues[0], legendValues[1])]; for (let i = 1; i < len - 1; i++) { legendBounds[legendValues[i]] = [valBetween(legendValues[i-1], legendValues[i]), valBetween(legendValues[i], legendValues[i+1])]; } legendBounds[legendValues[len-1]] = [valBetween(legendValues[len-2], legendValues[len-1]), 10000]; return legendBounds; }; /** * calculate the color scale. * @param {string} colorBy - provided trait to use as color * @param {object} controls * @param {object} tree * @param {object} treeToo * @param {object} metadata * @return {{scale: function, continuous: string, colorBy: string, version: int, legendValues: Array, legendBounds: Array, genotype: null|object}} */ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { if (colorBy === "none") { console.warn("ColorScale fallthrough for colorBy set to none"); return { scale: () => unknownColor, continuous: false, colorBy: colorBy, version: controls.colorScale === undefined ? 1 : controls.colorScale.version + 1, legendValues: ["unknown"], legendBounds: createLegendBounds(["unknown"]), genotype: null }; } let genotype; if (isColorByGenotype(colorBy) && controls.geneLength) { genotype = decodeColorByGenotype(colorBy, controls.geneLength); setGenotype(tree.nodes, genotype.gene, genotype.positions); /* modifies nodes recursively */ } // const colorOptions = metadata.colorOptions; const colorings = metadata.colorings; const treeTooNodes = treeToo ? treeToo.nodes : undefined; let error = false; let continuous = false; let colorScale, legendValues, legendBounds; if (!tree.nodes) { console.error("calcColorScale called before tree is ready."); error = true; } else if (genotype) { /* G E N O T Y P E */ legendValues = orderOfGenotypeAppearance(tree.nodes, controls.mutType); const trueValues = controls.mutType === "nuc" ? legendValues.filter((x) => x !== "X" && x !== "-" && x !== "N") : legendValues.filter((x) => x !== "X" && x !== "-"); const domain = [undefined, ...legendValues]; const range = [unknownColor, ...genotypeColors.slice(0, trueValues.length)]; // Bases are returned by orderOfGenotypeAppearance in order, unknowns at end if (legendValues.indexOf("-") !== -1) { range.push(rgb(217, 217, 217)); } if (legendValues.indexOf("N") !== -1 && controls.mutType === "nuc") { range.push(rgb(153, 153, 153)); } if (legendValues.indexOf("X") !== -1) { range.push(rgb(102, 102, 102)); } colorScale = scaleOrdinal() .domain(domain) .range(range); } else if (colorings && colorings[colorBy]) { let minMax; /* Is the scale set in the provided colorings object? */ if (colorings[colorBy].scale) { // console.log(`calcColorScale: colorBy ${colorBy} provided us with a scale (list of [trait, hex])`); const scale = colorings[colorBy].scale; if (!Array.isArray(colorings[colorBy].scale)) { console.error(`${colorBy} has a scale which wasn't an array`); error = true; } else { continuous = false; /* colorMaps can't (yet) be continuous */ let domain = scale.map((x) => x[0]); let range = scale.map((x) => x[1]); const extraVals = getExtraVals(tree.nodes, treeTooNodes, colorBy, domain); if (extraVals.length) { // we must add these to the domain + provide a value in the range domain = domain.concat(extraVals); range = range.concat(createListOfColors(extraVals.length, [rgb(192, 192, 192), rgb(32, 32, 32)])); } colorScale = scaleOrdinal() .domain(domain) .range(range); legendValues = domain; } } else if (colorings[colorBy].type === "categorical") { continuous = false; legendValues = getDiscreteValuesFromTree(tree.nodes, treeTooNodes, colorBy); colorScale = createDiscreteScale(legendValues, "categorical"); } else if (colorings[colorBy].type === "ordinal") { /* currently, ordinal scales are only implemented for those with integer values. TODO: we should be able to have non-numerical ordinal scales (e.g. `["small", "medium", "large"]`) however we currently cannot specify this ordering in the dataset JSON. Ordinal scales may also want to be used for numerical but non-integer values */ legendValues = getDiscreteValuesFromTree(tree.nodes, treeTooNodes, colorBy); const allInteger = legendValues.every((x) => Number.isInteger(x)); if (allInteger) { minMax = getMinMaxFromTree(tree.nodes, treeTooNodes, colorBy, colorings[colorBy]); if (minMax[1]-minMax[0]<=colors.length) { continuous = false; legendValues = []; for (let i=minMax[0]; i<=minMax[1]; i++) legendValues.push(i); colorScale = createDiscreteScale(legendValues, "ordinal"); } else { /* too many integers for the provided colours -- using continuous scale instead */ /* TODO - when we refactor this code we can abstract into functions to stop code duplication, as this is identical to that of the continuous scale below */ console.warn("Using a continous scale as there are too many values in the ordinal scale"); continuous = true; const scale = scaleLinear().domain(genericDomain.map((d) => minMax[0] + d * (minMax[1] - minMax[0]))).range(colors[9]); colorScale = (val) => isValueValid(val) ? scale(val): unknownColor; const spread = minMax[1] - minMax[0]; const dp = spread > 5 ? 2 : 3; legendValues = genericDomain.map((d) => parseFloat((minMax[0] + d*spread).toFixed(dp))); if (legendValues[0] === -0) legendValues[0] = 0; /* hack to avoid bugs */ legendBounds = createLegendBounds(legendValues); } } else { console.warn("Using a categorical scale as currently ordinal scales must only contain integers"); continuous = false; colorScale = createDiscreteScale(legendValues, "categorical"); } } else if (colorings[colorBy].type === "boolean") { continuous = false; legendValues = getDiscreteValuesFromTree(tree.nodes, treeTooNodes, colorBy); colorScale = booleanColorScale; } else if (colorings[colorBy].type === "continuous") { // console.log("making a continuous color scale for ", colorBy); continuous = true; switch (colorBy) { case "lbi": minMax = [0, 0.7]; break; case "num_date": break; /* minMax not needed for num_date */ default: minMax = getMinMaxFromTree(tree.nodes, treeTooNodes, colorBy, colorings[colorBy]); } /* make the continuous scale */ let domain, range; switch (colorBy) { case "num_date": /* we want the colorScale to "focus" on the tip dates, and be spaced according to sampling */ let rootDate = getTraitFromNode(tree.nodes[0], "num_date"); let vals = tree.nodes.filter((n) => !n.hasChildren) .map((n) => getTraitFromNode(n, "num_date")); if (treeTooNodes) { const treeTooRootDate = getTraitFromNode(treeTooNodes[0], "num_date"); if (treeTooRootDate < rootDate) rootDate = treeTooRootDate; vals.concat( treeTooNodes.filter((n) => !n.hasChildren) .map((n) => getTraitFromNode(n, "num_date")) ); } vals = vals.sort(); domain = [rootDate]; const n = 10; const spaceBetween = parseInt(vals.length / (n - 1), 10); for (let i = 0; i < (n-1); i++) domain.push(vals[spaceBetween*i]); domain.push(vals[vals.length-1]); domain = [...new Set(domain)]; /* filter to unique values only */ range = colors[domain.length]; /* use the right number of colours */ break; default: range = colors[9]; domain = genericDomain.map((d) => minMax[0] + d * (minMax[1] - minMax[0])); } const scale = scaleLinear().domain(domain).range(range); colorScale = (val) => isValueValid(val) ? scale(val) : unknownColor; /* construct the legend values & their respective bounds */ switch (colorBy) { case "lbi": legendValues = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]; break; case "num_date": legendValues = domain.slice(1); break; default: const spread = minMax[1] - minMax[0]; const dp = spread > 5 ? 2 : 3; legendValues = genericDomain.map((d) => parseFloat((minMax[0] + d*spread).toFixed(dp))); } if (legendValues[0] === -0) legendValues[0] = 0; /* hack to avoid bugs */ legendBounds = createLegendBounds(legendValues); } else { console.error("ColorBy", colorBy, "invalid type --", colorings[colorBy].type); error = true; } } else { error = true; } if (error) { console.error("ColorScale fallthrough for ", colorBy); continuous = false; legendValues = [undefined]; colorScale = () => unknownColor; } return { scale: colorScale, continuous: continuous, colorBy: colorBy, version: controls.colorScale === undefined ? 1 : controls.colorScale.version + 1, legendValues, legendBounds, genotype }; };