Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow YYYY-MM-DD values in a temporal scales #1832

Merged
merged 6 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 57 additions & 34 deletions src/actions/recomputeReduxState.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,31 @@ const modifyStateViaURLQuery = (state, query) => {
if (query.tl) {
state["tipLabelKey"] = query.tl===strainSymbolUrlString ? strainSymbol : query.tl;
}

if (query.dmin) {
state["dateMin"] = query.dmin;
state["dateMinNumeric"] = calendarToNumeric(query.dmin);
const dateNum = calendarToNumeric(query.dmin);
if (_validDate(dateNum, state.absoluteDateMinNumeric, state.absoluteDateMaxNumeric)) {
state["dateMin"] = query.dmin;
state["dateMinNumeric"] = dateNum;
} else {
console.error(`URL query "dmin=${query.dmin}" is invalid ${state.branchLengthsToDisplay==='divOnly'?'(the tree is not a timetree)':''}`);
delete query.dmin;
}
}
if (query.dmax) {
state["dateMax"] = query.dmax;
state["dateMaxNumeric"] = calendarToNumeric(query.dmax);
const dateNum = calendarToNumeric(query.dmax);
if (_validDate(dateNum, state.absoluteDateMinNumeric, state.absoluteDateMaxNumeric)) {
if ((query.dmin && dateNum <= state.dateMinNumeric)) {
console.error(`Requested "dmax=${query.dmax}" is earlier than "dmin=${query.dmin}", ignoring dmax.`);
delete query.dmax;
} else {
state["dateMax"] = query.dmax;
state["dateMaxNumeric"] = dateNum;
}
} else {
console.error(`URL query "dmax=${query.dmax}" is invalid ${state.branchLengthsToDisplay==='divOnly'?'(the tree is not a timetree)':''}`);
delete query.dmax;
}
}

/** Queries 's', 'gt', and 'f_<name>' correspond to active filters */
Expand All @@ -115,21 +133,39 @@ const modifyStateViaURLQuery = (state, query) => {
state.filters[genotypeSymbol] = decodeGenotypeFilters(query.gt||"");
}

state.animationPlayPauseButton = "Play";
if (query.animate) {
const params = query.animate.split(',');
// console.log("start animation!", params);
window.NEXTSTRAIN.animationStartPoint = calendarToNumeric(params[0]);
window.NEXTSTRAIN.animationEndPoint = calendarToNumeric(params[1]);
state.dateMin = params[0];
state.dateMax = params[1];
state.dateMinNumeric = calendarToNumeric(params[0]);
state.dateMaxNumeric = calendarToNumeric(params[1]);
state.mapAnimationShouldLoop = params[2] === "1";
state.mapAnimationCumulative = params[3] === "1";
state.mapAnimationDurationInMilliseconds = parseInt(params[4], 10);
state.animationPlayPauseButton = "Pause";
} else {
state.animationPlayPauseButton = "Play";
if (params.length!==5) {
console.error("Invalid 'animate' URL query (not enough fields)");
delete query.animate;
} else if (state.branchLengthsToDisplay==='divOnly') {
console.error("Invalid 'animate' URL query (tree is not a timetree)");
delete query.animate;
} else {
const [_dmin, _dminNum] = [params[0], calendarToNumeric(params[0])];
const [_dmax, _dmaxNum] = [params[1], calendarToNumeric(params[1])];
if (
!_validDate(_dminNum, state.absoluteDateMinNumeric, state.absoluteDateMaxNumeric) ||
!_validDate(_dmaxNum, state.absoluteDateMinNumeric, state.absoluteDateMaxNumeric) ||
_dminNum >= _dmaxNum
) {
console.error("Invalid 'animate' URL query (invalid date range)")
delete query.animate
} else {
window.NEXTSTRAIN.animationStartPoint = _dminNum;
window.NEXTSTRAIN.animationEndPoint = _dmaxNum;
state.dateMin = _dmin;
state.dateMax = _dmax;
state.dateMinNumeric = _dminNum;
state.dateMaxNumeric = _dmaxNum;
state.mapAnimationShouldLoop = params[2] === "1";
state.mapAnimationCumulative = params[3] === "1";
const duration = parseInt(params[4], 10);
state.mapAnimationDurationInMilliseconds = isNaN(duration) ? 30_000 : duration;
state.animationPlayPauseButton = "Pause";
}
}
}
if (query.branchLabel) {
state.selectedBranchLabel = query.branchLabel;
Expand Down Expand Up @@ -171,6 +207,10 @@ const modifyStateViaURLQuery = (state, query) => {
if (query.scatterX) state.scatterVariables.x = query.scatterX;
if (query.scatterY) state.scatterVariables.y = query.scatterY;
return state;

function _validDate(dateNum, absoluteDateMinNumeric, absoluteDateMaxNumeric) {
return !(dateNum===undefined || dateNum > absoluteDateMaxNumeric || dateNum < absoluteDateMinNumeric);
}
};

const restoreQueryableStateToDefaults = (state) => {
Expand Down Expand Up @@ -209,23 +249,6 @@ const restoreQueryableStateToDefaults = (state) => {
};

const modifyStateViaMetadata = (state, metadata, genomeMap) => {
if (metadata.date_range) {
/* this may be useful if, e.g., one were to want to display an outbreak
from 2000-2005 (the default is the present day) */
if (metadata.date_range.date_min) {
state["dateMin"] = metadata.date_range.date_min;
state["dateMinNumeric"] = calendarToNumeric(state["dateMin"]);
state["absoluteDateMin"] = metadata.date_range.date_min;
state["absoluteDateMinNumeric"] = calendarToNumeric(state["absoluteDateMin"]);
state["mapAnimationStartDate"] = metadata.date_range.date_min;
}
if (metadata.date_range.date_max) {
state["dateMax"] = metadata.date_range.date_max;
state["dateMaxNumeric"] = calendarToNumeric(state["dateMax"]);
state["absoluteDateMax"] = metadata.date_range.date_max;
state["absoluteDateMaxNumeric"] = calendarToNumeric(state["absoluteDateMax"]);
}
}
if (metadata.analysisSlider) {
state["analysisSlider"] = {key: metadata.analysisSlider, valid: false};
}
Expand Down
17 changes: 17 additions & 0 deletions src/util/colorHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import scalePow from "d3-scale/src/pow";
import { isColorByGenotype, decodeColorByGenotype } from "./getGenotype";
import { getTraitFromNode } from "./treeMiscHelpers";
import { isValueValid } from "./globals";
import { calendarToNumeric } from "./dateHelpers";

/**
* Average over the visible colours for a given location
Expand Down Expand Up @@ -147,3 +148,19 @@ export const getColorByTitle = (colorings, colorBy) => {
return colorings[colorBy] === undefined ?
"" : colorings[colorBy].title;
};

/**
* We allow values (on nodes) to be encoded as numeric dates (2021.123) or
* YYYY-MM-DD strings. This helper function handles this flexibility and
* translates any provided value to either a number or undefined.
*/
export function numDate(value) {
switch (typeof value) {
case "number":
return value;
case "string":
return calendarToNumeric(value, true); // allow XX ambiguity
default:
return undefined;
}
}
111 changes: 69 additions & 42 deletions src/util/colorScale.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { rgb } from "d3-color";
import { interpolateHcl } from "d3-interpolate";
import { genericDomain, colors, genotypeColors, isValueValid, NODE_VISIBLE } from "./globals";
import { countTraitsAcrossTree } from "./treeCountingHelpers";
import { getExtraVals } from "./colorHelpers";
import { getExtraVals, numDate } from "./colorHelpers";
import { isColorByGenotype, decodeColorByGenotype } from "./getGenotype";
import { setGenotype, orderOfGenotypeAppearance } from "./setGenotype";
import { getTraitFromNode } from "./treeMiscHelpers";
Expand Down Expand Up @@ -48,9 +48,12 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => {
({legendValues, colorScale} = createScaleForGenotype(tree.nodes, treeToo?.nodes, genotype.aa));
domain = [...legendValues];
} else if (colorings && colorings[colorBy]) {
if (scaleType === "continuous" || scaleType==="temporal") {
if (scaleType === "temporal" || colorBy === "num_date") {
({continuous, colorScale, legendBounds, legendValues} =
createContinuousScale(colorBy, colorings[colorBy].scale, tree.nodes, treeTooNodes, scaleType==="temporal"));
createTemporalScale(colorBy, colorings[colorBy].scale, tree.nodes, treeTooNodes));
} else if (scaleType === "continuous") {
({continuous, colorScale, legendBounds, legendValues} =
createContinuousScale(colorBy, colorings[colorBy].scale, tree.nodes, treeTooNodes));
} else if (colorings[colorBy].scale) { /* scale set via JSON */
({continuous, legendValues, colorScale} =
createNonContinuousScaleFromProvidedScaleMap(colorBy, colorings[colorBy].scale, tree.nodes, treeTooNodes));
Expand Down Expand Up @@ -218,63 +221,31 @@ function createOrdinalScale(colorBy, t1nodes, t2nodes) {
return {continuous, colorScale, legendValues, legendBounds};
}

function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes, isTemporal) {
/* Note that a temporal scale is treated very similar to a continuous one... for the time being.
In the future it'd be nice to allow YYYY-MM-DD values, but that's for another PR (and comes
with its own complexities - what about -XX dates?) james june 2022 */
// console.log("making a continuous color scale for ", colorBy);
if (colorBy==="num_date") {
/* before numeric scales were a definable type, num_date was specified as continuous */
isTemporal = true;
}
function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes) {

let minMax;
if (isTemporal) {
// empty - minMax not needed
} else if (colorBy==="lbi") {
if (colorBy==="lbi") {
minMax = [0, 0.7]; /* TODO: this is for historical reasons, and we should switch to a provided scale */
} else {
minMax = getMinMaxFromTree(t1nodes, t2nodes, colorBy);
}

/* user-defined anchor points across the scale */
const anchorPoints = _validateContinuousAnchorPoints(providedScale);
const anchorPoints = _validateAnchorPoints(providedScale, (val) => typeof val==="number");

/* make the continuous scale */
let domain, range;
if (anchorPoints) {
domain = anchorPoints.map((pt) => pt[0]);
range = anchorPoints.map((pt) => pt[1]);
} else if (isTemporal) {
/* we want the colorScale to "focus" on the tip dates, and be spaced according to sampling */
let rootDate = getTraitFromNode(t1nodes[0], colorBy);
let vals = t1nodes.filter((n) => !n.hasChildren)
.map((n) => getTraitFromNode(n, colorBy));
if (t2nodes) {
const treeTooRootDate = getTraitFromNode(t2nodes[0], colorBy);
if (treeTooRootDate < rootDate) rootDate = treeTooRootDate;
vals.concat(
t2nodes.filter((n) => !n.hasChildren)
.map((n) => getTraitFromNode(n, colorBy))
);
}
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 */
} else {
range = colors[9];
domain = genericDomain.map((d) => minMax[0] + d * (minMax[1] - minMax[0]));
}
const scale = scaleLinear().domain(domain).range(range);

let legendValues;
if (isTemporal) {
legendValues = domain.slice(1);
} else if (colorBy==="lbi") {
if (colorBy==="lbi") {
/* TODO: this is for historical reasons, and we should switch to a provided scale */
legendValues = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
huddlej marked this conversation as resolved.
Show resolved Hide resolved
} else {
Expand All @@ -298,6 +269,57 @@ function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes, isTempo
}


function createTemporalScale(colorBy, providedScale, t1nodes, t2nodes) {

let domain, range;
const anchorPoints = _validateAnchorPoints(providedScale, (val) => numDate(val)!==undefined);
if (anchorPoints) {
domain = anchorPoints.map((pt) => numDate(pt[0]));
range = anchorPoints.map((pt) => pt[1]);
} else {
/* construct a domain / range which "focuses" on the tip dates, and be spaced according to sampling */
let rootDate = numDate(getTraitFromNode(t1nodes[0], colorBy));
let vals = t1nodes.filter((n) => !n.hasChildren)
.map((n) => numDate(getTraitFromNode(n, colorBy)));
if (t2nodes) {
const treeTooRootDate = numDate(getTraitFromNode(t2nodes[0], colorBy));
if (treeTooRootDate < rootDate) rootDate = treeTooRootDate;
vals.concat(
t2nodes.filter((n) => !n.hasChildren)
.map((n) => numDate(getTraitFromNode(n, colorBy)))
);
}
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 */
}

const scale = scaleLinear().domain(domain).range(range);

const legendValues = anchorPoints ? domain.slice() : domain.slice(1);

// Hack to avoid a bug: https://github.com/nextstrain/auspice/issues/540
if (Object.is(legendValues[0], -0)) legendValues[0] = 0;

const colorScale = (val) => {
const d = numDate(val);
return d===undefined ? unknownColor : scale(d);
};

return {
continuous: true,
colorScale,
legendBounds: createLegendBounds(legendValues),
legendValues
};
}


function getMinMaxFromTree(nodes, nodesToo, attr) {
const arr = nodesToo ? nodes.concat(nodesToo) : nodes.slice();
const vals = arr.map((n) => getTraitFromNode(n, attr))
Expand Down Expand Up @@ -412,11 +434,11 @@ function createLegendBounds(legendValues) {
return legendBounds;
}

function _validateContinuousAnchorPoints(providedScale) {
function _validateAnchorPoints(providedScale, validator) {
if (!Array.isArray(providedScale)) return false;
const ap = providedScale.filter((item) =>
Array.isArray(item) && item.length===2 &&
typeof item[0]==="number" && // idx0 is the numerical value to anchor against
validator(item[0]) &&
typeof item[1]==="string" && item[1].match(/#[0-9A-Fa-f]{6}/) // schema demands full-length colour hexes
);
if (ap.length<2) return false; // need at least 2 valid points
Expand All @@ -440,6 +462,11 @@ function _validateContinuousAnchorPoints(providedScale) {
*/
function parseUserProvidedLegendData(providedLegend, currentLegendValues, scaleType) {
if (!Array.isArray(providedLegend)) return false;
if (scaleType==='temporal') {
console.error("Auspice currently doesn't allow a JSON-provided 'legend' for temporal colorings, "+
"however all provided 'scale' entries will be shown in the legend");
return false;
}

const data = scaleType==="continuous" ?
providedLegend.filter((d) => typeof d.value === "number") : // continuous scales _must_ have numeric stops
Expand Down
49 changes: 38 additions & 11 deletions src/util/dateHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,53 @@ export const numericToCalendar = (numDate) => {
};

/**
* Convert a calendar date to a numeric one.
* This function is meant to behave similarly to TreeTime's `numeric_date`
* as found in v0.7*. Note that for negative dates, i.e. BCE, no fraction
* in the year will be returned.
* Convert a YYYY-MM-DD string to a numeric date. This function is meant to
* behave similarly to TreeTime's `numeric_date` as found in v0.7*. For negative
* dates (i.e. BCE) we simply return the year (ignoring month / day). Ambiguity
* is optionally allowed in the form of YYYY-MM-XX or YYYY-XX-XX in which case
* the midpoint of the implied range is returned. All non compliant inputs
* return `undefined`.
* @param {string} calDate in format YYYY-MM-DD
* @returns {float} YYYY.F, where F is the fraction of the year passed
* @param {boolean} ambiguity
* @returns {float|undefined} YYYY.F, where F is the fraction of the year passed
*/
export const calendarToNumeric = (calDate) => {
export const calendarToNumeric = (calDate, ambiguity=false) => {
if (typeof calDate !== "string") return undefined;
if (calDate[0]==='-') {
const pieces = calDate.substring(1).split('-');
return -parseFloat(pieces[0]);
const d = -parseFloat(calDate.substring(1).split('-')[0]);
return isNaN(d) ? undefined : d;
}
/* Beware: for `Date`, months are 0-indexed, days are 1-indexed */
const [year, month, day] = calDate.split("-").map((n) => parseInt(n, 10));
const fields = calDate.split("-");
if (fields.length !== 3) return undefined;
const [year, month, day] = fields;
const [numYear, numMonth, numDay] = fields.map((d) => parseInt(d, 10));

if (calDate.includes("X")) {
if (!ambiguity) return undefined
if (year.includes("X")) return undefined;
if (month.includes("X")) {
jameshadfield marked this conversation as resolved.
Show resolved Hide resolved
if (isNaN(numYear) || month!=="XX" || day!=="XX") return undefined
return numYear + 0.5;
}
/* at this point 'day' includes 'X' */
if (isNaN(numYear) || isNaN(numMonth) || day!=='XX') return undefined
const range = [
_yearMonthDayToNumeric(numYear, numMonth, 1),
_yearMonthDayToNumeric(numMonth===12?numYear+1:numYear, numMonth===12?1:numMonth+1, 1)
]
return range[0] + (range[1]-range[0])/2
}
return _yearMonthDayToNumeric(numYear, numMonth, numDay)
};

function _yearMonthDayToNumeric(year,month,day) {
const oneDayInMs = 86400000; // 1000 * 60 * 60 * 24
/* Beware: for `Date`, months are 0-indexed, days are 1-indexed */
/* add on 1/2 day to let time represent noon (12h00) */
const elapsedDaysInYear = (Date.UTC(year, month-1, day) - Date.UTC(year, 0, 1)) / oneDayInMs + 0.5;
const fracPart = elapsedDaysInYear / (isLeapYear(year) ? 366 : 365);
return year + fracPart;
};
}

export const currentCalDate = () => dateToString(new Date());

Expand Down
Loading
Loading