diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ce4a662..c98c72c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +* Added a new color tree by measurements feature. +Clicking on a group in the measurements panel will add a new coloring to the tree, +where the colors represent an average of the measurement values for the matching +test strain within the selected measurements group. +For full details, please see [#1924](https://github.com/nextstrain/auspice/pull/1924). * Bugfix: Clicking on the icons for FULL and GRID layout now changes the layout, just like clicking on the text ([#1911](https://github.com/nextstrain/auspice/issues/1911)) ## version 2.61.2 - 2024/11/19 diff --git a/src/actions/measurements.ts b/src/actions/measurements.ts index 6e4bc5a91..7f92da38c 100644 --- a/src/actions/measurements.ts +++ b/src/actions/measurements.ts @@ -1,14 +1,20 @@ +import { batch } from "react-redux"; +import { quantile } from "d3-array"; import { cloneDeep } from "lodash"; +import { Colorings } from "../metadata"; import { AppDispatch, ThunkFunction } from "../store"; -import { measurementIdSymbol } from "../util/globals"; -import { ControlsState, defaultMeasurementsControlState, MeasurementsControlState } from "../reducers/controls"; +import { colors, measurementIdSymbol } from "../util/globals"; +import { ControlsState, defaultMeasurementsControlState, MeasurementsControlState, MeasurementFilters } from "../reducers/controls"; import { getDefaultMeasurementsState } from "../reducers/measurements"; -import { warningNotification } from "./notifications"; +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"; @@ -23,6 +29,8 @@ import { 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 @@ -58,6 +66,42 @@ 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. @@ -136,6 +180,7 @@ function getCollectionDefaultControl( } 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`); @@ -214,6 +259,12 @@ const getCollectionDisplayControls = ( 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; }; @@ -392,14 +443,56 @@ export const changeMeasurementsCollection = ( const newControls = getCollectionDisplayControls(controls, collectionToDisplay); const queryParams = createMeasurementsQueryFromControls(newControls, collectionToDisplay, measurements.defaultCollectionKey); - dispatch({ - type: CHANGE_MEASUREMENTS_COLLECTION, - collectionToDisplay, - controls: newControls, - queryParams + 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 = { + 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. @@ -416,11 +509,7 @@ export const applyMeasurementFilter = ( measurementsFilters[field] = new Map(measurementsFilters[field]); measurementsFilters[field].set(value, {active}); - dispatch({ - type: APPLY_MEASUREMENTS_FILTER, - controls: { measurementsFilters }, - queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) - }); + updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch); }; export const removeSingleFilter = ( @@ -438,11 +527,7 @@ export const removeSingleFilter = ( delete measurementsFilters[field]; } - dispatch({ - type: APPLY_MEASUREMENTS_FILTER, - controls: { measurementsFilters }, - queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) - }); + updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch); }; export const removeAllFieldFilters = ( @@ -452,11 +537,7 @@ export const removeAllFieldFilters = ( const measurementsFilters = {...controls.measurementsFilters}; delete measurementsFilters[field]; - dispatch({ - type: APPLY_MEASUREMENTS_FILTER, - controls: { measurementsFilters }, - queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) - }); + updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch); }; export const toggleAllFieldFilters = ( @@ -469,11 +550,7 @@ export const toggleAllFieldFilters = ( for (const fieldValue of measurementsFilters[field].keys()) { measurementsFilters[field].set(fieldValue, {active}); } - dispatch({ - type: APPLY_MEASUREMENTS_FILTER, - controls: { measurementsFilters }, - queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) - }); + updateMeasurementsFilters(measurementsFilters, controls, measurements, dispatch); }; export const toggleOverallMean = (): ThunkFunction => (dispatch, getState) => { @@ -517,14 +594,186 @@ export const changeMeasurementsDisplay = ( export const changeMeasurementsGroupBy = ( newGroupBy: string ): ThunkFunction => (dispatch, getState) => { - const { measurements } = getState(); - const controlKey = "measurementsGroupBy"; - const newControls = { [controlKey]: newGroupBy }; + const { controls, measurements } = getState(); + const groupingValues = measurements.collectionToDisplay.groupings.get(newGroupBy).values || []; + const newControls: Partial = { + /* If the measurementsColorGrouping is no longer valid, then set to undefined */ + measurementsColorGrouping: groupingValues.includes(controls.measurementsColorGrouping) + ? controls.measurementsColorGrouping + : undefined, + measurementsGroupBy: newGroupBy + }; - dispatch({ - type: CHANGE_MEASUREMENTS_GROUP_BY, - controls: newControls, - queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey) + 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])) }); } @@ -614,7 +863,12 @@ export const combineMeasurementsControlsAndQuery = ( ): { collectionToDisplay: Collection, collectionControls: MeasurementsControlState, - updatedQuery: Query + updatedQuery: Query, + newColoringData: undefined | { + coloringsPresentOnTree: string[], + colorings: Colorings, + nodeAttrs: MeasurementsNodeAttrs, + }, } => { const updatedQuery = cloneDeep(query); const collectionKeys = measurements.collections.map((collection) => collection.key); @@ -696,11 +950,35 @@ export const combineMeasurementsControlsAndQuery = ( 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 + updatedQuery, + newColoringData, } } diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 09d734dbe..ef9e3c680 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -18,11 +18,11 @@ import { computeMatrixFromRawData, checkIfNormalizableFromRawData } from "../uti import { applyInViewNodesToTree } from "../actions/tree"; import { validateScatterVariables } from "../util/scatterplotHelpers"; import { isColorByGenotype, decodeColorByGenotype, encodeColorByGenotype, decodeGenotypeFilters, encodeGenotypeFilters, getCdsFromGenotype } from "../util/getGenotype"; -import { getTraitFromNode, getDivFromNode, collectGenotypeStates } from "../util/treeMiscHelpers"; +import { getTraitFromNode, getDivFromNode, collectGenotypeStates, addNodeAttrs, removeNodeAttrs } from "../util/treeMiscHelpers"; import { collectAvailableTipLabelOptions } from "../components/controls/choose-tip-label"; import { hasMultipleGridPanels } from "./panelDisplay"; import { strainSymbolUrlString } from "../middleware/changeURL"; -import { combineMeasurementsControlsAndQuery, loadMeasurements } from "./measurements"; +import { combineMeasurementsControlsAndQuery, encodeMeasurementColorBy, loadMeasurements } from "./measurements"; export const doesColorByHaveConfidence = (controlsState, colorBy) => controlsState.coloringsPresentOnTreeWithConfidence.has(colorBy); @@ -919,11 +919,65 @@ export const createStateFromQueryOrJSONs = ({ controls = modifyStateViaURLQuery(controls, query); /* Special handling of measurements controls and query params */ + let newMeasurementsColoringData = false; if (measurements.loaded) { - const { collectionToDisplay, collectionControls, updatedQuery} = combineMeasurementsControlsAndQuery(measurements, query); + const { + collectionToDisplay, + collectionControls, + updatedQuery, + newColoringData, + } = combineMeasurementsControlsAndQuery(measurements, query); measurements.collectionToDisplay = collectionToDisplay; controls = {...controls, ...collectionControls}; query = updatedQuery; + + /** + * Similar to state changes applied for `REMOVE_METADATA` + * Remove the old measurements coloring data before adding the new data, + * which is necessary for changing measurements coloring in narratives + */ + if (oldState?.controls?.measurementsColorGrouping !== undefined) { + const colorByToRemove = encodeMeasurementColorBy(oldState.controls.measurementsColorGrouping); + // Update controls + controls.coloringsPresentOnTree.delete(colorByToRemove); + // Update metadata + if (colorByToRemove in metadata.colorings) { + delete metadata.colorings[colorByToRemove]; + } + // Update tree + removeNodeAttrs(tree.nodes, [colorByToRemove]); + tree.nodeAttrKeys.delete(colorByToRemove); + if (colorByToRemove in tree.totalStateCounts) { + delete tree.totalStateCounts[colorByToRemove]; + } + // Update treeToo if exists + if (treeToo && treeToo.loaded) { + removeNodeAttrs(treeToo.nodes, [colorByToRemove]); + } + } + + // Similar to the state changes applied for `ADD_EXTRA_METADATA` + if (newColoringData !== undefined) { + newMeasurementsColoringData = true; + // Update controls + newColoringData.coloringsPresentOnTree.forEach((coloring) => controls.coloringsPresentOnTree.add(coloring)); + // Update metadata + metadata.colorings = {...metadata.colorings, ...newColoringData.colorings}; + // Update tree + addNodeAttrs(tree.nodes, newColoringData.nodeAttrs); + Object.keys(newColoringData.colorings).forEach((attr) => tree.nodeAttrKeys.add(attr)); + const nonContinuousColorings = Object.keys(newColoringData.colorings).filter((coloring) => { + return newColoringData.colorings[coloring].type !== "continuous" + }); + tree.totalStateCounts = { + ...tree.totalStateCounts, + ...countTraitsAcrossTree(tree.nodes, nonContinuousColorings, false, true) + }; + // Update treeToo if exists + if (treeToo && treeToo.loaded) { + addNodeAttrs(treeToo.nodes, newColoringData.nodeAttrs); + } + } } else { // Hide measurements panel if loading failed controls.panelsToDisplay = controls.panelsToDisplay.filter((panel) => panel !== "measurements"); @@ -950,7 +1004,7 @@ export const createStateFromQueryOrJSONs = ({ /* calculate colours if loading from JSONs or if the query demands change */ - if (json || controls.colorBy !== oldState.controls.colorBy) { + if (json || controls.colorBy !== oldState.controls.colorBy || newMeasurementsColoringData) { const colorScale = calcColorScale(controls.colorBy, controls, tree, treeToo, metadata); const nodeColors = calcNodeColor(tree, colorScale); controls.colorScale = colorScale; diff --git a/src/actions/types.js b/src/actions/types.js index dffa484af..93512bab3 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -31,6 +31,7 @@ export const CHANGE_PANEL_LAYOUT = "CHANGE_PANEL_LAYOUT"; export const TOGGLE_PANEL_DISPLAY = "TOGGLE_PANEL_DISPLAY"; export const SET_MODAL = "SET_MODAL"; export const ADD_EXTRA_METADATA = "ADD_EXTRA_METADATA"; +export const REMOVE_METADATA = "REMOVE_METADATA"; export const CHANGE_TREE_ROOT_IDX = "CHANGE_TREE_ROOT_IDX"; export const TOGGLE_NARRATIVE = "TOGGLE_NARRATIVE"; export const ENTROPY_DATA = "ENTROPY_DATA"; @@ -57,6 +58,7 @@ export const CHANGE_MEASUREMENTS_GROUP_BY = "CHANGE_MEASUREMENTS_GROUP_BY"; export const TOGGLE_MEASUREMENTS_THRESHOLD = "TOGGLE_MEASUREMENTS_THRESHOLD"; export const TOGGLE_MEASUREMENTS_OVERALL_MEAN = "TOGGLE_MEASUREMENTS_OVERALL_MEAN"; export const CHANGE_MEASUREMENTS_DISPLAY = "CHANGE_MEASUREMENTS_DISPLAY"; +export const CHANGE_MEASUREMENTS_COLOR_GROUPING = "CHANGE_MEASUREMENTS_COLOR_GROUPING"; export const APPLY_MEASUREMENTS_FILTER = "APPLY_MEASUREMENTS_FILTER"; export const TOGGLE_SHOW_ALL_BRANCH_LABELS = "TOGGLE_SHOW_ALL_BRANCH_LABELS"; export const TOGGLE_MOBILE_DISPLAY = "TOGGLE_MOBILE_DISPLAY"; diff --git a/src/components/measurements/index.tsx b/src/components/measurements/index.tsx index 43be5b9c2..b42c1307d 100644 --- a/src/components/measurements/index.tsx +++ b/src/components/measurements/index.tsx @@ -1,5 +1,5 @@ import React, { CSSProperties, MutableRefObject, useCallback, useRef, useEffect, useMemo, useState } from "react"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { isEqual, orderBy } from "lodash"; import { NODE_VISIBLE } from "../../util/globals"; import { getColorByTitle, getTipColorAttribute } from "../../util/colorHelpers"; @@ -23,12 +23,21 @@ import { addHoverPanelToMeasurementsAndMeans, addColorByAttrToGroupingLabel, layout, - jitterRawMeansByColorBy + jitterRawMeansByColorBy, + addGroupingValueCrosshair, + removeColorGroupingCrosshair, } from "./measurementsD3"; import { RootState } from "../../store"; import { MeasurementFilters } from "../../reducers/controls"; import { Visibility } from "../../reducers/tree/types"; import { Measurement, isMeasurement } from "../../reducers/measurements/types"; +import { + applyMeasurementsColorBy, + isMeasurementColorBy, + getActiveMeasurementFilters, + matchesAllActiveFilters +} from "../../actions/measurements"; +import { changeColorBy } from "../../actions/colors"; interface MeanAndStandardDeviation { mean: number @@ -131,14 +140,7 @@ const filterMeasurements = ( filteredMeasurements: Measurement[] } => { // 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); - }); - }); + const activeFilters = getActiveMeasurementFilters(filters); return { activeFilters, @@ -146,24 +148,21 @@ const filterMeasurements = ( // First check the strain is visible in the tree if (!isVisible(treeStrainVisibility[measurement.strain])) return false; // Then check that the measurement contains values for all active filters - 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; + return matchesAllActiveFilters(measurement, activeFilters); }) }; }; const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { + const dispatch = useDispatch(); // Use `lodash.isEqual` to deep compare object states to prevent unnecessary re-renderings of the component const { treeStrainVisibility, treeStrainColors } = useSelector((state: RootState) => treeStrainPropertySelector(state), isEqual); - const legendValues = useSelector((state: RootState) => state.controls.colorScale.legendValues, isEqual); + // Convert legendValues to string to ensure that subsequent attribute matches work as intended + const legendValues = useSelector((state: RootState) => state.controls.colorScale.legendValues.map(String), isEqual); const colorings = useSelector((state: RootState) => state.metadata.colorings); const colorBy = useSelector((state: RootState) => state.controls.colorBy); + const defaultColorBy = useSelector((state: RootState) => state.controls.defaults.colorBy); + const colorGrouping = useSelector((state: RootState) => state.controls.measurementsColorGrouping); const groupBy = useSelector((state: RootState) => state.controls.measurementsGroupBy); const filters = useSelector((state: RootState) => state.controls.measurementsFilters); const display = useSelector((state: RootState) => state.controls.measurementsDisplay); @@ -257,6 +256,28 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { setHoverData(newHoverData); }, [fields, colorings, colorBy]); + /** + * Ref to save previous non-measurements coloring for toggling back to previous + * coloring when clicking on the same measurements grouping twice. + * Uses the default color by if the color is a measurements color on first + * load, i.e. the color is set by the URL param `c=m-` + */ + const prevNonMeasurementColorBy: MutableRefObject = useRef(isMeasurementColorBy(colorBy) ? defaultColorBy : colorBy); + useEffect(() => { + if (!isMeasurementColorBy(colorBy)) { + prevNonMeasurementColorBy.current = colorBy; + } + }, [colorBy]); + + const handleClickOnGrouping = useCallback((grouping: string): void => { + if (grouping !== colorGrouping || !isMeasurementColorBy(colorBy)) { + dispatch(applyMeasurementsColorBy(grouping)); + } else if (grouping === colorGrouping && isMeasurementColorBy(colorBy)) { + // Clicking on the same grouping twice will toggle back to the previous non-measurements coloring + dispatch(changeColorBy(prevNonMeasurementColorBy.current)); + } + }, [dispatch, colorGrouping, colorBy]); + useEffect(() => { setPanelTitle(`${title || "Measurements"} (grouped by ${fields.get(groupBy).title})`); }, [setPanelTitle, title, fields, groupBy]); @@ -267,8 +288,8 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { // the scroll position on whitespace. svgContainerRef.current.scrollTop = 0; clearMeasurementsSVG(d3Ref.current, d3XAxisRef.current); - drawMeasurementsSVG(d3Ref.current, d3XAxisRef.current, svgData); - }, [svgData]); + drawMeasurementsSVG(d3Ref.current, d3XAxisRef.current, svgData, handleClickOnGrouping); + }, [svgData, handleClickOnGrouping]); // Color the SVG & redraw color-by means when SVG is re-drawn or when colors have changed useEffect(() => { @@ -292,6 +313,14 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { toggleDisplay(d3Ref.current, "threshold", showThreshold); }, [svgData, showThreshold]); + useEffect(() => { + if(isMeasurementColorBy(colorBy)) { + addGroupingValueCrosshair(d3Ref.current, colorGrouping); + } else { + removeColorGroupingCrosshair(d3Ref.current); + } + }, [svgData, colorBy, colorGrouping]) + const getSVGContainerStyle = (): CSSProperties => { return { overflowY: "auto", diff --git a/src/components/measurements/measurementsD3.js b/src/components/measurements/measurementsD3.js index de4693ed5..41f5f1cea 100644 --- a/src/components/measurements/measurementsD3.js +++ b/src/components/measurements/measurementsD3.js @@ -39,6 +39,7 @@ layout['overallMeanYValue'] = layout.subplotHeight / 2; const classes = { xAxis: "measurementXAxis", yAxis: "measurementYAxis", + groupingValue: "measurementGroupingValue", yAxisColorByLabel: "measurementYAxisColorByLabel", threshold: "measurementThreshold", subplot: "measurementSubplot", @@ -189,7 +190,7 @@ const drawStickyXAxis = (ref, containerHeight, svgHeight, xScale, x_axis_label) .text(x_axis_label); }; -export const drawMeasurementsSVG = (ref, xAxisRef, svgData) => { +export const drawMeasurementsSVG = (ref, xAxisRef, svgData, handleClickOnGrouping) => { const {containerHeight, xScale, yScale, x_axis_label, thresholds, groupingOrderedValues, groupedMeasurements} = svgData; // Do not draw SVG if there are no measurements @@ -251,12 +252,18 @@ export const drawMeasurementsSVG = (ref, xAxisRef, svgData) => { subplot.append("g") .attr("class", classes.yAxis) .attr("transform", `translate(${layout.leftPadding}, 0)`) + .attr("cursor", "pointer") .call( axisLeft(yScale) .tickValues([yScale((layout.yMax - layout.yMin) / 2)]) .tickSize(layout.yAxisTickSize) .tickFormat(groupingValue)) .call((g) => { + // Add tooltip to hint at color by measurements feature + g.selectAll('.tick') + .append("title") + .text("Click to color by an average of all measurement values per test strain in this group"); + g.attr("font-family", null); // If necessary, scale down the text to fit in the available space for the y-Axis labels // This does mean that if the text is extremely long, it can be unreadable. @@ -264,6 +271,7 @@ export const drawMeasurementsSVG = (ref, xAxisRef, svgData) => { // but there're always limits of the available space so punting that for now. // -Jover, 20 September 2022 g.selectAll('text') + .attr("class", classes.groupingValue) .attr("transform", (_, i, element) => { const textWidth = select(element[i]).node().getBoundingClientRect().width; // Subtract the twice the y-axis tick size to give some padding around the text @@ -273,7 +281,8 @@ export const drawMeasurementsSVG = (ref, xAxisRef, svgData) => { } return null; }); - }); + }) + .on("click", () => handleClickOnGrouping(groupingValue)); // Add circles for each measurement // Note, "cy" is added later when jittering within color-by groups @@ -466,7 +475,7 @@ export const addColorByAttrToGroupingLabel = (ref, treeStrainColors) => { svg.selectAll(`.${classes.yAxis}`).select(".tick") .each((_, i, elements) => { const groupingLabel = select(elements[i]); - const groupingValue = groupingLabel.text(); + const groupingValue = groupingLabel.select(`.${classes.groupingValue}`).text(); const groupingValueColorBy = treeStrainColors[groupingValue]; if (groupingValueColorBy) { // Get the current label width to add colored line and text relative to the width @@ -490,3 +499,38 @@ export const addColorByAttrToGroupingLabel = (ref, treeStrainColors) => { } }); }; + +const colorGroupingCrosshairId = "measurementsColorGroupingCrosshair"; +export const removeColorGroupingCrosshair = (ref) => { + const svg = select(ref); + svg.select(`#${colorGroupingCrosshairId}`).remove(); +}; + +export const addGroupingValueCrosshair = (ref, groupingValue) => { + // Remove previous color grouping crosshair + removeColorGroupingCrosshair(ref); + + const svg = select(ref); + svg.selectAll(`.${classes.yAxis}`).select(".tick") + .each((_, i, elements) => { + const groupingLabel = select(elements[i]); + const currentGroupingValue = groupingLabel.select(`.${classes.groupingValue}`).text() + if (groupingValue === currentGroupingValue){ + const {width} = groupingLabel.node().getBoundingClientRect(); + groupingLabel.append("svg") + .attr("id", colorGroupingCrosshairId) + .attr("stroke", "currentColor") + .attr("fill", "currentColor") + .attr("strokeWidth", "0") + .attr("viewBox", "0 0 256 256") + .attr("height", layout.yAxisColorByLineHeight * 2) + .attr("width", layout.yAxisColorByLineHeight * 2) + .attr("x", -width - (layout.yAxisColorByLineHeight * 2)) + .attr("y", -layout.yAxisColorByLineHeight) + .append("path") + // path copied from react-icons/pi/PiCrosshairSimpleBold + .attr("d", "M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm12,191.13V184a12,12,0,0,0-24,0v27.13A84.18,84.18,0,0,1,44.87,140H72a12,12,0,0,0,0-24H44.87A84.18,84.18,0,0,1,116,44.87V72a12,12,0,0,0,24,0V44.87A84.18,84.18,0,0,1,211.13,116H184a12,12,0,0,0,0,24h27.13A84.18,84.18,0,0,1,140,211.13Z" ) + + } + }); +} diff --git a/src/components/tree/phyloTree/change.ts b/src/components/tree/phyloTree/change.ts index aa839b0c6..178589605 100644 --- a/src/components/tree/phyloTree/change.ts +++ b/src/components/tree/phyloTree/change.ts @@ -216,6 +216,12 @@ export const modifySVG = function modifySVG( } else if (elemsToUpdate.has('.branchLabel')) { this.updateBranchLabels(transitionTime); } + + if (this.measurementsColorGrouping) { + this.drawMeasurementsColoringCrosshair(); + } else { + this.removeMeasurementsColoringCrosshair(); + } }; /* instead of modifying the SVG the "normal" way, this is sometimes too janky (e.g. when we need to move everything) @@ -242,6 +248,9 @@ export const modifySVGInStages = function modifySVGInStages( this.updateTipLabels(); this.drawTips(); if (this.vaccines) this.drawVaccines(); + if (this.measurementsColorGrouping) { + this.drawMeasurementsColoringCrosshair(); + } this.showTemporalSlice(); if (this.regression) this.drawRegression(); if (elemsToUpdate.has(".branchLabel")) this.drawBranchLabels(extras.newBranchLabellingKey || this.params.branchLabelKey); @@ -266,6 +275,7 @@ export const modifySVGInStages = function modifySVGInStages( .on("start", () => inProgress++) .on("end", step2); this.hideTemporalSlice(); + this.removeMeasurementsColoringCrosshair(); if (!transitionTimeFadeOut) timerFlush(); }; @@ -312,6 +322,7 @@ export const change = function change( branchThickness = undefined, scatterVariables = undefined, performanceFlags = undefined, + newMeasurementsColorGrouping = undefined, }: ChangeParams ): void { // console.log("\n** phylotree.change() (time since last run:", Date.now() - this.timeLastRenderRequested, "ms) **\n\n"); @@ -376,6 +387,7 @@ export const change = function change( // recalculate gradients here? if (changeColorBy) { this.updateColorBy(); + this.measurementsColorGrouping = newMeasurementsColorGrouping; } // recalculate existing regression if needed if (changeVisibility && this.regression) { diff --git a/src/components/tree/phyloTree/phyloTree.ts b/src/components/tree/phyloTree/phyloTree.ts index 79c2a0f19..4140fb1e4 100644 --- a/src/components/tree/phyloTree/phyloTree.ts +++ b/src/components/tree/phyloTree/phyloTree.ts @@ -63,6 +63,8 @@ PhyloTree.prototype.setClipMask = renderers.setClipMask; PhyloTree.prototype.drawTips = renderers.drawTips; PhyloTree.prototype.drawBranches = renderers.drawBranches; PhyloTree.prototype.drawVaccines = renderers.drawVaccines; +PhyloTree.prototype.drawMeasurementsColoringCrosshair = renderers.drawMeasurementsColoringCrosshair; +PhyloTree.prototype.removeMeasurementsColoringCrosshair = renderers.removeMeasurementsColoringCrosshair; PhyloTree.prototype.drawRegression = renderers.drawRegression; PhyloTree.prototype.removeRegression = renderers.removeRegression; PhyloTree.prototype.updateColorBy = renderers.updateColorBy; diff --git a/src/components/tree/phyloTree/renderers.ts b/src/components/tree/phyloTree/renderers.ts index fecaa7996..d065b2205 100644 --- a/src/components/tree/phyloTree/renderers.ts +++ b/src/components/tree/phyloTree/renderers.ts @@ -26,7 +26,8 @@ export const render = function render( tipFill, tipRadii, dateRange, - scatterVariables + scatterVariables, + measurementsColorGrouping, }: { /** the svg into which the tree is drawn */ svg: Selection @@ -74,6 +75,8 @@ export const render = function render( /** {x, y} properties to map nodes => scatterplot (only used if layout="scatter") */ scatterVariables: ScatterVariables + + measurementsColorGrouping: string | undefined }) { timerStart("phyloTree render()"); this.svg = svg; @@ -83,6 +86,7 @@ export const render = function render( }; this.callbacks = callbacks; this.vaccines = vaccines ? vaccines.map((d) => d.shell) : undefined; + this.measurementsColorGrouping = measurementsColorGrouping; this.dateRange = dateRange; /* set nodes stroke / fill */ @@ -112,6 +116,7 @@ export const render = function render( this.drawTips(); if (this.params.branchLabelKey) this.drawBranchLabels(this.params.branchLabelKey); if (this.vaccines) this.drawVaccines(); + if (this.measurementsColorGrouping) this.drawMeasurementsColoringCrosshair(); if (this.regression) this.drawRegression(); this.confidencesInSVG = false; if (drawConfidence) this.drawConfidence(); @@ -146,6 +151,51 @@ export const drawVaccines = function drawVaccines(this: PhyloTreeType): void { .on("click", this.callbacks.onTipClick); }; +export const removeMeasurementsColoringCrosshair = function removeMeasurementsColoringCrosshair(this: PhyloTreeType): void { + if ("measurementsColoringCrosshair" in this.groups) { + this.groups.measurementsColoringCrosshair.selectAll("*").remove(); + } +} + +/** + * Adds crosshair to tip matching the measurements coloring group + */ +export const drawMeasurementsColoringCrosshair = function drawMeasurementsColoringCrosshair(this: PhyloTreeType): void { + if ("measurementsColoringCrosshair" in this.groups) { + this.removeMeasurementsColoringCrosshair(); + } else { + this.groups.measurementsColoringCrosshair = this.svg.append("g").attr("id", "measurementsColoringCrosshairId"); + } + + const matchingStrains = this.nodes.filter((d) => !d.n.hasChildren && d.n.name === this.measurementsColorGrouping); + if (matchingStrains.length === 1) { + this.groups.measurementsColoringCrosshair + .selectAll(".crosshair") + .data(matchingStrains) + .enter() + .append("svg") + .attr("stroke", "currentColor") + .attr("fill", "currentColor") + .attr("strokeWidth", "0") + .attr("viewBox", "0 0 256 256") + .attr("height", (d) => d.r * 5) + .attr("width", (d) => d.r * 5) + .attr("x", (d) => d.xTip - (d.r * 5 / 2)) + .attr("y", (d) => d.yTip - (d.r * 5 / 2)) + .style("cursor", "pointer") + .style("pointer-events", "auto") + .on("mouseover", this.callbacks.onTipHover) + .on("mouseout", this.callbacks.onTipLeave) + .on("click", this.callbacks.onTipClick) + .append("path") + // path copied from react-icons/pi/PiCrosshairSimpleBold + .attr("d", "M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm12,191.13V184a12,12,0,0,0-24,0v27.13A84.18,84.18,0,0,1,44.87,140H72a12,12,0,0,0,0-24H44.87A84.18,84.18,0,0,1,116,44.87V72a12,12,0,0,0,24,0V44.87A84.18,84.18,0,0,1,211.13,116H184a12,12,0,0,0,0,24h27.13A84.18,84.18,0,0,1,140,211.13Z"); + } else if (matchingStrains.length === 0) { + console.warn(`Measurements coloring group ${this.measurementsColorGrouping} doesn't match any tip names`); + } else { + console.warn(`Measurements coloring group ${this.measurementsColorGrouping} matches multiple tips`); + } +} /** * adds all the tip circles to the svg, they have class tip @@ -408,7 +458,7 @@ const handleBranchHoverColor = ( if (!tel.empty()) { // Some displays don't have S & T parts of the branch tel.style("stroke", c2); } - + /* If we reinstate gradient stem colours this section must be updated; see the commit which added this comment for the previous implementation */ const sel = d.that.svg.select("#"+getDomId("branchS", d.n.name)); diff --git a/src/components/tree/phyloTree/types.ts b/src/components/tree/phyloTree/types.ts index 0304e1fea..cdd3edc80 100644 --- a/src/components/tree/phyloTree/types.ts +++ b/src/components/tree/phyloTree/types.ts @@ -14,7 +14,7 @@ import * as renderers from "./renderers"; export type Distance = "num_date" | "div" -export type TreeElement = +export type TreeElement = ".branch.S" | ".branch.T" | ".branch" | @@ -35,7 +35,7 @@ export interface Regression { // ---------- Callbacks ---------- // -type NodeCallback = (d: PhyloNode) => void // See +type NodeCallback = (d: PhyloNode) => void // See export interface Callbacks { onBranchClick: NodeCallback @@ -205,6 +205,7 @@ export interface ChangeParams { newBranchLabellingKey?: string showAllBranchLabels?: boolean newTipLabelKey?: string | symbol + newMeasurementsColorGrouping?: string | undefined // arrays of data (the same length as nodes) // branchStroke?: string[] @@ -232,6 +233,7 @@ export interface PhyloTreeType { drawBranchLabels: typeof labels.drawBranchLabels drawBranches: typeof renderers.drawBranches drawConfidence: typeof confidence.drawConfidence + drawMeasurementsColoringCrosshair: typeof renderers.drawMeasurementsColoringCrosshair drawRegression: typeof renderers.drawRegression drawSingleCI: typeof confidence.drawSingleCI drawTips: typeof renderers.drawTips @@ -243,6 +245,7 @@ export interface PhyloTreeType { branchTee?: Selection clipPath?: Selection confidenceIntervals?: Selection + measurementsColoringCrosshair?: Selection regression?: Selection tips?: Selection vaccines?: Selection @@ -258,6 +261,7 @@ export interface PhyloTreeType { right: number top: number } + measurementsColorGrouping: string | undefined modifySVG: typeof modifySVG modifySVGInStages: typeof modifySVGInStages nodes: PhyloNode[] @@ -267,6 +271,7 @@ export interface PhyloTreeType { regression?: Regression removeBranchLabels: typeof labels.removeBranchLabels removeConfidence: typeof confidence.removeConfidence + removeMeasurementsColoringCrosshair: typeof renderers.removeMeasurementsColoringCrosshair removeRegression: typeof renderers.removeRegression removeTipLabels: typeof labels.removeTipLabels render: typeof renderers.render diff --git a/src/components/tree/reactD3Interface/change.ts b/src/components/tree/reactD3Interface/change.ts index 24c309b60..faa90bd3f 100644 --- a/src/components/tree/reactD3Interface/change.ts +++ b/src/components/tree/reactD3Interface/change.ts @@ -1,3 +1,4 @@ +import { decodeMeasurementColorBy, isMeasurementColorBy } from "../../../actions/measurements"; import { calculateStrokeColors, getBrighterColor } from "../../../util/colorHelpers"; import { ChangeParams, PhyloTreeType } from "../phyloTree/types"; import { TreeComponentProps, TreeComponentState } from "../types"; @@ -37,6 +38,7 @@ export const changePhyloTreeViaPropsComparison = ( args.branchStroke = calculateStrokeColors(newTreeRedux, true, newProps.colorByConfidence, newProps.colorBy); args.tipStroke = calculateStrokeColors(newTreeRedux, false, newProps.colorByConfidence, newProps.colorBy); args.fill = args.tipStroke.map(getBrighterColor); + args.newMeasurementsColorGrouping = isMeasurementColorBy(newProps.colorBy) ? decodeMeasurementColorBy(newProps.colorBy) : undefined; } /* visibility */ diff --git a/src/components/tree/reactD3Interface/initialRender.ts b/src/components/tree/reactD3Interface/initialRender.ts index 22cec9e4f..8a9f8abfb 100644 --- a/src/components/tree/reactD3Interface/initialRender.ts +++ b/src/components/tree/reactD3Interface/initialRender.ts @@ -1,6 +1,7 @@ import { select } from "d3-selection"; import 'd3-transition'; import { calculateStrokeColors, getBrighterColor } from "../../../util/colorHelpers"; +import { decodeMeasurementColorBy, isMeasurementColorBy } from "../../../actions/measurements"; import * as callbacks from "./callbacks"; import { makeTipLabelFunc } from "../phyloTree/labels"; import { PhyloTreeType } from "../phyloTree/types"; @@ -53,11 +54,12 @@ export const renderTree = ( visibility: treeState.visibility, drawConfidence: props.temporalConfidence.on, vaccines: treeState.vaccines, - branchStroke: calculateStrokeColors(treeState, true, props.colorByConfidence, props.colorBy), + branchStroke: calculateStrokeColors(treeState, true, props.colorByConfidence, props.colorBy), tipStroke: tipStrokeColors, tipFill: tipStrokeColors.map(getBrighterColor), tipRadii: treeState.tipRadii, dateRange: [props.dateMinNumeric, props.dateMaxNumeric], scatterVariables: props.scatterVariables, + measurementsColorGrouping: isMeasurementColorBy(props.colorBy) ? decodeMeasurementColorBy(props.colorBy) : undefined, }); }; diff --git a/src/metadata.ts b/src/metadata.ts index 73b60ea2a..8bf9c237a 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -15,8 +15,8 @@ export type ColoringInfo = { title: string type: ScaleType - /** scale set via JSON */ - scale: [string, string][] + /** scale set via JSON or ADD_EXTRA_METADATA action */ + scale?: [string | number, string][] legend?: Legend } diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index 516fb8065..619219f92 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -156,11 +156,12 @@ export interface MeasurementFilters { [key: string]: Map } export interface MeasurementsControlState { - measurementsGroupBy: string | undefined, - measurementsDisplay: MeasurementsDisplay | undefined, - measurementsShowOverallMean: boolean | undefined, - measurementsShowThreshold: boolean | undefined, + measurementsGroupBy: string | undefined + measurementsDisplay: MeasurementsDisplay | undefined + measurementsShowOverallMean: boolean | undefined + measurementsShowThreshold: boolean | undefined measurementsFilters: MeasurementFilters + measurementsColorGrouping: string | undefined } export interface ControlsState extends BasicControlsState, MeasurementsControlState {} @@ -247,6 +248,7 @@ export const getDefaultControlsState = (): ControlsState => { measurementsDisplay: undefined, measurementsShowOverallMean: undefined, measurementsShowThreshold: undefined, + measurementsColorGrouping: undefined, measurementsFilters: {}, performanceFlags: new Map(), }; @@ -266,7 +268,8 @@ export const defaultMeasurementsControlState: MeasurementsControlState = { measurementsDisplay: "mean", measurementsShowOverallMean: true, measurementsShowThreshold: true, - measurementsFilters: {} + measurementsFilters: {}, + measurementsColorGrouping: undefined, }; /* while this may change, div currently doesn't have CIs, so they shouldn't be displayed. */ @@ -479,6 +482,13 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con } return newState; } + case types.REMOVE_METADATA: { + const coloringsPresentOnTree = new Set(state.coloringsPresentOnTree); + action.nodeAttrsToRemove.forEach((colorBy: string): void => { + coloringsPresentOnTree.delete(colorBy); + }) + return {...state, coloringsPresentOnTree}; + } case types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS: { const colorScale = Object.assign({}, state.colorScale, { visibleLegendValues: action.visibleLegendValues }); return Object.assign({}, state, { colorScale: colorScale }); @@ -495,6 +505,7 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con return state; } case types.CHANGE_MEASUREMENTS_COLLECTION: // fallthrough + case types.CHANGE_MEASUREMENTS_COLOR_GROUPING: // fallthrough case types.CHANGE_MEASUREMENTS_DISPLAY: // fallthrough case types.CHANGE_MEASUREMENTS_GROUP_BY: // fallthrough case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: // fallthrough diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js index 818159496..947a95eca 100644 --- a/src/reducers/metadata.js +++ b/src/reducers/metadata.js @@ -32,6 +32,15 @@ const Metadata = (state = { } return Object.assign({}, state, {colorings, geoResolutions}); } + case types.REMOVE_METADATA: { + const colorings = {...state.colorings}; + action.nodeAttrsToRemove.forEach((colorBy) => { + if (colorBy in colorings) { + delete colorings[colorBy]; + } + }) + return {...state, colorings} + } case types.SET_AVAILABLE: { if (state.buildUrl) { return state; // do not use data from getAvailable to overwrite a buildUrl set from a dataset JSON diff --git a/src/reducers/tree/index.ts b/src/reducers/tree/index.ts index b7d08f2e0..5ca2efc67 100644 --- a/src/reducers/tree/index.ts +++ b/src/reducers/tree/index.ts @@ -1,6 +1,6 @@ import { AnyAction } from "@reduxjs/toolkit"; import { countTraitsAcrossTree } from "../../util/treeCountingHelpers"; -import { addNodeAttrs } from "../../util/treeMiscHelpers"; +import { addNodeAttrs, removeNodeAttrs } from "../../util/treeMiscHelpers"; import * as types from "../../actions/types"; import { TreeState, TreeTooState } from "./types"; @@ -79,16 +79,36 @@ const Tree = ( // add the new nodeAttrKeys to ensure tip labels get updated const nodeAttrKeys = new Set(state.nodeAttrKeys); Object.keys(action.newColorings).forEach((attr) => nodeAttrKeys.add(attr)); - // add the new colorings to totalStateCounts so that they can function as filters + // add the new non-continuous colorings to totalStateCounts so that they can function as filters + const nonContinuousColorings = Object.keys(action.newColorings).filter((coloring: string) => { + return action.newColorings[coloring].type !== "continuous" + }); return { ...state, totalStateCounts: { ...state.totalStateCounts, - ...countTraitsAcrossTree(state.nodes, Object.keys(action.newColorings), false, true) + ...countTraitsAcrossTree(state.nodes, nonContinuousColorings, false, true) }, nodeAttrKeys }; } + case types.REMOVE_METADATA: { + // remove data from `nodes` in-place, so no redux update will be triggered if you only listen to `nodes` + removeNodeAttrs(state.nodes, action.nodeAttrsToRemove); + const nodeAttrKeys = new Set(state.nodeAttrKeys); + const totalStateCounts = {...state.totalStateCounts}; + action.nodeAttrsToRemove.forEach((attrKey: string): void => { + nodeAttrKeys.delete(attrKey); + if (attrKey in totalStateCounts) { + delete totalStateCounts[attrKey]; + } + }) + return { + ...state, + totalStateCounts, + nodeAttrKeys, + } + } default: return state; } diff --git a/src/reducers/tree/treeToo.ts b/src/reducers/tree/treeToo.ts index fd5b75de7..c13e4bf1c 100644 --- a/src/reducers/tree/treeToo.ts +++ b/src/reducers/tree/treeToo.ts @@ -1,6 +1,6 @@ import { AnyAction } from "@reduxjs/toolkit"; import { getDefaultTreeState } from "."; -import { addNodeAttrs } from "../../util/treeMiscHelpers"; +import { addNodeAttrs, removeNodeAttrs } from "../../util/treeMiscHelpers"; import * as types from "../../actions/types"; import { TreeTooState } from "./types"; @@ -71,6 +71,10 @@ const treeToo = ( // add data into `nodes` in-place, so no redux update will be triggered if you only listen to `nodes` addNodeAttrs(state.nodes, action.newNodeAttrs); return state; + case types.REMOVE_METADATA: + // remove data from `nodes` in-place, so no redux update will be triggered if you only listen to `nodes` + removeNodeAttrs(state.nodes, action.nodeAttrsToRemove); + return state; default: return state; } diff --git a/src/util/colorScale.ts b/src/util/colorScale.ts index f85f80471..4a187fd6e 100644 --- a/src/util/colorScale.ts +++ b/src/util/colorScale.ts @@ -136,7 +136,7 @@ export const calcColorScale = ( export function createNonContinuousScaleFromProvidedScaleMap( colorBy: string, - providedScale: [string, string][], + providedScale: [string | number, string][], t1nodes: ReduxNode[], t2nodes: ReduxNode[] | undefined, ): { @@ -150,7 +150,7 @@ export function createNonContinuousScaleFromProvidedScaleMap( } /* The providedScale may have duplicate names (not ideal, but it happens). In this case we should filter out duplicates (taking the first of the duplicates is fine) & print a console warning */ - const colorMap = new Map(); + const colorMap = new Map(); for (const [name, colorHex] of providedScale) { if (colorMap.has(name)) { console.warn(`User provided color scale contained a duplicate entry for ${colorBy}→${name} which is ignored.`); diff --git a/src/util/treeMiscHelpers.js b/src/util/treeMiscHelpers.js index 80acb6fa8..ef71a66c0 100644 --- a/src/util/treeMiscHelpers.js +++ b/src/util/treeMiscHelpers.js @@ -309,3 +309,18 @@ export const addNodeAttrs = (nodes, newAttrs) => { } }); }; + +/** + * Remove attrs from the `nodes` data structure. + * @param {Array} nodes + * @param {Array} attrsToRemove + */ +export const removeNodeAttrs = (nodes, attrsToRemove) => { + nodes.forEach((node) => { + if(!node.node_attrs) return; + + attrsToRemove.forEach((attrName) => { + delete node.node_attrs[attrName]; + }) + }) +}