Skip to content

Commit

Permalink
Merge pull request #1832 from nextstrain/james/temporal-yyyy-mm-dd
Browse files Browse the repository at this point in the history
Allow YYYY-MM-DD values in a temporal scales
  • Loading branch information
jameshadfield authored Aug 27, 2024
2 parents 331d87e + 6cad339 commit c585722
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 89 deletions.
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];
} 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")) {
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

0 comments on commit c585722

Please sign in to comment.