From 22c3b23cd1c1bf36fb727e35a8aee9862c068bf3 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 29 Jan 2024 16:18:25 +1300 Subject: [PATCH 1/5] Restore text summary of filters This fixes a bug introduced in 8b1b9a2e7955233fa5864795800153ffd59e83c7 (September 2019) where we moved the data structure from an Object to a Map. The test summary appears at the top of the page, the download modal and within a exported SVG. --- src/components/info/datasetSummary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/info/datasetSummary.js b/src/components/info/datasetSummary.js index 01672c04b..e65f936a7 100644 --- a/src/components/info/datasetSummary.js +++ b/src/components/info/datasetSummary.js @@ -76,7 +76,7 @@ export const datasetSummary = ({nodes, visibility, mainTreeNumTips, branchLength /* parse filters */ const filterTextArr = []; Object.keys(filters).forEach((filterName) => { - const n = Object.keys(visibleStateCounts[filterName]).length; + const n = visibleStateCounts[filterName].size; if (!n) return; filterTextArr.push(`${n} ${pluralise(filterName, n)}`); }); From fe032c151d2df1633df6b31e608202ce2b573205 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 30 Jan 2024 09:33:49 +1300 Subject: [PATCH 2/5] Remove text summary of filters This behaviour was restored by the previous commit but had been broken for over 4 years. For early datasets, which used relatively few filters, this text was mostly appropriate. For many of today's datasets it's cumbersome, with phrases like "comprising 3 symptoms and 3 recencys". The removal of `visibleStateCounts` should improve performance a little, but we may wish to reinstate it in the future if we ever want to display information about the number of visible tips per filter. --- src/actions/recomputeReduxState.js | 3 +- src/actions/tree.js | 3 -- src/components/download/downloadButtons.js | 4 +-- src/components/download/downloadModal.js | 4 --- src/components/download/helperFunctions.js | 4 +-- src/components/info/datasetSummary.js | 34 +--------------------- src/components/info/filtersSummary.js | 1 - src/components/info/info.js | 2 -- src/reducers/tree.js | 8 +---- src/reducers/treeToo.js | 1 - 10 files changed, 5 insertions(+), 59 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index cc00d3547..ed33c9ca2 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -669,8 +669,7 @@ const modifyTreeStateVisAndBranchThickness = (oldState, zoomSelected, controlsSt const newState = Object.assign({}, oldState, visAndThicknessData); newState.stateCountAttrs = Object.keys(controlsState.filters); newState.idxOfInViewRootNode = newIdxRoot; - newState.visibleStateCounts = countTraitsAcrossTree(newState.nodes, newState.stateCountAttrs, newState.visibility, true); - newState.totalStateCounts = countTraitsAcrossTree(newState.nodes, newState.stateCountAttrs, false, true); + newState.totalStateCounts = countTraitsAcrossTree(newState.nodes, newState.stateCountAttrs, false, true); return newState; }; diff --git a/src/actions/tree.js b/src/actions/tree.js index bdce4fa83..a8e1cd8ac 100644 --- a/src/actions/tree.js +++ b/src/actions/tree.js @@ -97,7 +97,6 @@ export const updateVisibleTipsAndBranchThicknesses = ( idxOfFilteredRoot: data.idxOfFilteredRoot, cladeName: cladeSelected, selectedClade: cladeSelected, - stateCountAttrs: Object.keys(controls.filters) }; if (controls.showTreeToo) { @@ -168,7 +167,6 @@ export const changeDateFilter = ({newMin = false, newMax = false, quickdraw = fa branchThickness: data.branchThickness, branchThicknessVersion: data.branchThicknessVersion, idxOfInViewRootNode: tree.idxOfInViewRootNode, - stateCountAttrs: Object.keys(controls.filters) }; if (controls.showTreeToo) { const dataToo = calculateVisiblityAndBranchThickness(treeToo, controls, dates); @@ -408,7 +406,6 @@ export const explodeTree = (attr) => (dispatch, getState) => { {dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric} ); visData.idxOfInViewRootNode = 0; - visData.stateCountAttrs = Object.keys(controls.filters); /* Changes in visibility require a recomputation of which legend items we wish to display */ visData.visibleLegendValues = createVisibleLegendValues({ colorBy: controls.colorBy, diff --git a/src/components/download/downloadButtons.js b/src/components/download/downloadButtons.js index e1e50388c..b077016cc 100644 --- a/src/components/download/downloadButtons.js +++ b/src/components/download/downloadButtons.js @@ -16,7 +16,7 @@ const iconWidth = 25; * A React Component displaying buttons which trigger data-downloads. Intended for display within the * larger Download modal component */ -export const DownloadButtons = ({dispatch, t, tree, entropy, metadata, colorBy, distanceMeasure, panelsToDisplay, panelLayout, filters, visibility, visibleStateCounts, relevantPublications}) => { +export const DownloadButtons = ({dispatch, t, tree, entropy, metadata, colorBy, distanceMeasure, panelsToDisplay, panelLayout, visibility, relevantPublications}) => { const totalTipCount = metadata.mainTreeNumTips; const selectedTipsCount = getNumSelectedTips(tree.nodes, tree.visibility); const partialData = selectedTipsCount !== totalTipCount; @@ -100,9 +100,7 @@ export const DownloadButtons = ({dispatch, t, tree, entropy, metadata, colorBy, t, metadata, tree.nodes, - filters, visibility, - visibleStateCounts, getFilePrefix(), panelsToDisplay, panelLayout, diff --git a/src/components/download/downloadModal.js b/src/components/download/downloadModal.js index 100301c09..4dbc2f688 100644 --- a/src/components/download/downloadModal.js +++ b/src/components/download/downloadModal.js @@ -50,8 +50,6 @@ export const publications = { entropy: state.entropy, tree: state.tree, nodes: state.tree.nodes, - visibleStateCounts: state.tree.visibleStateCounts, - filters: state.controls.filters, visibility: state.tree.visibility, panelsToDisplay: state.controls.panelsToDisplay, panelLayout: state.controls.panelLayout @@ -164,9 +162,7 @@ class DownloadModal extends React.Component { {datasetSummary({ mainTreeNumTips: this.props.metadata.mainTreeNumTips, nodes: this.props.nodes, - filters: this.props.filters, visibility: this.props.visibility, - visibleStateCounts: this.props.visibleStateCounts, t: this.props.t })} diff --git a/src/components/download/helperFunctions.js b/src/components/download/helperFunctions.js index 76e74426c..23fcc1457 100644 --- a/src/components/download/helperFunctions.js +++ b/src/components/download/helperFunctions.js @@ -565,7 +565,7 @@ const writeSVGPossiblyIncludingMap = (dispatch, filePrefix, panelsInDOM, panelLa } }; -export const SVG = (dispatch, t, metadata, nodes, filters, visibility, visibleStateCounts, filePrefix, panelsInDOM, panelLayout, publications) => { +export const SVG = (dispatch, t, metadata, nodes, visibility, filePrefix, panelsInDOM, panelLayout, publications) => { /* make the text strings */ const textStrings = []; textStrings.push(metadata.title); @@ -575,9 +575,7 @@ export const SVG = (dispatch, t, metadata, nodes, filters, visibility, visibleSt textStrings.push(datasetSummary({ mainTreeNumTips: metadata.mainTreeNumTips, nodes, - filters, visibility, - visibleStateCounts, t })); textStrings.push(""); diff --git a/src/components/info/datasetSummary.js b/src/components/info/datasetSummary.js index e65f936a7..971b8a883 100644 --- a/src/components/info/datasetSummary.js +++ b/src/components/info/datasetSummary.js @@ -18,21 +18,6 @@ export const pluralise = (word, n) => { return word; }; -const arrayToSentence = (arr, {prefix=undefined, suffix=undefined, capatalise=true, fullStop=true}={}) => { - let ret; - if (!arr.length) return ''; - if (arr.length === 1) { - ret = arr[0]; - } else { - ret = arr.slice(0, -1).join(", ") + " and " + arr[arr.length-1]; - } - if (prefix) ret = prefix + " " + ret; - if (suffix) ret += " " + suffix; - if (capatalise) ret = ret.charAt(0).toUpperCase(); - if (fullStop) ret += "."; - return ret + " "; -}; - export const styliseDateRange = (date) => { const dateStr = (typeof date === "number") ? numericToCalendar(date) : @@ -49,7 +34,7 @@ export const styliseDateRange = (date) => { /** * @returns {string} */ -export const datasetSummary = ({nodes, visibility, mainTreeNumTips, branchLengthsToDisplay, filters, visibleStateCounts, t}) => { +export const datasetSummary = ({nodes, visibility, mainTreeNumTips, branchLengthsToDisplay, t}) => { const nSelectedSamples = getNumSelectedTips(nodes, visibility); const sampledDateRange = getVisibleDateRange(nodes, visibility); let summary = ""; /* text returned from this function */ @@ -72,22 +57,5 @@ export const datasetSummary = ({nodes, visibility, mainTreeNumTips, branchLength ); } summary += "."; - - /* parse filters */ - const filterTextArr = []; - Object.keys(filters).forEach((filterName) => { - const n = visibleStateCounts[filterName].size; - if (!n) return; - filterTextArr.push(`${n} ${pluralise(filterName, n)}`); - }); - const prefix = t("Comprising"); - const filterText = arrayToSentence(filterTextArr, {prefix: prefix, capatalise: false}); - if (filterText.length) { - summary += ` ${filterText}`; - } else if (summary.endsWith('.')) { - summary += " "; - } else { - summary += ". "; - } return summary; }; diff --git a/src/components/info/filtersSummary.js b/src/components/info/filtersSummary.js index df0850f87..f905695ab 100644 --- a/src/components/info/filtersSummary.js +++ b/src/components/info/filtersSummary.js @@ -32,7 +32,6 @@ const closeBracketSmall = { vaccines: false, version: 0, idxOfInViewRootNode: 0, - visibleStateCounts: {}, totalStateCounts: {}, observedMutations: {}, availableBranchLabels: [], @@ -52,7 +51,6 @@ const Tree = (state = getDefaultTreeState(), action) => { idxOfFilteredRoot: action.idxOfFilteredRoot, cladeName: action.cladeName, selectedClade: action.cladeName, - visibleStateCounts: countTraitsAcrossTree(state.nodes, action.stateCountAttrs, action.visibility, true), selectedStrain: action.selectedStrain }; return Object.assign({}, state, newStates); @@ -72,13 +70,9 @@ const Tree = (state = getDefaultTreeState(), action) => { case types.ADD_EXTRA_METADATA: // add data into `nodes` in-place, so no redux update will be triggered if you only listen to `nodes` addNodeAttrs(state.nodes, action.newNodeAttrs); - // add the new colorings to visibleStateCounts & totalStateCounts so that they can function as filters + // add the new colorings to totalStateCounts so that they can function as filters return { ...state, - visibleStateCounts: { - ...state.visibleStateCounts, - ...countTraitsAcrossTree(state.nodes, Object.keys(action.newColorings), state.visibility, true) - }, totalStateCounts: { ...state.totalStateCounts, ...countTraitsAcrossTree(state.nodes, Object.keys(action.newColorings), false, true) diff --git a/src/reducers/treeToo.js b/src/reducers/treeToo.js index cf6a8a207..6b195ce06 100644 --- a/src/reducers/treeToo.js +++ b/src/reducers/treeToo.js @@ -37,7 +37,6 @@ const treeToo = (state = getDefaultTreeState(), action) => { case types.CHANGE_DATES_VISIBILITY_THICKNESS: /* fallthrough */ case types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS: if (action.tangleTipLookup) { - // console.warn("NB missing visibleStateCounts from treeToo here"); return Object.assign({}, state, { tangleTipLookup: action.tangleTipLookup, visibility: action.visibilityToo, From 78ba458072a75ec652b939758bd5d55dc339353d Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 30 Jan 2024 10:16:39 +1300 Subject: [PATCH 3/5] Minor improvements to trait counting Deferring the value lookup will be slightly faster, especially for the (common) terminal-nodes-only case. We have no use for counting invalid values, so skip these. --- src/util/treeCountingHelpers.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/util/treeCountingHelpers.js b/src/util/treeCountingHelpers.js index ffcc140c0..b90cd34f7 100644 --- a/src/util/treeCountingHelpers.js +++ b/src/util/treeCountingHelpers.js @@ -15,7 +15,6 @@ export const countTraitsAcrossTree = (nodes, traits, visibility, terminalOnly) = nodes.forEach((node) => { traits.forEach((trait) => { // traits are "country" or "author" etc - const value = getTraitFromNode(node, trait); // value is "USA", "black" etc if (terminalOnly && node.hasChildren) { return; @@ -25,8 +24,10 @@ export const countTraitsAcrossTree = (nodes, traits, visibility, terminalOnly) = return; } - const currentValue = counts[trait].get(value) || 0; - counts[trait].set(value, currentValue+1); + const value = getTraitFromNode(node, trait); // value is "USA", "black" etc + if (value===undefined) return; // check for invalid values + const currentValueCount = counts[trait].get(value) || 0; + counts[trait].set(value, currentValueCount+1); }); }); return counts; From 8a0b9046485683cbd8aab601a539d99cb2538898 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 30 Jan 2024 10:24:13 +1300 Subject: [PATCH 4/5] Filter on all available data The sidebar filtering now surfaces all valid node-attrs defined across the (terminal) nodes. URL queries (`?f_${attrName}=value1,value2,...`) also work for all known attrs. Attributes which are known to be continuous (via a colorings definition) are excluded, as the filtering UI is not (yet) able to handle these; if a non-coloring continuous attribute is set on the nodes then this will end up as a multitude of numerical options in the sidebar. As part of this implementation we have removed `stateCountAttrs` from redux state and improved the validation of (filtering) URL queries. The behaviour of filtering, and the restriction to collecting attributes from terminal nodes only, is unchanged. See #1275 for more context. Closes #1251 --- src/actions/recomputeReduxState.js | 102 ++++++++++++++++++----------- src/actions/tree.js | 2 +- src/components/controls/filter.js | 73 +++++++++++++-------- src/components/framework/footer.js | 23 +++---- src/reducers/controls.ts | 7 +- src/util/getGenotype.js | 1 + src/util/treeCountingHelpers.js | 29 ++++++++ 7 files changed, 154 insertions(+), 83 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index ed33c9ca2..d5859bced 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -8,7 +8,7 @@ import { constructVisibleTipLookupBetweenTrees } from "../util/treeTangleHelpers import { getDefaultControlsState, shouldDisplayTemporalConfidence } from "../reducers/controls"; import { getDefaultFrequenciesState } from "../reducers/frequencies"; import { getDefaultMeasurementsState } from "../reducers/measurements"; -import { countTraitsAcrossTree, calcTotalTipsInTree } from "../util/treeCountingHelpers"; +import { countTraitsAcrossTree, calcTotalTipsInTree, gatherTraitNames } from "../util/treeCountingHelpers"; import { calcEntropyInView } from "../util/entropy"; import { treeJsonToState } from "../util/treeJsonProcessing"; import { castIncorrectTypes } from "../util/castJsonTypes"; @@ -100,16 +100,21 @@ const modifyStateViaURLQuery = (state, query) => { state["dateMax"] = query.dmax; state["dateMaxNumeric"] = calendarToNumeric(query.dmax); } + + /** Queries 's', 'gt', and 'f_' correspond to active filters */ for (const filterKey of Object.keys(query).filter((c) => c.startsWith('f_'))) { - state.filters[filterKey.replace('f_', '')] = query[filterKey].split(',') - .map((value) => ({value, active: true})); /* all filters in the URL are "active" */ + const filterName = filterKey.replace('f_', ''); + const filterValues = query[filterKey] ? query[filterKey].split(',') : []; + state.filters[filterName] = filterValues.map((value) => ({value, active: true})) } - if (query.s) { // selected strains are a filter too - state.filters[strainSymbol] = query.s.split(',').map((value) => ({value, active: true})); + if (query.s) { + const filterValues = query.s ? query.s.split(',') : []; + state.filters[strainSymbol] = filterValues.map((value) => ({value, active: true})); } if (query.gt) { - state.filters[genotypeSymbol] = decodeGenotypeFilters(query.gt); + state.filters[genotypeSymbol] = decodeGenotypeFilters(query.gt||""); } + if (query.animate) { const params = query.animate.split(','); // console.log("start animation!", params); @@ -225,19 +230,16 @@ const modifyStateViaMetadata = (state, metadata, genomeMap) => { state["analysisSlider"] = {key: metadata.analysisSlider, valid: false}; } if (metadata.filters) { - /* the `meta -> filters` JSON spec should define which filters are displayed in the footer. - Note that this UI may change, and if so then we can change this state name. */ + /** + * this spec previously defined both the footer-filters and the + * sidebar-filters, however it now only defines the former as the sidebar + * surfaces all available attributes. + */ state.filtersInFooter = [...metadata.filters]; - /* TODO - these will be searchable => all available traits should be added and this block shifted up */ - metadata.filters.forEach((v) => { - state.filters[v] = []; - }); } else { console.warn("JSON did not include any filters"); state.filtersInFooter = []; } - state.filters[strainSymbol] = []; - state.filters[genotypeSymbol] = []; // this doesn't necessitate that mutations are defined if (metadata.displayDefaults) { const keysToCheckFor = ["geoResolution", "colorBy", "distanceMeasure", "layout", "mapTriplicate", "selectedBranchLabel", "tipLabelKey", 'sidebar', "showTransmissionLines", "normalizeFrequencies"]; const expectedTypes = ["string", "string", "string", "string", "boolean", "string", 'string', 'string', "boolean" , "boolean"]; @@ -579,32 +581,51 @@ const checkAndCorrectErrorsInState = (state, metadata, genomeMap, query, tree, v delete query.ci; // rm ci from the query if it doesn't apply } - /* ensure selected filters (via the URL query) are valid. If not, modify state + URL. */ - const filterNames = Object.keys(state.filters).filter((filterName) => state.filters[filterName].length); - const stateCounts = countTraitsAcrossTree(tree.nodes, filterNames, false, true); - filterNames.forEach((filterName) => { - const validItems = state.filters[filterName] - .filter((item) => stateCounts[filterName].has(item.value)); - state.filters[filterName] = validItems; - if (!validItems.length) { - delete query[`f_${filterName}`]; + /** + * Any filters currently set are done so via the URL query, which we validate now + * (and update the URL query accordingly) + */ + const _queryKey = (traitName) => (traitName === strainSymbol) ? 's' : + (traitName === genotypeSymbol) ? 'gt' : + `f_${traitName}`; + + for (const traitName of Reflect.ownKeys(state.filters)) { + /* delete empty filters, e.g. "?f_country" or "?f_country=" */ + if (!state.filters[traitName].length) { + delete state.filters[traitName]; + delete query[_queryKey(traitName)]; + continue + } + /* delete filter names (e.g. country, region) which aren't observed on the tree */ + if (!Object.keys(tree.totalStateCounts).includes(traitName) && traitName!==strainSymbol && traitName!==genotypeSymbol) { + delete state.filters[traitName]; + delete query[_queryKey(traitName)]; + continue + } + /* delete filter values (e.g. USA, Oceania) which aren't valid, i.e. observed on the tree */ + const traitValues = state.filters[traitName].map((f) => f.value); + let validTraitValues; + if (traitName === strainSymbol) { + const nodeNames = new Set(tree.nodes.map((n) => n.name)); + validTraitValues = traitValues.filter((v) => nodeNames.has(v)); + } else if (traitName === genotypeSymbol) { + const observedMutations = collectGenotypeStates(tree.nodes); + validTraitValues = traitValues.filter((v) => observedMutations.has(v)); } else { - query[`f_${filterName}`] = validItems.map((x) => x.value).join(","); + validTraitValues = traitValues.filter((value) => tree.totalStateCounts[traitName].has(value)); + } + if (validTraitValues.length===0) { + delete state.filters[traitName]; + delete query[_queryKey(traitName)]; + } else if (traitValues.length !== validTraitValues.length) { + state.filters[traitName] = validTraitValues.map((value) => ({value, active: true})); + query[_queryKey(traitName)] = traitName === genotypeSymbol ? + encodeGenotypeFilters(state.filters[traitName]) : + validTraitValues.join(","); } - }); - if (state.filters[strainSymbol]) { - const validNames = tree.nodes.map((n) => n.name); - state.filters[strainSymbol] = state.filters[strainSymbol] - .filter((strainFilter) => validNames.includes(strainFilter.value)); - query.s = state.filters[strainSymbol].map((f) => f.value).join(","); - if (!query.s) delete query.s; - } - if (state.filters[genotypeSymbol]) { - const observedMutations = collectGenotypeStates(tree.nodes); - state.filters[genotypeSymbol] = state.filters[genotypeSymbol] - .filter((f) => observedMutations.has(f.value)); - query.gt = encodeGenotypeFilters(state.filters[genotypeSymbol]); } + /* Also remove any traitNames from the footer-displayed filters if they're not present on the tree */ + state.filtersInFooter = state.filtersInFooter.filter((traitName) => traitName in tree.totalStateCounts); /* can we display branch length by div or num_date? */ if (query.m && state.branchLengthsToDisplay !== "divAndDate") { @@ -667,10 +688,7 @@ const modifyTreeStateVisAndBranchThickness = (oldState, zoomSelected, controlsSt ); const newState = Object.assign({}, oldState, visAndThicknessData); - newState.stateCountAttrs = Object.keys(controlsState.filters); newState.idxOfInViewRootNode = newIdxRoot; - newState.totalStateCounts = countTraitsAcrossTree(newState.nodes, newState.stateCountAttrs, false, true); - return newState; }; @@ -867,6 +885,10 @@ export const createStateFromQueryOrJSONs = ({ } const viewingNarrative = (narrativeBlocks || (oldState && oldState.narrative.display)); + + const stateCountAttrs = gatherTraitNames(tree.nodes, metadata.colorings); + tree.totalStateCounts = countTraitsAcrossTree(tree.nodes, stateCountAttrs, false, true); + controls = checkAndCorrectErrorsInState(controls, metadata, entropy.genomeMap, query, tree, viewingNarrative); /* must run last */ diff --git a/src/actions/tree.js b/src/actions/tree.js index a8e1cd8ac..a5f7de910 100644 --- a/src/actions/tree.js +++ b/src/actions/tree.js @@ -272,7 +272,7 @@ export const applyFilter = (mode, trait, values) => { console.error(`trying to ${mode} values from an un-initialised filter!`); return; } - newValues = controls.filters[trait].slice(); + newValues = controls.filters[trait].map((f) => ({...f})); const currentItemNames = newValues.map((i) => i.value); for (const item of values) { const idx = currentItemNames.indexOf(item); diff --git a/src/components/controls/filter.js b/src/components/controls/filter.js index 4333cf78a..90159c39a 100644 --- a/src/components/controls/filter.js +++ b/src/components/controls/filter.js @@ -2,7 +2,7 @@ import React from "react"; import { connect } from "react-redux"; import AsyncSelect from "react-select/async"; import { debounce } from 'lodash'; -import { controlsWidth, isValueValid, strainSymbol, genotypeSymbol} from "../../util/globals"; +import { controlsWidth, strainSymbol, genotypeSymbol} from "../../util/globals"; import { collectGenotypeStates } from "../../util/treeMiscHelpers"; import { applyFilter } from "../../actions/tree"; import { removeAllFieldFilters, toggleAllFieldFilters, applyMeasurementFilter } from "../../actions/measurements"; @@ -24,6 +24,7 @@ const DEBOUNCE_TIME = 200; activeFilters: state.controls.filters, colorings: state.metadata.colorings, totalStateCounts: state.tree.totalStateCounts, + canFilterByGenotype: !!state.entropy.genomeMap, nodes: state.tree.nodes, measurementsFieldsMap: state.measurements.collectionToDisplay.fields, measurementsFiltersMap: state.measurements.collectionToDisplay.filters, @@ -54,22 +55,40 @@ class FilterData extends React.Component { * each time a filter is toggled on / off. */ const options = []; - Object.keys(this.props.activeFilters) - .forEach((filterName) => { - const filterTitle = this.getFilterTitle(filterName); - const filterValuesCurrentlyActive = this.props.activeFilters[filterName].filter((x) => x.active).map((x) => x.value); - Array.from(this.props.totalStateCounts[filterName].keys()) - .filter((itemName) => isValueValid(itemName)) // remove invalid values present across the tree - .filter((itemName) => !filterValuesCurrentlyActive.includes(itemName)) // remove already enabled filters - .sort() // filters are sorted alphabetically - probably not necessary for a select component - .forEach((itemName) => { - options.push({ - label: `${filterTitle} → ${itemName}`, - value: [filterName, itemName] - }); - }); - }); - if (genotypeSymbol in this.props.activeFilters) { + + /** + * First set of options is from the totalStateCounts -- i.e. every node attr + * which we know about (minus any currently selected filters). Note that we + * can't filter the filters to those set on visible nodes, as selecting a + * filter from outside this is perfectly fine in many situations. + * + * Those which are colorings appear first (and in the order defined in + * colorings). Within each trait, the values are alphabetical + */ + const coloringKeys = Object.keys(this.props.colorings||{}); + const unorderedTraitNames = Object.keys(this.props.totalStateCounts); + const traitNames = [ + ...coloringKeys.filter((name) => unorderedTraitNames.includes(name)), + ...unorderedTraitNames.filter((name) => !coloringKeys.includes(name)) + ] + for (const traitName of traitNames) { + const traitData = 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)); + for (const traitValue of Array.from(traitData.keys()).sort()) { + if (filterValuesCurrentlyActive.has(traitValue)) continue; + options.push({ + label: `${traitTitle} → ${traitValue}`, + value: [traitName, traitValue] + }); + } + } + + /** + * Genotype filter options are numerous, they're the set of all observed + * mutations + */ + if (this.props.canFilterByGenotype) { Array.from(collectGenotypeStates(this.props.nodes)) .sort() .forEach((o) => { @@ -79,16 +98,16 @@ class FilterData extends React.Component { }); }); } - if (strainSymbol in this.props.activeFilters) { - this.props.nodes - .filter((n) => !n.hasChildren) - .forEach((n) => { - options.push({ - label: `sample → ${n.name}`, - value: [strainSymbol, n.name] - }); + + this.props.nodes + .filter((n) => !n.hasChildren) + .forEach((n) => { + options.push({ + label: `sample → ${n.name}`, + value: [strainSymbol, n.name] }); - } + }); + if (this.props.measurementsOn && this.props.measurementsFiltersMap && this.props.measurementsFieldsMap) { this.props.measurementsFiltersMap.forEach(({values}, filterField) => { const { title } = this.props.measurementsFieldsMap.get(filterField); @@ -148,7 +167,7 @@ class FilterData extends React.Component { const measurementsFilters = this.summariseMeasurementsFilters(); /* When filter categories were dynamically created (via metadata drag&drop) the `options` here updated but `` seemed to use a cached version of all values & wouldn't update. Changing the key forces a rerender, but it's not ideal */ - const divKey = String(Object.keys(this.props.activeFilters).length); + const divKey = String(Object.keys(this.props.totalStateCounts).join(",")); return (
{ }; const dispatchFilter = (dispatch, activeFilters, key, value) => { - const activeValuesOfFilter = activeFilters[key].map((f) => f.value); + const activeValuesOfFilter = (activeFilters[key] || []).map((f) => f.value); const mode = activeValuesOfFilter.indexOf(value) === -1 ? "add" : "remove"; dispatch(applyFilter(mode, key, [value])); }; @@ -245,11 +245,12 @@ class Footer extends React.Component { displayFilter(filterName) { const { t } = this.props; const totalStateCount = this.props.totalStateCounts[filterName]; + if (!totalStateCount) return null; const filterTitle = this.props.metadata.colorings[filterName] ? this.props.metadata.colorings[filterName].title : filterName; - const activeFilterItems = this.props.activeFilters[filterName].filter((x) => x.active).map((x) => x.value); + const activeFilterItems = (this.props.activeFilters[filterName] || []).filter((x) => x.active).map((x) => x.value); const title = (
{t("Filter by {{filterTitle}}", {filterTitle: filterTitle}) + ` (n=${totalStateCount.size})`} - {this.props.activeFilters[filterName].length ? removeFiltersButton(this.props.dispatch, [filterName], "inlineRight", t("Clear {{filterName}} filter", { filterName: filterName})) : null} + {this.props.activeFilters?.[filterName]?.length ? removeFiltersButton(this.props.dispatch, [filterName], "inlineRight", t("Clear {{filterName}} filter", { filterName: filterName})) : null}
); return (
@@ -293,16 +294,12 @@ class Footer extends React.Component {
{getAcknowledgments(this.props.metadata, this.props.dispatch)}
- {Object.keys(this.props.activeFilters) - .filter((name) => this.props.filtersInFooter.includes(name)) - .map((name) => { - return ( -
- {this.displayFilter(name)} -
-
- ); - })} + {this.props.filtersInFooter.map((name) => ( +
+ {this.displayFilter(name)} +
+
+ ))}
); diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index 58b20293f..c78a82e14 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -247,7 +247,11 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con case types.APPLY_FILTER: { // values arrive as array const filters = Object.assign({}, state.filters, {}); - filters[action.trait] = action.values; + if (action.values.length) { // set the filters to the new values + filters[action.trait] = action.values; + } else { // remove if no active+inactive filters + delete filters[action.trait] + } return Object.assign({}, state, { filters }); @@ -284,7 +288,6 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con return Object.assign({}, state, { legendOpen: action.value }); case types.ADD_EXTRA_METADATA: { for (const colorBy of Object.keys(action.newColorings)) { - state.filters[colorBy] = []; state.coloringsPresentOnTree.add(colorBy); } let newState = Object.assign({}, state, { coloringsPresentOnTree: state.coloringsPresentOnTree, filters: state.filters }); diff --git a/src/util/getGenotype.js b/src/util/getGenotype.js index 8310dbae1..6da8e8f7b 100644 --- a/src/util/getGenotype.js +++ b/src/util/getGenotype.js @@ -102,6 +102,7 @@ export const decodeGenotypeFilters = (query) => { } return `${currentGene} ${x}`; }) + .filter((value) => !!value) .map((value) => ({active: true, value})); // all URL filters _start_ active }; diff --git a/src/util/treeCountingHelpers.js b/src/util/treeCountingHelpers.js index b90cd34f7..1c440cb03 100644 --- a/src/util/treeCountingHelpers.js +++ b/src/util/treeCountingHelpers.js @@ -33,6 +33,35 @@ export const countTraitsAcrossTree = (nodes, traits, visibility, terminalOnly) = return counts; }; + +/** + * Scan terminal nodes and gather all trait names with at least one valid value. + * Includes a hardcoded list of trait names we will ignore, as well as any trait + * which we know is continuous (via a colouring definition) because the + * filtering is not designed for these kinds of data (yet). + * @param {Array} nodes + * @param {Object} colorings + * @returns {Array} list of trait names + */ +export const gatherTraitNames = (nodes, colorings) => { + const ignore = new Set([ + 'num_date', + ...Object.entries(colorings).filter(([_, info]) => info.type==='continuous').map(([name, _]) => name), + ]) + const names = new Set(); + for (const node of nodes) { + if (node.hasChildren) continue; + for (const traitName in node.node_attrs || {}) { + if (ignore.has(traitName)) continue; + if (names.has(traitName)) continue; + if (getTraitFromNode(node, traitName)) { // ensures validity + names.add(traitName); + } + } + } + return [...names] +} + /** * for each node, calculate the number of subtending tips which are visible * side effects: n.tipCount for each node From 70665c8def79a2d2b883c9b26473f4a91362426e Mon Sep 17 00:00:00 2001 From: james hadfield Date: Fri, 2 Feb 2024 15:27:53 +1300 Subject: [PATCH 5/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe29b7f63..22d2b596a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +* Sidebar filtering now contains all non-continuous metadata defined across the tree (i.e. all data within `node.node_attrs`). The traits listed in `meta.filters` are now only used to determine which filters to list in the footer of the page. ([#1743](https://github.com/nextstrain/auspice/pull/1743)) * Added a link to this changelog from the Auspice view. ([#1727](https://github.com/nextstrain/auspice/pull/1727)) ## version 2.51.0 - 2023/11/16