auspice
Version:
Web app for visualizing pathogen evolution
985 lines (910 loc) • 38.2 kB
text/typescript
import { batch } from "react-redux";
import { quantile } from "d3-array";
import { cloneDeep } from "lodash";
import { Colorings } from "../metadata";
import { AppDispatch, ThunkFunction } from "../store";
import { colors, measurementIdSymbol } from "../util/globals";
import { ControlsState, defaultMeasurementsControlState, MeasurementsControlState, MeasurementFilters } from "../reducers/controls";
import { getDefaultMeasurementsState } from "../reducers/measurements";
import { infoNotification, warningNotification } from "./notifications";
import {
ADD_EXTRA_METADATA,
APPLY_MEASUREMENTS_FILTER,
CHANGE_MEASUREMENTS_COLLECTION,
CHANGE_MEASUREMENTS_COLOR_GROUPING,
CHANGE_MEASUREMENTS_DISPLAY,
CHANGE_MEASUREMENTS_GROUP_BY,
REMOVE_METADATA,
TOGGLE_MEASUREMENTS_OVERALL_MEAN,
TOGGLE_MEASUREMENTS_THRESHOLD,
} from "./types";
import {
Collection,
asCollection,
asMeasurement,
isMeasurementsDisplay,
measurementsDisplayValues,
Measurement,
MeasurementsDisplay,
MeasurementsJson,
MeasurementsState,
} from "../reducers/measurements/types";
import { changeColorBy } from "./colors";
import { applyFilter, updateVisibleTipsAndBranchThicknesses } from "./tree";
/**
* Temp object for groupings to keep track of values and their counts so that
* we can create a stable default order for grouping field values
*/
interface GroupingValues {
[key: string]: Map<string, number>
}
/* mf_<field> correspond to active measurements filters */
const filterQueryPrefix = "mf_";
type MeasurementsFilterQuery = `mf_${string}`
type QueryBoolean = "show" | "hide"
const queryBooleanValues: QueryBoolean[] = ["show", "hide"];
export const isQueryBoolean = (x: any): x is QueryBoolean => queryBooleanValues.includes(x)
/* Measurements query parameters that are constructed and/or parsed here. */
interface MeasurementsQuery {
m_collection?: string
m_display?: MeasurementsDisplay
m_groupBy?: string
m_overallMean?: QueryBoolean
m_threshold?: QueryBoolean
[key: MeasurementsFilterQuery]: string[]
}
/**
* Central Query type placeholder!
* Expected to be the returned object from querystring.parse()
* https://nodejs.org/docs/latest-v22.x/api/querystring.html#querystringparsestr-sep-eq-options
*/
interface Query extends MeasurementsQuery {
[key: string]: string | string[]
}
const hasMeasurementColorAttr = "_hasMeasurementColor";
const hasMeasurementColorValue = "true";
interface MeasurementsNodeAttrs {
[strain: string]: {
[key: string]: {
// number for the average measurements value
// 'true' for the presence of measurements coloring
value: number | typeof hasMeasurementColorValue
}
[hasMeasurementColorAttr]: {
value: typeof hasMeasurementColorValue
}
}
}
/**
* Using the `m-` prefix to lower chances of generated measurements coloring
* from clashing with an existing coloring on the tree (similar to how genotype
* coloring is prefixed by `gt-`). This is paired with encode/decode functions
* to ensure we have centralized methods for encoding and decoding the
* measurements coloring in case we need to expand this in the future
* (e.g. allow multiple groupingValues).
*/
const measurementColoringPrefix = "m-";
export function isMeasurementColorBy(colorBy: string): boolean {
return colorBy.startsWith(measurementColoringPrefix);
}
export function encodeMeasurementColorBy(groupingValue: string): string {
return `${measurementColoringPrefix}${groupingValue}`;
}
export function decodeMeasurementColorBy(colorBy: string): string {
const prefixPattern = new RegExp(`^${measurementColoringPrefix}`);
return colorBy.replace(prefixPattern, '');
}
/**
* Find the collection within collections that has a key matching the provided
* collectionKey. The default collection is defined by the provided defaultKey.
*
* If collectionKey is not provided, returns the default collection.
* If no matches are found, returns the default collection.
* If multiple matches are found, only returns the first matching collection.
*/
const getCollectionToDisplay = (
collections: Collection[],
collectionKey: string,
defaultKey: string
): Collection => {
const defaultCollection = collections.filter((collection) => collection.key === defaultKey)[0];
if (!collectionKey) return defaultCollection;
const potentialCollections = collections.filter((collection) => collection.key === collectionKey);
if (potentialCollections.length === 0) return defaultCollection;
if (potentialCollections.length > 1) {
console.error(`Found multiple collections with key ${collectionKey}. Returning the first matching collection only.`);
}
return potentialCollections[0];
};
/**
* Map the controlKey to the default value in collectionDefaults
* Checks if the collection default is a valid value for the control
*/
function getCollectionDefaultControl(
controlKey: string,
collection: Collection
): string | boolean | undefined {
const collectionControlToDisplayDefaults = {
measurementsGroupBy: 'group_by',
measurementsDisplay: 'measurements_display',
measurementsShowOverallMean: 'show_overall_mean',
measurementsShowThreshold: 'show_threshold'
}
const collectionDefaults = collection["display_defaults"] || {};
const displayDefaultKey = collectionControlToDisplayDefaults[controlKey];
let defaultControl = collectionDefaults[displayDefaultKey];
// Check default is a valid value for the control key
switch (controlKey) {
case 'measurementsGroupBy': {
if (defaultControl === undefined || !collection.groupings.has(defaultControl)) {
if (defaultControl !== undefined) {
console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be one of collection's groupings. Using first grouping as default`)
}
defaultControl = collection.groupings.keys().next().value;
}
break;
}
case 'measurementsDisplay': {
if (defaultControl !== undefined && !isMeasurementsDisplay(defaultControl)) {
console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be one of ${measurementsDisplayValues}`)
defaultControl = undefined;
}
break;
}
case 'measurementsShowOverallMean': {
if (defaultControl !== undefined && typeof defaultControl !== "boolean") {
console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be a boolean`)
defaultControl = undefined;
}
break;
}
case 'measurementsShowThreshold': {
if (defaultControl !== undefined) {
if (!Array.isArray(collection.thresholds) ||
!collection.thresholds.some((threshold) => typeof threshold === "number")) {
console.error(`Ignoring ${displayDefaultKey} value because collection does not have valid thresholds`)
defaultControl = undefined;
} else if (typeof defaultControl !== "boolean") {
console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be a boolean`)
defaultControl = undefined;
}
}
break;
}
case 'measurementsColorGrouping': // fallthrough
case 'measurementsFilters': {
// eslint-disable-next-line no-console
console.debug(`Skipping control key ${controlKey} because it does not have default controls`);
break;
}
default:
console.error(`Skipping unknown control key ${controlKey}`);
}
return defaultControl;
}
/**
* Returns the default control state for the provided collection
* Returns teh default control state for the app if the collection is not loaded yet
*/
function getCollectionDefaultControls(collection: Collection): MeasurementsControlState {
const defaultControls = {...defaultMeasurementsControlState};
if (Object.keys(collection).length) {
for (const [key, value] of Object.entries(defaultControls)) {
const collectionDefault = getCollectionDefaultControl(key, collection);
defaultControls[key] = collectionDefault !== undefined ? collectionDefault : value;
}
}
return defaultControls;
}
/**
* Constructs the controls redux state for the measurements panel based on
* config values within the provided collection.
*
* If no display defaults are provided, uses the current controls redux state.
* If the current `measurementsGrouping` does not exist in the collection, then
* defaults to the first grouping option.
*/
const getCollectionDisplayControls = (
controls: ControlsState,
collection: Collection
): MeasurementsControlState => {
// Copy current control options for measurements
const newControls = cloneDeep(defaultMeasurementsControlState);
Object.entries(controls).forEach(([key, value]) => {
if (key in newControls) {
newControls[key] = cloneDeep(value);
}
})
// Checks the current group by is available as a grouping in collection
// If it doesn't exist, set to undefined so it will get filled in with collection's default
if (!collection.groupings.has(newControls.measurementsGroupBy)) {
newControls.measurementsGroupBy = undefined
}
// Verify that current filters are valid for the new collection
newControls.measurementsFilters = Object.fromEntries(
Object.entries(newControls.measurementsFilters)
.map(([field, valuesMap]): [string, Map<string, {active: boolean}>] => {
// Clone nested Map to avoid changing redux state in place
// Delete filter for values that do not exist within the field of the new collection
const newValuesMap = new Map([...valuesMap].filter(([value]) => {
return collection.filters.get(field)?.values.has(value)
}));
return [field, newValuesMap];
})
.filter(([field, valuesMap]) => {
// Delete filter for field that does not exist in the new collection filters
// or filters where none of the values are valid
return collection.filters.has(field) && valuesMap.size;
})
)
// Ensure controls use collection's defaults or app defaults if this is
// the initial loading of the measurements JSON
const collectionDefaultControls = getCollectionDefaultControls(collection);
for (const [key, value] of Object.entries(newControls)) {
// Skip values that are not undefined because this indicates they are URL params or existing controls
if (value !== undefined) continue;
newControls[key] = collectionDefaultControls[key]
}
// Remove the color grouping value if it is not included for the new group by
const groupingValues = collection.groupings.get(newControls.measurementsGroupBy).values || [];
if (newControls.measurementsColorGrouping !== undefined && !groupingValues.includes(newControls.measurementsColorGrouping)) {
newControls.measurementsColorGrouping = undefined;
}
return newControls;
};
const parseMeasurementsJSON = (json: MeasurementsJson): MeasurementsState => {
const jsonCollections = json["collections"];
if (!jsonCollections || jsonCollections.length === 0) {
throw new Error("Measurements JSON does not have collections");
}
// Collection properties with the same type as JsonCollection properties.
const propertiesWithSameType = ["key", "x_axis_label", "display_defaults", "thresholds", "title"];
const collections = jsonCollections.map((jsonCollection): Collection => {
const collection: Partial<Collection> = {};
// Check for properties with the same type that can be directly copied
for (const collectionProp of propertiesWithSameType) {
if (collectionProp in jsonCollection) {
collection[collectionProp] = cloneDeep(jsonCollection[collectionProp]);
}
}
/**
* Keep backwards compatibility with single value threshold.
* Make sure thresholds are an array of values so that we don't have to
* check the data type in the D3 drawing process
* `collection.thresholds` takes precedence over the deprecated `collection.threshold`
*/
if (typeof jsonCollection.threshold === "number") {
collection.thresholds = collection.thresholds || [jsonCollection.threshold];
}
/*
* Create fields Map for easier access of titles and to keep ordering
* First add fields from JSON to keep user's ordering
* Then loop over measurements to add any remaining fields
*/
collection.fields = new Map(
(jsonCollection.fields || [])
.map(({key, title}): [string, {title: string}] => [key, {title: title || key}])
);
/**
* Create filters Map for easier access of values and to keep ordering
* First add fields from JSON to keep user's ordering
* Then loop over measurements to add values
* If there are no JSON defined filters, then add all fields as filters
*/
const collectionFiltersArray = jsonCollection.filters;
collection.filters = new Map(
(jsonCollection.filters || [])
.map((filterField): [string, {values: Set<string>}] => [filterField, {values: new Set()}])
);
// Create a temp object for groupings to keep track of values and their
// counts so that we can create a stable default order for grouping field values
const groupingsValues: GroupingValues = jsonCollection.groupings.reduce((tempObject, {key}) => {
tempObject[key] = new Map();
return tempObject;
}, {});
collection.measurements = jsonCollection.measurements.map((jsonMeasurement, index): Measurement => {
const parsedMeasurement: Partial<Measurement> = {
[measurementIdSymbol]: index
}
Object.entries(jsonMeasurement).forEach(([field, fieldValue]) => {
/**
* Convert all measurements metadata (except the `value`) to strings
* for proper matching with filter queries.
* This does mean the the `value` cannot be used as a field filter.
* We can revisit this decision when adding types to measurementsD3
* because converting `value` to string resulted in a lot of calculation errors
*/
if (field === "value") {
parsedMeasurement[field] = Number(fieldValue);
} else {
const fieldValueString = fieldValue.toString();
parsedMeasurement[field] = fieldValueString;
// Add remaining field titles
if (!collection.fields.has(field)) {
collection.fields.set(field, {title: field});
}
// Only save the unique values if the field is in defined filters
// OR there are no JSON defined filters, so all fields are filters
if ((collection.filters.has(field)) || !collectionFiltersArray) {
const filterObject = collection.filters.get(field) || { values: new Set()};
filterObject.values.add(fieldValueString);
collection.filters.set(field, filterObject);
}
// Save grouping field values and counts
if (field in groupingsValues) {
const previousValue = groupingsValues[field].get(fieldValueString);
groupingsValues[field].set(fieldValueString, previousValue ? previousValue + 1 : 1);
}
}
});
return asMeasurement(parsedMeasurement);
});
// Create groupings Map for easier access of sorted values and to keep groupings ordering
// Must be done after looping through measurements to build `groupingsValues` object
collection.groupings = new Map(
jsonCollection.groupings.map(({key, order}): [string, {values: string[]}] => {
const defaultOrder = order ? order.map((x) => x.toString()) : [];
const valuesByCount = [...groupingsValues[key].entries()]
// Use the grouping values' counts to sort the values, highest count first
.sort(([, valueCountA], [, valueCountB]) => valueCountB - valueCountA)
// Filter out values that already exist in provided order from JSON
.filter(([fieldValue]) => !defaultOrder.includes(fieldValue))
// Create array of field values
.map(([fieldValue]) => fieldValue);
return [
key,
// Prioritize the provided values order then list values by count
{values: (defaultOrder).concat(valuesByCount)}
];
})
);
return asCollection(collection);
});
const collectionKeys = collections.map((collection) => collection.key);
let defaultCollectionKey = json["default_collection"];
if (!collectionKeys.includes(defaultCollectionKey)) {
defaultCollectionKey = collectionKeys[0];
}
const collectionToDisplay = collections.filter((collection) => collection.key === defaultCollectionKey)[0];
return {
loaded: true,
error: undefined,
defaultCollectionKey,
collections,
collectionToDisplay
}
};
export const loadMeasurements = (
measurementsData: MeasurementsJson | Error,
dispatch: AppDispatch
): MeasurementsState => {
let measurementState = getDefaultMeasurementsState();
/* Just return default state there are no measurements data to load */
if (!measurementsData) {
return measurementState
}
let warningMessage = "";
if (measurementsData instanceof Error) {
console.error(measurementsData);
warningMessage = "Failed to fetch measurements collections";
} else {
try {
measurementState = { ...measurementState, ...parseMeasurementsJSON(measurementsData) };
} catch (error) {
console.error(error);
warningMessage = "Failed to parse measurements collections";
}
}
if (warningMessage) {
measurementState.error = warningMessage;
dispatch(warningNotification({ message: warningMessage }));
}
return measurementState;
};
export const changeMeasurementsCollection = (
newCollectionKey: string
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const collectionToDisplay = getCollectionToDisplay(measurements.collections, newCollectionKey, measurements.defaultCollectionKey);
const newControls = getCollectionDisplayControls(controls, collectionToDisplay);
const queryParams = createMeasurementsQueryFromControls(newControls, collectionToDisplay, measurements.defaultCollectionKey);
batch(() => {
dispatch({
type: CHANGE_MEASUREMENTS_COLLECTION,
collectionToDisplay,
controls: newControls,
queryParams
});
/* After the collection has been updated, update the measurement coloring data if needed */
updateMeasurementsColorData(
newControls.measurementsColorGrouping,
controls.measurementsColorGrouping,
controls.colorBy,
controls.defaults.colorBy,
dispatch
);
});
};
function updateMeasurementsFilters(
newFilters: MeasurementFilters,
controls: ControlsState,
measurements: MeasurementsState,
dispatch: AppDispatch
): void {
const newControls: Partial<MeasurementsControlState> = {
measurementsFilters: newFilters,
}
batch(() => {
dispatch({
type: APPLY_MEASUREMENTS_FILTER,
controls: newControls,
queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey)
});
/**
* Filtering does _not_ affect the measurementsColorGrouping value, but
* the measurements metadata does need to be updated to reflect the
* filtered measurements
*/
updateMeasurementsColorData(
controls.measurementsColorGrouping,
controls.measurementsColorGrouping,
controls.colorBy,
controls.defaults.colorBy,
dispatch
);
});
}
/*
* The filter actions below will create a copy of `controls.measurementsFilters`
* then clone the nested Map to avoid changing the redux state in place.
* Tried to use lodash.cloneDeep(), but it did not work for the nested Map
* - Jover, 19 January 2022
*/
export const applyMeasurementFilter = (
field: string,
value: string,
active: boolean
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
measurementsFilters[field].set(value, {active});
updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch);
};
export const removeSingleFilter = (
field: string,
value: string
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
measurementsFilters[field].delete(value);
// If removing the single filter leaves 0 filters for the field, completely
// remove the field from the filters state
if (measurementsFilters[field].size === 0) {
delete measurementsFilters[field];
}
updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch);
};
export const removeAllFieldFilters = (
field: string
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
delete measurementsFilters[field];
updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch);
};
export const toggleAllFieldFilters = (
field: string,
active: boolean
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const measurementsFilters = {...controls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
for (const fieldValue of measurementsFilters[field].keys()) {
measurementsFilters[field].set(fieldValue, {active});
}
updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch);
};
export const toggleOverallMean = (): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const controlKey = "measurementsShowOverallMean";
const newControls = { [controlKey]: !controls[controlKey] };
dispatch({
type: TOGGLE_MEASUREMENTS_OVERALL_MEAN,
controls: newControls,
queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey)
});
}
export const toggleThreshold = (): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const controlKey = "measurementsShowThreshold";
const newControls = { [controlKey]: !controls[controlKey] };
dispatch({
type: TOGGLE_MEASUREMENTS_THRESHOLD,
controls: newControls,
queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey)
});
};
export const changeMeasurementsDisplay = (
newDisplay: MeasurementsDisplay
): ThunkFunction => (dispatch, getState) => {
const { measurements } = getState();
const controlKey = "measurementsDisplay";
const newControls = { [controlKey]: newDisplay };
dispatch({
type: CHANGE_MEASUREMENTS_DISPLAY,
controls: newControls,
queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey)
});
}
export const changeMeasurementsGroupBy = (
newGroupBy: string
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const groupingValues = measurements.collectionToDisplay.groupings.get(newGroupBy).values || [];
const newControls: Partial<MeasurementsControlState> = {
/* If the measurementsColorGrouping is no longer valid, then set to undefined */
measurementsColorGrouping: groupingValues.includes(controls.measurementsColorGrouping)
? controls.measurementsColorGrouping
: undefined,
measurementsGroupBy: newGroupBy
};
batch(() => {
dispatch({
type: CHANGE_MEASUREMENTS_GROUP_BY,
controls: newControls,
queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey)
});
/* After the group by has been updated, update the measurement coloring data if needed */
updateMeasurementsColorData(
newControls.measurementsColorGrouping,
controls.measurementsColorGrouping,
controls.colorBy,
controls.defaults.colorBy,
dispatch
);
})
}
export function getActiveMeasurementFilters(
filters: MeasurementFilters
): {string?: string[]} {
// Find active filters to filter measurements
const activeFilters: {string?: string[]} = {};
Object.entries(filters).forEach(([field, valuesMap]) => {
activeFilters[field] = activeFilters[field] || [];
valuesMap.forEach(({active}, fieldValue) => {
// Save array of active values for the field filter
if (active) activeFilters[field].push(fieldValue);
});
});
return activeFilters;
}
export function matchesAllActiveFilters(
measurement: Measurement,
activeFilters: {string?: string[]}
): boolean {
for (const [field, values] of Object.entries(activeFilters)) {
const measurementValue = measurement[field];
if (values.length > 0 &&
((typeof measurementValue === "string") && !values.includes(measurementValue))){
return false;
}
}
return true;
}
function createMeasurementsColoringData(
filters: MeasurementFilters,
groupBy: string,
groupingValue: string,
collection: Collection,
): {
nodeAttrs: MeasurementsNodeAttrs,
colorings: Colorings,
} {
const measurementColorBy = encodeMeasurementColorBy(groupingValue);
const activeMeasurementFilters = getActiveMeasurementFilters(filters);
const strainMeasurementValues: {[strain: string]: number[]} = collection.measurements
.filter((m) => m[groupBy] === groupingValue && matchesAllActiveFilters(m, activeMeasurementFilters))
.reduce((accum, m) => {
(accum[m.strain] = accum[m.strain] || []).push(m.value)
return accum
}, {});
const nodeAttrs: MeasurementsNodeAttrs = {};
for (const [strain, measurements] of Object.entries(strainMeasurementValues)) {
const averageMeasurementValue = measurements.reduce((sum, value) => sum + value) / measurements.length;
nodeAttrs[strain] = {
[measurementColorBy]: {
value: averageMeasurementValue
},
[hasMeasurementColorAttr]: {
value: hasMeasurementColorValue
}
};
}
const sortedValues = collection.measurements
.map((m) => m.value)
.sort((a, b) => a - b);
// Matching the default coloring for continuous scales
const colorRange = colors[9];
const step = 1 / (colorRange.length - 1);
const measurementsColorScale: [number, string][] = colorRange.map((color, i) => {
return [quantile(sortedValues, (step * i)), color]
});
return {
nodeAttrs,
colorings: {
[measurementColorBy]: {
title: `Measurements (${groupingValue})`,
type: "continuous",
scale: measurementsColorScale,
},
[hasMeasurementColorAttr]: {
title: `Has measurements for ${groupingValue}`,
type: "boolean",
}
}
};
}
const addMeasurementsColorData = (
groupingValue: string
): ThunkFunction => (dispatch, getState) => {
const { controls, measurements } = getState();
const { nodeAttrs, colorings } = createMeasurementsColoringData(
controls.measurementsFilters,
controls.measurementsGroupBy,
groupingValue,
measurements.collectionToDisplay,
);
dispatch({type: ADD_EXTRA_METADATA, newNodeAttrs: nodeAttrs, newColorings: colorings});
}
function updateMeasurementsColorData(
newColorGrouping: string,
oldColorGrouping: string,
currentColorBy: string,
defaultColorBy: string,
dispatch: AppDispatch,
): void {
/* Remove the measurement metadata and coloring for the old grouping */
if (oldColorGrouping !== undefined) {
/* Fallback to the default coloring because the measurements coloring is no longer valid */
if (newColorGrouping !== oldColorGrouping &&
currentColorBy === encodeMeasurementColorBy(oldColorGrouping)) {
dispatch(infoNotification({
message: "Measurement coloring is no longer valid",
details: "Falling back to the default color-by"
}));
dispatch(changeColorBy(defaultColorBy));
dispatch(applyFilter("remove", hasMeasurementColorAttr, [hasMeasurementColorValue]));
}
dispatch({
type: REMOVE_METADATA,
nodeAttrsToRemove: [hasMeasurementColorAttr, encodeMeasurementColorBy(oldColorGrouping)],
})
}
/* If there is a valid new color grouping, then add the measurement metadata and coloring */
if (newColorGrouping !== undefined) {
dispatch(addMeasurementsColorData(newColorGrouping));
dispatch(updateVisibleTipsAndBranchThicknesses());
}
}
export const applyMeasurementsColorBy = (
groupingValue: string
): ThunkFunction => (dispatch, getState) => {
const { controls } = getState();
/**
* Batching all dispatch actions together to prevent multiple renders
* This is also _required_ to prevent error in calcColorScale during extra renders:
* 1. REMOVE_METADATA removes current measurements coloring from metadata.colorings
* 2. This triggers the componentDidUpdate in controls/color-by, which dispatches changeColorBy.
* 3. calcColorScale throws error because the current coloring is no longer valid as it was removed by REMOVE_METADATA in step 1.
*/
batch(() => {
if (controls.measurementsColorGrouping !== undefined) {
dispatch({type: REMOVE_METADATA, nodeAttrsToRemove: [hasMeasurementColorAttr, encodeMeasurementColorBy(controls.measurementsColorGrouping)]});
}
if (controls.measurementsColorGrouping !== groupingValue) {
dispatch({type: CHANGE_MEASUREMENTS_COLOR_GROUPING, controls:{measurementsColorGrouping: groupingValue}});
}
dispatch(addMeasurementsColorData(groupingValue));
dispatch(changeColorBy(encodeMeasurementColorBy(groupingValue)));
dispatch(applyFilter("add", hasMeasurementColorAttr, [hasMeasurementColorValue]))
});
}
const controlToQueryParamMap = {
measurementsDisplay: "m_display",
measurementsGroupBy: "m_groupBy",
measurementsShowOverallMean: "m_overallMean",
measurementsShowThreshold: "m_threshold",
};
export function removeInvalidMeasurementsFilterQuery(
query: Query,
newQueryParams: {[key: MeasurementsFilterQuery]: string}
): Query {
const newQuery = cloneDeep(query);
// Remove measurements filter query params that are not included in the newQueryParams
Object.keys(query)
.filter((queryParam) => queryParam.startsWith(filterQueryPrefix) && !(queryParam in newQueryParams))
.forEach((queryParam) => delete newQuery[queryParam]);
return newQuery
}
function createMeasurementsQueryFromControls(
measurementControls: Partial<MeasurementsControlState>,
collection: Collection,
defaultCollectionKey: string
): MeasurementsQuery {
const newQuery = {
m_collection: collection.key === defaultCollectionKey ? "" : collection.key
};
for (const [controlKey, controlValue] of Object.entries(measurementControls)) {
let queryKey = controlToQueryParamMap[controlKey];
const collectionDefault = getCollectionDefaultControl(controlKey, collection);
const controlDefault = collectionDefault !== undefined ? collectionDefault : defaultMeasurementsControlState[controlKey];
// Remove URL param if control state is the same as the default state
if (controlValue === controlDefault) {
newQuery[queryKey] = "";
} else {
switch(controlKey) {
case "measurementsDisplay": // fallthrough
case "measurementsGroupBy":
newQuery[queryKey] = controlValue;
break;
case "measurementsShowOverallMean":
newQuery[queryKey] = controlValue ? "show" : "hide";
break;
case "measurementsShowThreshold":
if (collection.thresholds) {
newQuery[queryKey] = controlValue ? "show" : "hide";
} else {
newQuery[queryKey] = "";
}
break;
case "measurementsFilters":
// First clear all of the measurements filter query params
for (const field of collection.filters.keys()) {
queryKey = filterQueryPrefix + field;
newQuery[queryKey] = "";
}
// Then add back measurements filter query params for active filters only
for (const [field, values] of Object.entries(controlValue)) {
queryKey = filterQueryPrefix + field;
const activeFilterValues = [...values]
.filter(([_, {active}]) => active)
.map(([fieldValue]) => fieldValue);
newQuery[queryKey] = activeFilterValues;
}
break;
default:
console.error(`Ignoring unsupported control ${controlKey}`);
}
}
}
return newQuery;
}
/**
* Parses the current collection's controls from measurements and updates them
* with valid query parameters.
*
* In cases where the query param is invalid, the query param is removed from the
* returned query object.
*/
export const combineMeasurementsControlsAndQuery = (
measurements: MeasurementsState,
query: Query
): {
collectionToDisplay: Collection,
collectionControls: MeasurementsControlState,
updatedQuery: Query,
newColoringData: undefined | {
coloringsPresentOnTree: string[],
colorings: Colorings,
nodeAttrs: MeasurementsNodeAttrs,
},
} => {
const updatedQuery = cloneDeep(query);
const collectionKeys = measurements.collections.map((collection) => collection.key);
// Remove m_collection query if it's invalid or the default collection key
if (!collectionKeys.includes(updatedQuery.m_collection) ||
updatedQuery.m_collection === measurements.defaultCollectionKey) {
delete updatedQuery.m_collection;
}
// Parse collection's default controls
const collectionKey = updatedQuery.m_collection || measurements.defaultCollectionKey;
const collectionToDisplay = getCollectionToDisplay(measurements.collections, collectionKey, measurements.defaultCollectionKey)
const collectionControls = getCollectionDefaultControls(collectionToDisplay);
const collectionGroupings = Array.from(collectionToDisplay.groupings.keys());
// Modify controls via query
for (const [controlKey, queryKey] of Object.entries(controlToQueryParamMap)) {
const queryValue = updatedQuery[queryKey];
if (queryValue === undefined) continue;
let newControlState = undefined;
switch(queryKey) {
case "m_display":
if (isMeasurementsDisplay(queryValue)) {
newControlState = queryValue;
}
break;
case "m_groupBy":
// Verify value is a valid grouping of collection
if (typeof queryValue === "string" && collectionGroupings.includes(queryValue)) {
newControlState = queryValue;
}
break;
case "m_overallMean":
if (isQueryBoolean(queryValue)) {
newControlState = queryValue === "show";
}
break;
case "m_threshold":
if (collectionToDisplay.thresholds && isQueryBoolean(queryValue)) {
newControlState = queryValue === "show";
}
break;
default:
console.error(`Ignoring unsupported query ${queryKey}`);
}
// Remove query if it's invalid or the same as the collection's default controls
if (newControlState === undefined || newControlState === collectionControls[controlKey]) {
delete updatedQuery[queryKey];
continue;
}
collectionControls[controlKey] = newControlState
}
// Special handling of the filter query since these can be arbitrary query keys `mf_*`
for (const filterKey of Object.keys(updatedQuery).filter((c) => c.startsWith(filterQueryPrefix))) {
// Remove and ignore query for invalid fields
const field = filterKey.replace(filterQueryPrefix, '');
if (!collectionToDisplay.filters.has(field)) {
delete updatedQuery[filterKey];
continue;
}
// Remove and ignore query for invalid field values
let filterValues = updatedQuery[filterKey];
if (typeof filterValues === "string") {
filterValues = Array(filterValues);
}
const collectionFieldValues = collectionToDisplay.filters.get(field).values;
const validFilterValues = filterValues.filter((value) => collectionFieldValues.has(value));
if (!validFilterValues.length) {
delete updatedQuery[filterKey];
continue;
}
// Set field filter controls and query to the valid filter values
updatedQuery[filterKey] = validFilterValues;
const measurementsFilters = {...collectionControls.measurementsFilters};
measurementsFilters[field] = new Map(measurementsFilters[field]);
for (const value of validFilterValues) {
measurementsFilters[field].set(value, {active: true});
}
collectionControls.measurementsFilters = measurementsFilters;
}
// Special handling of the coloring query since this is _not_ a measurement specific query
// This must be after handling of filters so that the color data takes filters into account
let newColoringData = undefined;
if (typeof(updatedQuery.c) === 'string' && isMeasurementColorBy(updatedQuery.c)) {
const colorGrouping = decodeMeasurementColorBy(updatedQuery.c);
const groupingValues = collectionToDisplay.groupings.get(collectionControls.measurementsGroupBy).values || [];
// If the color grouping value is invalid, then remove the coloring query
// otherwise create the node attrs and coloring data needed for the measurements color-by
if (!groupingValues.includes(colorGrouping)) {
updatedQuery.c = undefined;
} else {
collectionControls['measurementsColorGrouping'] = colorGrouping;
newColoringData = {
coloringsPresentOnTree: [updatedQuery.c],
...createMeasurementsColoringData(
collectionControls.measurementsFilters,
collectionControls.measurementsGroupBy,
colorGrouping,
collectionToDisplay
),
}
}
}
return {
collectionToDisplay,
collectionControls,
updatedQuery,
newColoringData,
}
}