diff --git a/docs/advanced-functionality/view-settings.rst b/docs/advanced-functionality/view-settings.rst index eabb3c280..00c152423 100644 --- a/docs/advanced-functionality/view-settings.rst +++ b/docs/advanced-functionality/view-settings.rst @@ -128,3 +128,25 @@ URL queries are the part of the URL coming after the ``?`` character, and typica **See this in action:** For instance, go to `nextstrain.org/flu/seasonal/h3n2/ha/2y?c=num_date&d=tree,map&m=div&r=region `__ and you'll see how we've changed the coloring to a temporal scale (``c=num_date``), we're only showing the tree & map panels (``d=tree,map``), the tree x-axis is divergence (``m=div``) and the map resolution is region (``r=region``). + +Measurements panel URL query options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following query options are specifically for the measurements panel. + ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ +| Key | Description | Example(s) | ++======================+===========================================================+==============================================================+ +| ``m_collection`` | Specify which collection to display | ``m_collection=h3n2_ha_cell_hi`` | ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ +| ``m_display`` | Toggle measurements display between mean and raw | ``m_display=mean`` or ``m_display=raw`` | ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ +| ``m_groupBy`` | Specify group by field to use | ``m_groupBy=reference_strain`` | ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ +| ``m_overallMean`` | Show or hide the overall mean display | ``m_overallMean=show`` or ``m_overallMean=hide`` | ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ +| ``m_threshold`` | Show or hide the threshold(s) | ``m_threshold=show`` or ``m_threshold=hide`` | ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ +| ``mf_`` | | Filters for the measurements data. Multiple values for | | ``mf_reference_strain=A/Alabama/5/2010`` | +| | | the same field are specified by multiple query params | | ``mf_clade_reference=145S.2&mf_clade_reference=158N/189K`` | ++----------------------+-----------------------------------------------------------+--------------------------------------------------------------+ diff --git a/src/actions/measurements.js b/src/actions/measurements.js index bbdff7762..b4b7c365f 100644 --- a/src/actions/measurements.js +++ b/src/actions/measurements.js @@ -1,33 +1,126 @@ -import { pick } from "lodash"; +import { cloneDeep, pick } from "lodash"; import { measurementIdSymbol } from "../util/globals"; +import { defaultMeasurementsControlState } from "../reducers/controls"; import { APPLY_MEASUREMENTS_FILTER, CHANGE_MEASUREMENTS_COLLECTION, - LOAD_MEASUREMENTS + CHANGE_MEASUREMENTS_DISPLAY, + CHANGE_MEASUREMENTS_GROUP_BY, + LOAD_MEASUREMENTS, + TOGGLE_MEASUREMENTS_OVERALL_MEAN, + TOGGLE_MEASUREMENTS_THRESHOLD, } from "./types"; /** * Find the collection within collections that has a key matching the provided - * collectionKey. + * collectionKey. The default collection is defined by the provided defaultKey. * - * If collectionKey is not provided, returns the first collection. - * If no matches are found, returns the first collection. + * 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. * * @param {Array} collections * @param {string} collectionKey + * @param {string} defaultKey * @returns {Object} */ -export const getCollectionToDisplay = (collections, collectionKey) => { - if (!collectionKey) return collections[0]; +export const getCollectionToDisplay = (collections, collectionKey, defaultKey) => { + 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 collections[0]; + 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 + * @param {string} controlKey + * @param {Object} collection + * @returns {*} + */ +function getCollectionDefaultControl(controlKey, collection) { + 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': { + const expectedValues = ["mean", "raw"]; + if (defaultControl !== undefined && !expectedValues.includes(defaultControl)) { + console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be one of ${expectedValues}`) + 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 'measurementsCollectionKey': // 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 + * @param {Object} collection + * @returns {MeasurementsControlState} + */ +export function getCollectionDefaultControls(collection) { + const defaultControls = {...defaultMeasurementsControlState}; + if (Object.keys(collection).length) { + defaultControls.measurementsCollectionKey = collection.key; + 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. @@ -36,60 +129,43 @@ export const getCollectionToDisplay = (collections, collectionKey) => { * If the current `measurementsGrouping` does not exist in the collection, then * defaults to the first grouping option. * @param {Object} collection - * @returns {Object} + * @returns {MeasurementsControlState} */ const getCollectionDisplayControls = (controls, collection) => { - const measurementsControls = [ - "measurementsGroupBy", - "measurementsDisplay", - "measurementsShowOverallMean", - "measurementsShowThreshold", - "measurementsFilters" - ]; // Copy current control options for measurements - const newControls = pick(controls, measurementsControls); - // Checks the current group by is available as a field in collection - if (!collection.fields.has(newControls.measurementsGroupBy)) { - // If current group by is not available as a field, then default to the first grouping option. - [newControls.measurementsGroupBy] = collection.groupings.keys(); + const newControls = cloneDeep(pick(controls, Object.keys(defaultMeasurementsControlState))); + newControls.measurementsCollectionKey = collection.key; + // 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 - Object.entries(newControls.measurementsFilters).forEach(([field, valuesMap]) => { - // Delete filter for field that does not exist in the new collection filters - if (!collection.filters.has(field)) { - return delete newControls.measurementsFilters[field]; - } - // Clone nested Map to avoid changing redux state in place - newControls.measurementsFilters[field] = new Map(valuesMap); - return [...valuesMap.keys()].forEach((value) => { - // Delete filter for values that do not exist within the field of the new collection - if (!collection.filters.get(field).values.has(value)) { - newControls.measurementsFilters[field].delete(value); - } - }); - }); - - if (collection["display_defaults"]) { - const { - group_by, - measurements_display, - show_overall_mean, - show_threshold - } = collection["display_defaults"]; + newControls.measurementsFilters = Object.fromEntries( + Object.entries(newControls.measurementsFilters) + .map(([field, valuesMap]) => { + // 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; + }) + ) - if (group_by) { - newControls.measurementsGroupBy = group_by; - } - if (measurements_display) { - newControls.measurementsDisplay = measurements_display; - } - if (typeof show_overall_mean === "boolean") { - newControls.measurementsShowOverallMean = show_overall_mean; - } - if (typeof show_threshold === "boolean") { - newControls.measurementsShowThreshold = show_threshold; - } + // 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] } return newControls; @@ -191,32 +267,43 @@ export const parseMeasurementsJSON = (json) => { }; export const loadMeasurements = ({collections, defaultCollection}) => (dispatch, getState) => { - // TODO: Load controls from state to get potential url query parameters const { tree, controls } = getState(); if (!tree.loaded) { throw new Error("tree not loaded"); } + const collectionKeys = collections.map((collection) => collection.key); + let defaultCollectionKey = defaultCollection; + if (!collectionKeys.includes(defaultCollectionKey)) { + defaultCollectionKey = collectionKeys[0]; + } + // Get the collection to display to set up default controls - // TODO: consider url query parameter? - const collectionToDisplay = getCollectionToDisplay(collections, defaultCollection); + const collectionToDisplay = getCollectionToDisplay(collections, controls.measurementsCollectionKey, defaultCollectionKey); + const newControls = getCollectionDisplayControls(controls, collectionToDisplay); + const queryParams = createMeasurementsQueryFromControls(newControls, collectionToDisplay, defaultCollectionKey); dispatch({ type: LOAD_MEASUREMENTS, + defaultCollectionKey, collections, collectionToDisplay, - controls: getCollectionDisplayControls(controls, collectionToDisplay) + controls: newControls, + queryParams }); }; export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, getState) => { const { controls, measurements } = getState(); - const collectionToDisplay = getCollectionToDisplay(measurements.collections, newCollectionKey); + const collectionToDisplay = getCollectionToDisplay(measurements.collections, newCollectionKey, measurements.defaultCollectionKey); + const newControls = getCollectionDisplayControls(controls, collectionToDisplay); + const queryParams = createMeasurementsQueryFromControls(newControls, collectionToDisplay, measurements.defaultCollectionKey); dispatch({ type: CHANGE_MEASUREMENTS_COLLECTION, collectionToDisplay, - controls: getCollectionDisplayControls(controls, collectionToDisplay) + controls: newControls, + queryParams }); }; @@ -227,19 +314,20 @@ export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, get * - Jover, 19 January 2022 */ export const applyMeasurementFilter = (field, value, active) => (dispatch, getState) => { - const { controls } = getState(); + const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); measurementsFilters[field].set(value, {active}); dispatch({ type: APPLY_MEASUREMENTS_FILTER, - data: measurementsFilters + controls: { measurementsFilters }, + queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) }); }; export const removeSingleFilter = (field, value) => (dispatch, getState) => { - const { controls } = getState(); + const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); measurementsFilters[field].delete(value); @@ -252,23 +340,25 @@ export const removeSingleFilter = (field, value) => (dispatch, getState) => { dispatch({ type: APPLY_MEASUREMENTS_FILTER, - data: measurementsFilters + controls: { measurementsFilters }, + queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) }); }; export const removeAllFieldFilters = (field) => (dispatch, getState) => { - const { controls } = getState(); + const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; delete measurementsFilters[field]; dispatch({ type: APPLY_MEASUREMENTS_FILTER, - data: measurementsFilters + controls: { measurementsFilters }, + queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay) }); }; export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => { - const { controls } = getState(); + const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); for (const fieldValue of measurementsFilters[field].keys()) { @@ -276,6 +366,176 @@ export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => } dispatch({ type: APPLY_MEASUREMENTS_FILTER, - data: measurementsFilters + controls: { measurementsFilters }, + queryParams: createMeasurementsQueryFromControls({measurementsFilters}, measurements.collectionToDisplay, measurements.defaultCollectionKey) }); }; + +export const toggleOverallMean = () => (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) + }); +} + +export const toggleThreshold = () => (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) => (dispatch, getState) => { + const { measurements } = getState(); + const controlKey = "measurementsDisplay"; + const newControls = { [controlKey]: newDisplay }; + + dispatch({ + type: CHANGE_MEASUREMENTS_DISPLAY, + controls: newControls, + queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay) + }); +} + +export const changeMeasurementsGroupBy = (newGroupBy) => (dispatch, getState) => { + const { measurements } = getState(); + const controlKey = "measurementsGroupBy"; + const newControls = { [controlKey]: newGroupBy }; + + dispatch({ + type: CHANGE_MEASUREMENTS_GROUP_BY, + controls: newControls, + queryParams: createMeasurementsQueryFromControls(newControls, measurements.collectionToDisplay, measurements.defaultCollectionKey) + }); +} + +const controlToQueryParamMap = { + measurementsCollectionKey: "m_collection", + measurementsDisplay: "m_display", + measurementsGroupBy: "m_groupBy", + measurementsShowOverallMean: "m_overallMean", + measurementsShowThreshold: "m_threshold", +}; + +/* mf_ correspond to active measurements filters */ +const filterQueryPrefix = "mf_"; +export function removeInvalidMeasurementsFilterQuery(query, newQueryParams) { + 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, collection, defaultCollectionKey) { + const newQuery = {}; + 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 "measurementsCollectionKey": + if (controlValue !== defaultCollectionKey) { + newQuery[queryKey] = controlValue; + } else { + newQuery[queryKey] = ""; + } + break; + 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; +} + +export function createMeasurementsControlsFromQuery(query){ + const newState = {}; + for (const [controlKey, queryKey] of Object.entries(controlToQueryParamMap)) { + const queryValue = query[queryKey]; + if (queryValue === undefined) continue; + let expectedValues = []; + let conversionFn = () => null; + switch(queryKey) { + case "m_display": + expectedValues = ["mean", "raw"]; + conversionFn = () => queryValue; + break; + case "m_collection": // fallthrough + case "m_groupBy": + // Accept any value here because we cannot validate the query before + // the measurements JSON is loaded + expectedValues = [queryValue]; + conversionFn = () => queryValue; + break; + case "m_overallMean": // fallthrough + case "m_threshold": + expectedValues = ["show", "hide"]; + conversionFn = () => queryValue === "show"; + break; + } + + if(expectedValues.includes(queryValue)) { + newState[controlKey] = conversionFn(); + } else { + console.error(`Ignoring invalid query param ${queryKey}=${queryValue}, value should be one of ${expectedValues}`); + } + } + + // Accept any value here because we cannot validate the query before the measurements JSON is loaded + for (const filterKey of Object.keys(query).filter((c) => c.startsWith(filterQueryPrefix))) { + const field = filterKey.replace(filterQueryPrefix, ''); + const filterValues = Array.isArray(query[filterKey]) ? query[filterKey] : [query[filterKey]]; + const measurementsFilters = {...newState.measurementsFilters}; + measurementsFilters[field] = new Map(measurementsFilters[field]); + for (const value of filterValues) { + measurementsFilters[field].set(value, {active: true}); + } + newState.measurementsFilters = measurementsFilters; + } + return newState; +} diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index d1b44904b..588e5dc62 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -23,6 +23,7 @@ import { getTraitFromNode, getDivFromNode, collectGenotypeStates } from "../util import { collectAvailableTipLabelOptions } from "../components/controls/choose-tip-label"; import { hasMultipleGridPanels } from "./panelDisplay"; import { strainSymbolUrlString } from "../middleware/changeURL"; +import { createMeasurementsControlsFromQuery, getCollectionDefaultControls, getCollectionToDisplay } from "./measurements"; export const doesColorByHaveConfidence = (controlsState, colorBy) => controlsState.coloringsPresentOnTreeWithConfidence.has(colorBy); @@ -146,8 +147,8 @@ const modifyStateViaURLQuery = (state, query) => { 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) || + !_validDate(_dminNum, state.absoluteDateMinNumeric, state.absoluteDateMaxNumeric) || + !_validDate(_dmaxNum, state.absoluteDateMinNumeric, state.absoluteDateMaxNumeric) || _dminNum >= _dmaxNum ) { console.error("Invalid 'animate' URL query (invalid date range)") @@ -206,8 +207,11 @@ const modifyStateViaURLQuery = (state, query) => { if (query.regression==="hide") state.scatterVariables.showRegression = false; if (query.scatterX) state.scatterVariables.x = query.scatterX; if (query.scatterY) state.scatterVariables.y = query.scatterY; - return state; + /* Process query params for measurements panel. These all start with `m_` or `mf_` prefix to avoid conflicts */ + state = {...state, ...createMeasurementsControlsFromQuery(query)} + + return state; function _validDate(dateNum, absoluteDateMinNumeric, absoluteDateMaxNumeric) { return !(dateNum===undefined || dateNum > absoluteDateMaxNumeric || dateNum < absoluteDateMinNumeric); } @@ -899,6 +903,12 @@ export const createStateFromQueryOrJSONs = ({ measurements = {...oldState.measurements}; controls = restoreQueryableStateToDefaults(controls); controls = modifyStateViaMetadata(controls, metadata, entropy.genomeMap); + /* If available, reset to the default collection and the collection's default controls + so that narrative queries are respected between slides */ + if (measurements.loaded) { + measurements.collectionToDisplay = getCollectionToDisplay(measurements.collections, "", measurements.defaultCollectionKey) + controls = {...controls, ...getCollectionDefaultControls(measurements.collectionToDisplay)}; + } } /* For the creation of state, we want to parse out URL query parameters @@ -912,6 +922,18 @@ export const createStateFromQueryOrJSONs = ({ narrativeSlideIdx = getNarrativePageFromQuery(query, narrative); /* replace the query with the information which can guide the view */ query = queryString.parse(narrative[narrativeSlideIdx].query); + /** + * Special case where narrative includes query param for new measurements collection `m_collection` + * We need to reset the measurements and controls to the new collection's defaults before + * processing the remaining query params + */ + if (query.m_collection && measurements.loaded) { + const newCollectionToDisplay = getCollectionToDisplay(measurements.collections, query.m_collection, measurements.defaultCollectionKey); + measurements.collectionToDisplay = newCollectionToDisplay; + controls = {...controls, ...getCollectionDefaultControls(measurements.collectionToDisplay)}; + // Delete `m_collection` so there's no chance of things getting mixed up when processing remaining query params + delete query.m_collection; + } } controls = modifyStateViaURLQuery(controls, query); @@ -1096,4 +1118,4 @@ function updateSecondTree(tree, treeToo, controls, dispatch) { controls.panelLayout = "full"; return treeToo; -} \ No newline at end of file +} diff --git a/src/components/controls/filter.js b/src/components/controls/filter.js index e379d7440..de94cac8f 100644 --- a/src/components/controls/filter.js +++ b/src/components/controls/filter.js @@ -81,7 +81,7 @@ class FilterData extends React.Component { ...(this.props.totalStateCounts[traitName]?.keys() || []), ...(this.props.totalStateCountsSecondTree?.[traitName]?.keys() || []), ]); - + this.props.totalStateCounts[traitName]; const traitTitle = this.getFilterTitle(traitName); const filterValuesCurrentlyActive = new Set((this.props.activeFilters[traitName] || []).filter((x) => x.active).map((x) => x.value)); @@ -175,6 +175,7 @@ class FilterData extends React.Component { }); } summariseMeasurementsFilters = () => { + if (this.props.measurementsFieldsMap === undefined) return []; return Object.entries(this.props.measurementsFilters).map(([field, valuesMap]) => { const activeFiltersCount = Array.from(valuesMap.values()).reduce((prevCount, currentValue) => { return currentValue.active ? prevCount + 1 : prevCount; diff --git a/src/components/controls/measurementsOptions.js b/src/components/controls/measurementsOptions.js index 28f760013..78ace0c37 100644 --- a/src/components/controls/measurementsOptions.js +++ b/src/components/controls/measurementsOptions.js @@ -2,13 +2,13 @@ import React from "react"; import { useSelector } from "react-redux"; import { useAppDispatch } from "../../hooks"; import { isEqual } from "lodash"; -import { changeMeasurementsCollection } from "../../actions/measurements"; import { - CHANGE_MEASUREMENTS_DISPLAY, - CHANGE_MEASUREMENTS_GROUP_BY, - TOGGLE_MEASUREMENTS_OVERALL_MEAN, - TOGGLE_MEASUREMENTS_THRESHOLD -} from "../../actions/types"; + changeMeasurementsCollection, + changeMeasurementsDisplay, + changeMeasurementsGroupBy, + toggleOverallMean, + toggleThreshold +} from "../../actions/measurements"; import { controlsWidth } from "../../util/globals"; import { SidebarSubtitle, SidebarButton } from "./styles"; import Toggle from "./toggle"; @@ -81,12 +81,7 @@ const MeasurementsOptions = () => { isClearable={false} isSearchable={false} isMulti={false} - onChange={(opt) => { - dispatch({ - type: CHANGE_MEASUREMENTS_GROUP_BY, - data: opt.value - }); - }} + onChange={(opt) => {dispatch(changeMeasurementsGroupBy(opt.value));}} /> @@ -94,13 +89,13 @@ const MeasurementsOptions = () => { {dispatch({ type: CHANGE_MEASUREMENTS_DISPLAY, data: "mean" });}} + onClick={() => {dispatch(changeMeasurementsDisplay("mean"));}} > {"Mean ± SD"} {dispatch({ type: CHANGE_MEASUREMENTS_DISPLAY, data: "raw" });}} + onClick={() => {dispatch(changeMeasurementsDisplay("raw"));}} > {"Raw"} @@ -109,7 +104,7 @@ const MeasurementsOptions = () => { display on={showOverallMean} label="Show overall mean ± SD" - callback={() => dispatch({type: TOGGLE_MEASUREMENTS_OVERALL_MEAN, data: !showOverallMean})} + callback={() => dispatch(toggleOverallMean())} /> { } on={showThreshold} label="Show measurement threshold(s)" - callback={() => dispatch({type: TOGGLE_MEASUREMENTS_THRESHOLD, data: !showThreshold})} + callback={() => dispatch(toggleThreshold())} /> ); diff --git a/src/components/info/filtersSummary.js b/src/components/info/filtersSummary.js index 55f47ef2d..ecdcd6335 100644 --- a/src/components/info/filtersSummary.js +++ b/src/components/info/filtersSummary.js @@ -199,7 +199,7 @@ class FiltersSummary extends React.Component { {". "} } - {Object.keys(this.props.measurementsFilters).length > 0 && + {(Object.keys(this.props.measurementsFilters).length > 0 && this.props.measurementsFields !== undefined) && <>
{t("Measurements filtered to") + " "} diff --git a/src/middleware/changeURL.js b/src/middleware/changeURL.js index b8adce4be..a57511c7f 100644 --- a/src/middleware/changeURL.js +++ b/src/middleware/changeURL.js @@ -4,6 +4,7 @@ import { numericToCalendar } from "../util/dateHelpers"; import { shouldDisplayTemporalConfidence } from "../reducers/controls"; import { genotypeSymbol, nucleotide_gene, strainSymbol } from "../util/globals"; import { encodeGenotypeFilters, decodeColorByGenotype, isColorByGenotype } from "../util/getGenotype"; +import { removeInvalidMeasurementsFilterQuery } from "../actions/measurements"; export const strainSymbolUrlString = "__strain__"; @@ -222,6 +223,18 @@ export const changeURLMiddleware = (store) => (next) => (action) => { } break; } + case types.LOAD_MEASUREMENTS: // fallthrough + case types.CHANGE_MEASUREMENTS_COLLECTION: // fallthrough + case types.APPLY_MEASUREMENTS_FILTER: + query = removeInvalidMeasurementsFilterQuery(query, action.queryParams) + query = {...query, ...action.queryParams}; + break; + case types.CHANGE_MEASUREMENTS_DISPLAY: // fallthrough + case types.CHANGE_MEASUREMENTS_GROUP_BY: // fallthrough + case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: // fallthrough + case types.TOGGLE_MEASUREMENTS_THRESHOLD: // fallthrough + query = {...query, ...action.queryParams}; + break; default: break; } diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index e6b3919da..6682271a0 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -12,7 +12,7 @@ import { calcBrowserDimensionsInitialState } from "./browserDimensions"; import { doesColorByHaveConfidence } from "../actions/recomputeReduxState"; import { hasMultipleGridPanels } from "../actions/panelDisplay"; -export interface ControlsState { +export interface BasicControlsState { panelsAvailable: string[] panelsToDisplay: string[] showTreeToo: boolean @@ -23,6 +23,19 @@ export interface ControlsState { [propName: string]: any; } +export interface MeasurementsControlState { + measurementsCollectionKey: string | undefined, + measurementsGroupBy: string | undefined, + measurementsDisplay: string | undefined, + measurementsShowOverallMean: boolean | undefined, + measurementsShowThreshold: boolean | undefined, + measurementsFilters: { + [key: string]: Map + } +} + +export interface ControlsState extends BasicControlsState, MeasurementsControlState {} + /* defaultState is a fn so that we can re-create it at any time, e.g. if we want to revert things (e.g. on dataset change) */ @@ -99,14 +112,33 @@ export const getDefaultControlsState = () => { showOnlyPanels: false, showTransmissionLines: true, normalizeFrequencies: true, + measurementsCollectionKey: undefined, measurementsGroupBy: undefined, - measurementsDisplay: "mean", - measurementsShowOverallMean: true, - measurementsShowThreshold: true, + measurementsDisplay: undefined, + measurementsShowOverallMean: undefined, + measurementsShowThreshold: undefined, measurementsFilters: {} }; }; +/** + * Keeping measurements control state separate from getDefaultControlsState + * in order to be able to differentiate when the page is loaded with and without + * URL params for the measurements panel. + * + * The initial control state is constructed then the URL params update the state. + * However, the measurements JSON is loaded after this, so it needs a way to + * differentiate the clean slate vs the added URL params. + */ +export const defaultMeasurementsControlState: MeasurementsControlState = { + measurementsCollectionKey: undefined, + measurementsGroupBy: undefined, + measurementsDisplay: "mean", + measurementsShowOverallMean: true, + measurementsShowThreshold: true, + measurementsFilters: {} +}; + /* while this may change, div currently doesn't have CIs, so they shouldn't be displayed. */ export const shouldDisplayTemporalConfidence = (exists, distMeasure, layout) => exists && distMeasure === "num_date" && layout === "rect"; @@ -329,19 +361,14 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con } return state; } - case types.LOAD_MEASUREMENTS: /* fallthrough */ - case types.CHANGE_MEASUREMENTS_COLLECTION: - return {...state, ...action.controls}; - case types.CHANGE_MEASUREMENTS_GROUP_BY: - return {...state, measurementsGroupBy: action.data}; - case types.TOGGLE_MEASUREMENTS_THRESHOLD: - return {...state, measurementsShowThreshold: action.data}; - case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: - return {...state, measurementsShowOverallMean: action.data}; - case types.CHANGE_MEASUREMENTS_DISPLAY: - return {...state, measurementsDisplay: action.data}; + case types.LOAD_MEASUREMENTS: // fallthrough + case types.CHANGE_MEASUREMENTS_COLLECTION: // fallthrough + case types.CHANGE_MEASUREMENTS_DISPLAY: // fallthrough + case types.CHANGE_MEASUREMENTS_GROUP_BY: // fallthrough + case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: // fallthrough + case types.TOGGLE_MEASUREMENTS_THRESHOLD: // fallthrough case types.APPLY_MEASUREMENTS_FILTER: - return {...state, measurementsFilters: action.data}; + return {...state, ...action.controls}; /** * Currently the CHANGE_ZOOM action (entropy panel zoom changed) does not * update the zoomMin/zoomMax, and as such they only represent the initially diff --git a/src/reducers/measurements.js b/src/reducers/measurements.js index 58c8c3ca1..b0e2af395 100644 --- a/src/reducers/measurements.js +++ b/src/reducers/measurements.js @@ -8,6 +8,7 @@ import { export const getDefaultMeasurementsState = () => ({ error: undefined, loaded: false, + defaultCollectionKey: "", collections: [], collectionToDisplay: {} }); @@ -20,6 +21,7 @@ const measurements = (state = getDefaultMeasurementsState(), action) => { return { ...state, loaded: true, + defaultCollectionKey: action.defaultCollectionKey, collections: action.collections, collectionToDisplay: action.collectionToDisplay };