diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b0e89bf..c01ed8e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +* Added an experimental "Focus on Selected" toggle in the sidebar. + When focusing on selected nodes, nodes that do not match the filter will occupy less vertical space on the tree. + Only applicable to rectangular and radial layouts. + ([#1373](https://github.com/nextstrain/auspice/pull/1373)) + ## version 2.58.0 - 2024/09/12 diff --git a/src/actions/types.js b/src/actions/types.js index 10a71d8db..fb59b92fe 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -7,6 +7,7 @@ export const SEARCH_INPUT_CHANGE = "SEARCH_INPUT_CHANGE"; export const CHANGE_LAYOUT = "CHANGE_LAYOUT"; export const CHANGE_BRANCH_LABEL = "CHANGE_BRANCH_LABEL"; export const CHANGE_DISTANCE_MEASURE = "CHANGE_DISTANCE_MEASURE"; +export const TOGGLE_FOCUS = "TOGGLE_FOCUS"; export const CHANGE_DATES_VISIBILITY_THICKNESS = "CHANGE_DATES_VISIBILITY_THICKNESS"; export const CHANGE_ABSOLUTE_DATE_MIN = "CHANGE_ABSOLUTE_DATE_MIN"; export const CHANGE_ABSOLUTE_DATE_MAX = "CHANGE_ABSOLUTE_DATE_MAX"; diff --git a/src/components/controls/controls.tsx b/src/components/controls/controls.tsx index 8facaef8e..9196bfa4f 100644 --- a/src/components/controls/controls.tsx +++ b/src/components/controls/controls.tsx @@ -18,12 +18,14 @@ import TransmissionLines from './transmission-lines'; import NormalizeFrequencies from "./frequency-normalization"; import AnimationOptions from "./animation-options"; import { PanelSection } from "./panelSection"; +import ToggleFocus from "./toggle-focus"; import ToggleTangle from "./toggle-tangle"; import Language from "./language"; import { ControlsContainer } from "./styles"; import FilterData, {FilterInfo} from "./filter"; import {TreeInfo, MapInfo, AnimationOptionsInfo, PanelLayoutInfo, - ExplodeTreeInfo, EntropyInfo, FrequencyInfo, MeasurementsInfo} from "./miscInfoText"; + ExplodeTreeInfo, EntropyInfo, FrequencyInfo, MeasurementsInfo, + ToggleFocusInfo} from "./miscInfoText"; import { ControlHeader } from "./controlHeader"; import MeasurementsOptions from "./measurementsOptions"; import { RootState } from "../../store"; @@ -64,6 +66,7 @@ function Controls() { tooltip={TreeInfo} options={<> + diff --git a/src/components/controls/miscInfoText.js b/src/components/controls/miscInfoText.js index dbf542db4..382aaf665 100644 --- a/src/components/controls/miscInfoText.js +++ b/src/components/controls/miscInfoText.js @@ -64,3 +64,11 @@ export const ExplodeTreeInfo = ( It works best when the trait doesn't change value too frequently. ); + +export const ToggleFocusInfo = ( + <>This functionality is experimental and should be treated with caution! +
When focusing on selected nodes, nodes that do not match the + filter will occupy less vertical space on the tree. Only applicable to + rectangular and radial layouts. + +); diff --git a/src/components/controls/toggle-focus.tsx b/src/components/controls/toggle-focus.tsx new file mode 100644 index 000000000..2f82f87ae --- /dev/null +++ b/src/components/controls/toggle-focus.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { connect } from "react-redux"; +import { FaInfoCircle } from "react-icons/fa"; +import { Dispatch } from "@reduxjs/toolkit"; +import Toggle from "./toggle"; +import { SidebarIconContainer, StyledTooltip } from "./styles"; +import { TOGGLE_FOCUS } from "../../actions/types"; +import { RootState } from "../../store"; + + +function ToggleFocus({ tooltip, focus, layout, dispatch, mobileDisplay }: { + tooltip: React.ReactElement; + focus: boolean; + layout: "rect" | "radial" | "unrooted" | "clock" | "scatter"; + dispatch: Dispatch; + mobileDisplay: boolean; +}) { + // Focus functionality is only available to layouts that have the concept of a unitless y-axis + const validLayouts = new Set(["rect", "radial"]); + if (!validLayouts.has(layout)) return <>; + + const label = ( +
+ Focus on Selected + {tooltip && !mobileDisplay && ( + <> + + + + + {tooltip} + + + )} +
+ ); + + return ( + dispatch({ type: TOGGLE_FOCUS })} + label={label} + style={{ paddingBottom: "10px" }} + /> + ); +} + +export default connect((state: RootState) => ({ + focus: state.controls.focus, + layout: state.controls.layout, + mobileDisplay: state.general.mobileDisplay, +}))(ToggleFocus); diff --git a/src/components/controls/toggle.js b/src/components/controls/toggle.js index 17441e204..18c355ac1 100644 --- a/src/components/controls/toggle.js +++ b/src/components/controls/toggle.js @@ -1,4 +1,5 @@ import React from "react"; +import { ImLab } from "react-icons/im"; import styled from 'styled-components'; import { SidebarSubtitle } from "./styles"; @@ -28,6 +29,11 @@ const ToggleSubtitle = styled(SidebarSubtitle)` width: 200px; `; +const ExperimentalIcon = styled.span` + color: ${(props) => props.theme.color}; + margin-right: 5px; +` + const Slider = styled.div` & { position: absolute; @@ -73,11 +79,16 @@ const Input = styled.input` `; -const Toggle = ({display, on, callback, label, style={}}) => { +const Toggle = ({display, isExperimental = false, on, callback, label, style={}}) => { if (!display) return null; return ( + {isExperimental && + + + + } diff --git a/src/components/tree/index.ts b/src/components/tree/index.ts index b7931c96b..07218e725 100644 --- a/src/components/tree/index.ts +++ b/src/components/tree/index.ts @@ -8,6 +8,7 @@ const Tree = connect((state: RootState) => ({ selectedNode: state.controls.selectedNode, dateMinNumeric: state.controls.dateMinNumeric, dateMaxNumeric: state.controls.dateMaxNumeric, + filters: state.controls.filters, quickdraw: state.controls.quickdraw, colorBy: state.controls.colorBy, colorByConfidence: state.controls.colorByConfidence, @@ -16,6 +17,7 @@ const Tree = connect((state: RootState) => ({ temporalConfidence: state.controls.temporalConfidence, distanceMeasure: state.controls.distanceMeasure, explodeAttr: state.controls.explodeAttr, + focus: state.controls.focus, colorScale: state.controls.colorScale, colorings: state.metadata.colorings, genomeMap: state.entropy.genomeMap, diff --git a/src/components/tree/phyloTree/change.js b/src/components/tree/phyloTree/change.js index a3a0c4277..78fd96e1d 100644 --- a/src/components/tree/phyloTree/change.js +++ b/src/components/tree/phyloTree/change.js @@ -270,6 +270,7 @@ export const change = function change({ tipRadii = undefined, branchThickness = undefined, /* other data */ + focus = undefined, scatterVariables = undefined }) { // console.log("\n** phylotree.change() (time since last run:", Date.now() - this.timeLastRenderRequested, "ms) **\n\n"); @@ -323,7 +324,7 @@ export const change = function change({ } if (changeNodeOrder) { - setDisplayOrder(this.nodes); + setDisplayOrder({nodes: this.nodes, focus}); this.setDistance(); } @@ -359,7 +360,9 @@ export const change = function change({ /* run calculations as needed - these update properties on the phylotreeNodes (similar to updateNodesWithNewData) */ /* distance */ if (newDistance || updateLayout) this.setDistance(newDistance); - /* layout (must run after distance) */ + /* focus */ + if (updateLayout) setDisplayOrder({nodes: this.nodes, focus}); + /* layout (must run after distance and focus) */ if (newDistance || newLayout || updateLayout || changeNodeOrder) { this.setLayout(newLayout || this.layout, scatterVariables); } diff --git a/src/components/tree/phyloTree/helpers.js b/src/components/tree/phyloTree/helpers.js index 899f69842..82a13684e 100644 --- a/src/components/tree/phyloTree/helpers.js +++ b/src/components/tree/phyloTree/helpers.js @@ -1,6 +1,8 @@ /* eslint-disable no-param-reassign */ import { max } from "d3-array"; import {getTraitFromNode, getDivFromNode, getBranchMutations} from "../../../util/treeMiscHelpers"; +import { NODE_VISIBLE } from "../../../util/globals"; +import { timerStart, timerEnd } from "../../../util/perf"; /** get a string to be used as the DOM element ID * Note that this cannot have any "special" characters @@ -33,18 +35,22 @@ export const applyToChildren = (phyloNode, func) => { * of nodes in a rectangular tree. * If `yCounter` is undefined then we wish to hide the node and all descendants of it * @param {PhyloNode} node + * @param {function} incrementer * @param {int|undefined} yCounter * @sideeffect modifies node.displayOrder and node.displayOrderRange * @returns {int|undefined} current yCounter after assignment to the tree originating from `node` */ -export const setDisplayOrderRecursively = (node, yCounter) => { +export const setDisplayOrderRecursively = (node, incrementer, yCounter) => { const children = node.n.children; // (redux) tree node if (children && children.length) { for (let i = children.length - 1; i >= 0; i--) { - yCounter = setDisplayOrderRecursively(children[i].shell, yCounter); + yCounter = setDisplayOrderRecursively(children[i].shell, incrementer, yCounter); } } else { - node.displayOrder = (node.n.fullTipCount===0 || yCounter===undefined) ? yCounter : ++yCounter; + if (node.n.fullTipCount !== 0 && yCounter !== undefined) { + yCounter += incrementer(node); + } + node.displayOrder = yCounter; node.displayOrderRange = [yCounter, yCounter]; return yCounter; } @@ -77,26 +83,63 @@ function _getSpaceBetweenSubtrees(numSubtrees, numTips) { * PhyloTree can subsequently use this information. Accessed by prototypes * rectangularLayout, radialLayout, createChildrenAndParents * side effects: .displayOrder (i.e. in the redux node) and .displayOrderRange - * @param {Array} nodes + * @param {Object} props + * @param {Array} props.nodes + * @param {boolean} props.focus * @returns {undefined} */ -export const setDisplayOrder = (nodes) => { +export const setDisplayOrder = ({nodes, focus}) => { + timerStart("setDisplayOrder"); + const numSubtrees = nodes[0].n.children.filter((n) => n.fullTipCount!==0).length; - const numTips = nodes[0].n.fullTipCount; + const numTips = focus ? nodes[0].n.tipCount : nodes[0].n.fullTipCount; const spaceBetweenSubtrees = _getSpaceBetweenSubtrees(numSubtrees, numTips); + + // No focus: 1 unit per node + let incrementer = (_node) => 1; + + if (focus) { + const nVisible = nodes[0].n.tipCount; + const nTotal = nodes[0].n.fullTipCount; + + let yProportionFocused = 0.8; + // Adjust for a small number of visible tips (n<4) + yProportionFocused = Math.min(yProportionFocused, nVisible / 5); + // Adjust for a large number of visible tips (>80% of all tips) + yProportionFocused = Math.max(yProportionFocused, nVisible / nTotal); + + const yPerFocused = (yProportionFocused * nTotal) / nVisible; + const yPerUnfocused = ((1 - yProportionFocused) * nTotal) / (nTotal - nVisible); + + incrementer = (() => { + let previousWasVisible = false; + return (node) => { + // Focus if the current node is visible or if the previous node was visible (for symmetric padding) + const y = (node.visibility === NODE_VISIBLE || previousWasVisible) ? yPerFocused : yPerUnfocused; + + // Update for the next node + previousWasVisible = node.visibility === NODE_VISIBLE; + + return y; + } + })(); + } + let yCounter = 0; /* iterate through each subtree, and add padding between each */ for (const subtree of nodes[0].n.children) { if (subtree.fullTipCount===0) { // don't use screen space for this subtree - setDisplayOrderRecursively(nodes[subtree.arrayIdx], undefined); + setDisplayOrderRecursively(nodes[subtree.arrayIdx], incrementer, undefined); } else { - yCounter = setDisplayOrderRecursively(nodes[subtree.arrayIdx], yCounter); + yCounter = setDisplayOrderRecursively(nodes[subtree.arrayIdx], incrementer, yCounter); yCounter+=spaceBetweenSubtrees; } } /* note that nodes[0] is a dummy node holding each subtree */ nodes[0].displayOrder = undefined; nodes[0].displayOrderRange = [undefined, undefined]; + + timerEnd("setDisplayOrder"); }; diff --git a/src/components/tree/phyloTree/renderers.js b/src/components/tree/phyloTree/renderers.js index c955cc978..b2cd90298 100644 --- a/src/components/tree/phyloTree/renderers.js +++ b/src/components/tree/phyloTree/renderers.js @@ -7,6 +7,7 @@ import { getEmphasizedColor } from "../../../util/colorHelpers"; * @param {d3 selection} svg -- the svg into which the tree is drawn * @param {string} layout -- the layout to be used, e.g. "rect" * @param {string} distance -- the property used as branch length, e.g. div or num_date + * @param {string} focus -- whether to focus on filtered nodes * @param {object} parameters -- an object that contains options that will be added to this.params * @param {object} callbacks -- an object with call back function defining mouse behavior * @param {array} branchThickness -- array of branch thicknesses (same ordering as tree nodes) @@ -21,7 +22,7 @@ import { getEmphasizedColor } from "../../../util/colorHelpers"; * @param {object} scatterVariables -- {x, y} properties to map nodes => scatterplot (only used if layout="scatter") * @return {null} */ -export const render = function render(svg, layout, distance, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) { +export const render = function render(svg, layout, distance, focus, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) { timerStart("phyloTree render()"); this.svg = svg; this.params = Object.assign(this.params, parameters); @@ -40,7 +41,7 @@ export const render = function render(svg, layout, distance, parameters, callbac }); /* set x, y values & scale them to the screen */ - setDisplayOrder(this.nodes); + setDisplayOrder({nodes: this.nodes, focus}); this.setDistance(distance); this.setLayout(layout, scatterVariables); this.mapToScreen(); diff --git a/src/components/tree/reactD3Interface/change.js b/src/components/tree/reactD3Interface/change.js index 07bff8b1b..67a3b6707 100644 --- a/src/components/tree/reactD3Interface/change.js +++ b/src/components/tree/reactD3Interface/change.js @@ -7,6 +7,14 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, const oldTreeRedux = mainTree ? oldProps.tree : oldProps.treeToo; const newTreeRedux = mainTree ? newProps.tree : newProps.treeToo; + /* zoom to a clade / reset zoom to entire tree */ + const zoomChange = oldTreeRedux.idxOfInViewRootNode !== newTreeRedux.idxOfInViewRootNode; + + const dateRangeChange = oldProps.dateMinNumeric !== newProps.dateMinNumeric || + oldProps.dateMaxNumeric !== newProps.dateMaxNumeric; + + const filterChange = oldProps.filters !== newProps.filters; + /* do any properties on the tree object need to be updated? Note that updating properties itself won't trigger any visual changes */ phylotree.dateRange = [newProps.dateMinNumeric, newProps.dateMaxNumeric]; @@ -47,6 +55,20 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, /* explode! */ if (oldProps.explodeAttr !== newProps.explodeAttr) { args.changeNodeOrder = true; + args.focus = newProps.focus; + } + + /* enable/disable focus */ + if (oldProps.focus !== newProps.focus) { + args.focus = newProps.focus; + args.updateLayout = true; + } + /* re-focus on changes */ + else if (oldProps.focus === true && + newProps.focus === true && + (zoomChange || dateRangeChange || filterChange)) { + args.focus = true; + args.updateLayout = true; } /* change in key used to define branch labels, tip labels */ @@ -86,8 +108,7 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, } - /* zoom to a clade / reset zoom to entire tree */ - if (oldTreeRedux.idxOfInViewRootNode !== newTreeRedux.idxOfInViewRootNode) { + if (zoomChange) { const rootNode = phylotree.nodes[newTreeRedux.idxOfInViewRootNode]; args.zoomIntoClade = rootNode; newState.selectedNode = {}; diff --git a/src/components/tree/reactD3Interface/initialRender.js b/src/components/tree/reactD3Interface/initialRender.js index b85dd9917..9b44fa0b6 100644 --- a/src/components/tree/reactD3Interface/initialRender.js +++ b/src/components/tree/reactD3Interface/initialRender.js @@ -22,6 +22,7 @@ export const renderTree = (that, main, phylotree, props) => { select(ref), props.layout, props.distanceMeasure, + props.focus, { /* parameters (modifies PhyloTree's defaults) */ grid: true, confidence: props.temporalConfidence.display, diff --git a/src/components/tree/tangle/untangling.js b/src/components/tree/tangle/untangling.js index a48c9c31b..e4ef85b77 100644 --- a/src/components/tree/tangle/untangling.js +++ b/src/components/tree/tangle/untangling.js @@ -78,6 +78,7 @@ export const untangleTreeToo = (phylotree1, phylotree2) => { // const init_corr = calculatePearsonCorrelationCoefficient(phylotree1, phylotree2); flipChildrenPostorder(phylotree1, phylotree2); // console.log(`Untangling ${init_corr} -> ${calculatePearsonCorrelationCoefficient(phylotree1, phylotree2)}`); - setDisplayOrder(phylotree2.nodes); + // TODO: check the value of focus + setDisplayOrder({nodes: phylotree2.nodes, focus: false}); // console.timeEnd("untangle"); }; diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index 2c2a5ae2a..5fc96bb77 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -4,6 +4,7 @@ import { defaultGeoResolution, defaultDateRange, defaultDistanceMeasure, defaultLayout, + defaultFocus, controlsHiddenWidth, strainSymbol, twoColumnBreakpoint } from "../util/globals"; @@ -17,6 +18,7 @@ type Layout = "rect" | "radial" | "unrooted" | "clock" | "scatter" interface Defaults { distanceMeasure: string layout: Layout + focus: boolean geoResolution: string filters: Record filtersInFooter: string[] @@ -34,6 +36,7 @@ export interface BasicControlsState { panelsToDisplay: string[] showTreeToo: boolean canTogglePanelLayout: boolean + focus: boolean // This allows arbitrary prop names while TypeScript adoption is incomplete. // TODO: add all other props explicitly and remove this. @@ -60,6 +63,7 @@ export const getDefaultControlsState = () => { const defaults: Defaults = { distanceMeasure: defaultDistanceMeasure, layout: defaultLayout, + focus: defaultFocus, geoResolution: defaultGeoResolution, filters: {}, filtersInFooter: [], @@ -87,6 +91,7 @@ export const getDefaultControlsState = () => { layout: defaults.layout, scatterVariables: {}, distanceMeasure: defaults.distanceMeasure, + focus: defaults.focus, dateMin, dateMinNumeric, dateMax, @@ -209,7 +214,10 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con } return Object.assign({}, state, updatesToState); } - case types.CHANGE_DATES_VISIBILITY_THICKNESS: { + case types.TOGGLE_FOCUS: { + return {...state, focus: !state.focus} + } + case types.CHANGE_DATES_VISIBILITY_THICKNESS: { const newDates: Partial = { quickdraw: action.quickdraw }; if (action.dateMin) { newDates.dateMin = action.dateMin; diff --git a/src/util/globals.js b/src/util/globals.js index da8964366..243e390fe 100644 --- a/src/util/globals.js +++ b/src/util/globals.js @@ -27,6 +27,7 @@ export const defaultColorBy = "country"; export const defaultGeoResolution = "country"; export const defaultLayout = "rect"; export const defaultDistanceMeasure = "num_date"; +export const defaultFocus = false; export const defaultDateRange = 6; export const date_select = true; export const file_prefix = "Zika_";