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_";