UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

243 lines (235 loc) 11.6 kB
import React from "react"; import { connect } from "react-redux"; import { withTranslation } from 'react-i18next'; import { applyFilter, changeDateFilter } from "../../actions/tree"; import { applyMeasurementFilter, removeSingleFilter } from "../../actions/measurements"; import { strainSymbol, genotypeSymbol, getAminoAcidName } from "../../util/globals"; import { FilterBadge, Tooltip } from "./filterBadge"; import { styliseDateRange, pluralise } from "./datasetSummary"; import { createFilterConstellation } from "../../util/treeVisibilityHelpers"; const Intersect = ({id}) => ( <span style={{fontSize: "2rem", fontWeight: 300, padding: "0px 4px 0px 2px", cursor: 'help'}} data-tip data-for={id}><Tooltip id={id}>{`Groups of filters are combined by intersection`}</Tooltip> </span> ); const Union = () => ( <span style={{fontSize: "1.5rem", padding: "0px 3px 0px 2px"}}></span> ); const openBracketBig = <span style={{fontSize: "2rem", fontWeight: 300, padding: "0px 0px 0px 2px"}}>{'{'}</span>; const closeBracketBig = <span style={{fontSize: "2rem", fontWeight: 300, padding: "0px 2px"}}>{'}'}</span>; const openBracketSmall = <span style={{fontSize: "1.8rem", fontWeight: 300, padding: "0px 2px"}}>{'{'}</span>; const closeBracketSmall = <span style={{fontSize: "1.8rem", fontWeight: 300, padding: "0px 2px"}}>{'}'}</span>; @connect((state) => { return { browserDimensions: state.browserDimensions.browserDimensions, filters: state.controls.filters, animationPlayPauseButton: state.controls.animationPlayPauseButton, metadata: state.metadata, nodes: state.tree.nodes, totalStateCounts: state.tree.totalStateCounts, totalStateCountsSecondTree: state.treeToo?.totalStateCounts, visibility: state.tree.visibility, dateMin: state.controls.dateMin, dateMax: state.controls.dateMax, absoluteDateMin: state.controls.absoluteDateMin, absoluteDateMax: state.controls.absoluteDateMax, branchLengthsToDisplay: state.controls.branchLengthsToDisplay, measurementsFilters: state.controls.measurementsFilters, measurementsFields: state.measurements.collectionToDisplay?.fields }; }) class FiltersSummary extends React.Component { constructor(props) { super(props); } createIndividualBadge({filterName, item, label, onHoverMessage}) { return ( <FilterBadge key={item.value} id={String(item.value)} remove={() => {this.props.dispatch(applyFilter("remove", filterName, [item.value]));}} canMakeInactive onHoverMessage={onHoverMessage} active={item.active} activate={() => {this.props.dispatch(applyFilter("add", filterName, [item.value]));}} inactivate={() => {this.props.dispatch(applyFilter("inactivate", filterName, [item.value]));}} > {label} </FilterBadge> ); } createFilterBadgesForTime() { return ([ <FilterBadge key="timefilter" id="timefilter" onHoverMessage="Filtering to data sampled in this date range" remove={() => this.props.dispatch(changeDateFilter({newMin: this.props.absoluteDateMin, newMax: this.props.absoluteDateMax}))} > {`${styliseDateRange(this.props.dateMin)} to ${styliseDateRange(this.props.dateMax)}`} </FilterBadge> ]); } createFilterBadges(filterName) { const filterNameString = filterName===strainSymbol ? "sample" : filterName; const nFilterValues = this.props.filters[filterName].length; const onHoverMessage = nFilterValues === 1 ? `Filtering data to this ${filterNameString}` : `Filtering data to these ${nFilterValues} ${pluralise(filterNameString)}`; return this.props.filters[filterName] .sort((a, b) => a.value < b.value ? -1 : a.value > b.value ? 1 : 0) .map((item) => { let label = `${item.value}`; if (filterName!==strainSymbol) { /* Add the _total_ occurrences in parentheses. We don't compute the intersections / visible values here - e.g. we can have "England (5)" "North America (100)" even though such an intersection will deselect everything. If we have two trees shown we show both values. */ const tree1count = this.props.totalStateCounts[filterName]?.get(item.value) ?? 0; if (this.props.totalStateCountsSecondTree && Reflect.ownKeys(this.props.totalStateCountsSecondTree).length) { const tree2count = this.props.totalStateCountsSecondTree[filterName]?.get(item.value) ?? 0; label+=` (L: ${tree1count}, R: ${tree2count})`; } else { label+=` (${tree1count})`; } } return this.createIndividualBadge({filterName, item, label, onHoverMessage}); }); } createFilterBadgesForGenotype() { const filters = this.props.filters[genotypeSymbol]; const activeSet = new Set(filters.filter((f) => f.active).map((f) => f.value)); const constellation = createFilterConstellation(filters.map((f) => f.value)); return constellation.map((c) => { const nt = c[0]==="nuc"; if (c[2].size===1) { // filtered to a single codon/nt at this position const residue = [...c[2]][0]; const onHoverMessage = nt ? `Filtering to sequences with nucleotide ${residue} at base ${c[1]}` : `Filtering to sequences with ${getAminoAcidName(residue)} at codon ${c[1]} in ${c[0]}`; const label = `${c[0]} ${c[1]}${residue}`; const item = {active: activeSet.has(label), value: label}; return this.createIndividualBadge({filterName: genotypeSymbol, item, label: item.value, onHoverMessage}); } // filtered to multiple residues at this position using OR logic. const residues = [...c[2]]; const labels = residues.map((residue) => `${c[0]} ${c[1]}${residue}`); const activeResidues = residues.filter((_, i) => activeSet.has(labels[i])); // active means filter is active const stringify = (l) => l.length>1 ? `${l.slice(0, -1).join(", ")} or ${l[l.length-1]}` : l[0]; const onHoverMessage = nt ? `Filtering to sequences with nucleotides ${stringify(activeResidues)} at position ${c[1]}` : `Filtering to sequences with ${stringify(activeResidues.map((r) => getAminoAcidName(r)))} at codon ${c[1]} in ${c[0]}`; return residues.map((residue) => { const label = `${c[0]} ${c[1]}${residue}`; const item = {active: activeSet.has(label), value: label}; return this.createIndividualBadge({filterName: genotypeSymbol, item, label: item.value, onHoverMessage}); }); }); } render() { const { t } = this.props; // create an array of objects, with each object containing the badges for a filter category. // E.g. `filtersByCategory[i] = {name: "country", badges: Array<FilterBadgeComponent>`}` const filtersByCategory = []; Reflect.ownKeys(this.props.filters) .filter((filterName) => this.props.filters[filterName].length > 0) .forEach((filterName) => { if (filterName===strainSymbol) { filtersByCategory.push({name: 'sample', badges: this.createFilterBadges(strainSymbol)}); } else if (filterName===genotypeSymbol) { filtersByCategory.push({name: 'genotype', badges: this.createFilterBadgesForGenotype()}); } else { filtersByCategory.push({name: filterName, badges: this.createFilterBadges(filterName)}); } }); // temporal filtering is not a regular filter (i.e. this.props.filters) if (!(this.props.dateMin===this.props.absoluteDateMin && this.props.dateMax===this.props.absoluteDateMax)) { filtersByCategory.push({name: 'temporal', badges: this.createFilterBadgesForTime()}); } if (!filtersByCategory.length && !Object.keys(this.props.measurementsFilters).length) return null; return ( <> {filtersByCategory.length > 0 && <> {t("Filtered to") + " "} {filtersByCategory.map((filterCategory, idx) => { const multipleFilterBadges = filterCategory.badges.length > 1; const previousCategoriesRendered = idx!==0; return ( <span style={{fontSize: "2rem", padding: "0px 2px"}} key={filterCategory.name}> {previousCategoriesRendered && <Intersect id={'intersect'+idx}/>} {multipleFilterBadges && openBracketBig} {/* multiple badges => surround with set notation */} {filterCategory.badges.map((badge, badgeIdx) => { if (Array.isArray(badge)) { // if `badge` is an array then we wish to render a set-within-a-set return ( <span key={badge.map((b) => b.props.id).join("")}> {openBracketSmall} {badge.map((el, elIdx) => ( <span key={el.props.id}> {el} {elIdx!==badge.length-1 && <Union/>} </span> ))} {closeBracketSmall} {badgeIdx!==filterCategory.badges.length-1 && ", "} </span> ); } return ( <span key={badge.props.id}> {badge} {badgeIdx!==filterCategory.badges.length-1 && ", "} </span> ); })} {multipleFilterBadges && closeBracketBig} </span> ); })} {". "} </> } {(Object.keys(this.props.measurementsFilters).length > 0 && this.props.measurementsFields !== undefined) && <> <br/> {t("Measurements filtered to") + " "} {Object.entries(this.props.measurementsFilters).map(([field, valuesMap], fieldIndex) => { const fieldTitle = this.props.measurementsFields.get(field).title; return ( <span key={field}> {openBracketBig} {`${fieldTitle}: `} {[...valuesMap].map(([fieldValue, {active}], valueIndex) => { return ( <span key={fieldValue} style={{fontSize: "2rem", padding: "0px 2px"}}> <FilterBadge active={active} canMakeInactive id={String(fieldValue)} remove={() => this.props.dispatch(removeSingleFilter(field, fieldValue))} activate={() => this.props.dispatch(applyMeasurementFilter(field, fieldValue, true))} inactivate={() => this.props.dispatch(applyMeasurementFilter(field, fieldValue, false))} onHoverMessage={`Filtering measurements to this ${fieldTitle}`} > {fieldValue} </FilterBadge> {valueIndex < (valuesMap.size - 1) && ","} </span> ); })} {closeBracketBig} {fieldIndex < (Object.keys(this.props.measurementsFilters).length - 1) && <Intersect/>} </span> ); })} </> } </> ); } } const WithTranslation = withTranslation()(FiltersSummary); export default WithTranslation;