diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 931228b02..d3941a526 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -11,6 +11,7 @@ import { getDefaultMeasurementsState } from "../reducers/measurements"; import { countTraitsAcrossTree, calcTotalTipsInTree } from "../util/treeCountingHelpers"; import { calcEntropyInView } from "../util/entropy"; import { treeJsonToState } from "../util/treeJsonProcessing"; +import { castIncorrectTypes } from "../util/castJsonTypes"; import { entropyCreateState } from "../util/entropyCreateStateFromJsons"; import { determineColorByGenotypeMutType, calcNodeColor } from "../util/colorHelpers"; import { calcColorScale, createVisibleLegendValues } from "../util/colorScale"; @@ -771,11 +772,13 @@ export const createStateFromQueryOrJSONs = ({ measurements = getDefaultMeasurementsState(); /* new tree state(s) */ tree = treeJsonToState(json.tree); + castIncorrectTypes(metadata, tree); tree.debug = "LEFT"; tree.name = mainTreeName; metadata.mainTreeNumTips = calcTotalTipsInTree(tree.nodes); if (secondTreeDataset) { treeToo = treeJsonToState(secondTreeDataset.tree); + castIncorrectTypes(metadata, treeToo); treeToo.debug = "RIGHT"; treeToo.name = secondTreeName; /* TODO: calc & display num tips in 2nd tree */ diff --git a/src/util/castJsonTypes.js b/src/util/castJsonTypes.js new file mode 100644 index 000000000..712519c0d --- /dev/null +++ b/src/util/castJsonTypes.js @@ -0,0 +1,56 @@ +/** + * Values in the JSON should be appropriately typed however this may not always be the case. + * For instance, certain values of a continuous trait may be strings, and we should cast + * these to numbers. See https://github.com/nextstrain/auspice/issues/1626 for more discussion + * of this particular case. Feel free to add more checks / casts to this function! + * @param {Object} metadata + * @param {Object} tree + * @returns {undefined} Any type casting is in-place + */ +export const castIncorrectTypes = (metadata, tree) => { + try { + const continuousKeys = new Set(); + Object.entries(metadata.colorings || {}).forEach(([key, details]) => { + if (details.type==="continuous") { + continuousKeys.add(key); + } + }); + tree.nodes.forEach((node) => { + for (const key of continuousKeys) { + const attr = node?.node_attrs?.[key]; + if (!attr) continue; + continuousAttrValueToNumber(attr); + } + }); + } catch (err) { + // type casting shouldn't be required (the JSON should be correctly typed) + // so any errors shouldn't prevent Auspice loading + console.error(err); + } +}; + +/** + * Cast `attr.value` to either a Number or undefined. + * Note that "Infinity" (string) is changed to undefined, but `Infinity` (Number) + * is left alone as the latter is not possible to encode in JSON. + * @param {Record} attr + */ +export function continuousAttrValueToNumber(attr) { + switch (typeof attr.value) { + case "number": + break; + case "string": + const value = attr.value.trim(); + if (value === "" || isNaN(value) || value ==="Infinity" || value ==="-Infinity") { + // Note: Number("")=0 + // undefined values are handled appropriately (e.g. scatterplots, tooltips etc) + attr.value = undefined; + } else { + attr.value = Number(value); + } + break; + default: + // any other types (Boolean, Null ('object')) are not valid for a continuous scale + attr.value = undefined; + } +} diff --git a/test/jsonParsing.test.js b/test/jsonParsing.test.js new file mode 100644 index 000000000..58e35df88 --- /dev/null +++ b/test/jsonParsing.test.js @@ -0,0 +1,20 @@ +import { continuousAttrValueToNumber } from "../src/util/castJsonTypes"; + +/** + * function used to cast node values of continuous traits to numbers + */ +test("Continuous scale values are cast to Numbers or are undefined", () => { + const numbers = ["123", "1e5", "1", "0", "-1", 0, 1, -1]; + const notNumbers = ["", " ", " Infinity", "infinity", "Infinity", null, "abc", "", true, false, "true", undefined]; + numbers.forEach((n) => { + const attr = {value: n}; + continuousAttrValueToNumber(attr); // modifies in place + expect(typeof attr.value).toStrictEqual('number'); + expect(Number.isFinite(attr.value)).toBe(true); + }); + notNumbers.forEach((n) => { + const attr = {value: n}; + continuousAttrValueToNumber(attr); // modifies in place + expect(attr.value).toBeUndefined(); + }); +});