Skip to content

Commit c370552

Browse files
authored
Merge pull request #1373: tree: Add toggle to focus on selected
2 parents 887624a + b7f8bc9 commit c370552

File tree

15 files changed

+181
-18
lines changed

15 files changed

+181
-18
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
* Added an experimental "Focus on Selected" toggle in the sidebar.
4+
When focusing on selected nodes, nodes that do not match the filter will occupy less vertical space on the tree.
5+
Only applicable to rectangular and radial layouts.
6+
([#1373](https://github.com/nextstrain/auspice/pull/1373))
7+
38
## version 2.58.0 - 2024/09/12
49

510

src/actions/types.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const SEARCH_INPUT_CHANGE = "SEARCH_INPUT_CHANGE";
77
export const CHANGE_LAYOUT = "CHANGE_LAYOUT";
88
export const CHANGE_BRANCH_LABEL = "CHANGE_BRANCH_LABEL";
99
export const CHANGE_DISTANCE_MEASURE = "CHANGE_DISTANCE_MEASURE";
10+
export const TOGGLE_FOCUS = "TOGGLE_FOCUS";
1011
export const CHANGE_DATES_VISIBILITY_THICKNESS = "CHANGE_DATES_VISIBILITY_THICKNESS";
1112
export const CHANGE_ABSOLUTE_DATE_MIN = "CHANGE_ABSOLUTE_DATE_MIN";
1213
export const CHANGE_ABSOLUTE_DATE_MAX = "CHANGE_ABSOLUTE_DATE_MAX";

src/components/controls/controls.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ import TransmissionLines from './transmission-lines';
1818
import NormalizeFrequencies from "./frequency-normalization";
1919
import AnimationOptions from "./animation-options";
2020
import { PanelSection } from "./panelSection";
21+
import ToggleFocus from "./toggle-focus";
2122
import ToggleTangle from "./toggle-tangle";
2223
import Language from "./language";
2324
import { ControlsContainer } from "./styles";
2425
import FilterData, {FilterInfo} from "./filter";
2526
import {TreeInfo, MapInfo, AnimationOptionsInfo, PanelLayoutInfo,
26-
ExplodeTreeInfo, EntropyInfo, FrequencyInfo, MeasurementsInfo} from "./miscInfoText";
27+
ExplodeTreeInfo, EntropyInfo, FrequencyInfo, MeasurementsInfo,
28+
ToggleFocusInfo} from "./miscInfoText";
2729
import { ControlHeader } from "./controlHeader";
2830
import MeasurementsOptions from "./measurementsOptions";
2931
import { RootState } from "../../store";
@@ -64,6 +66,7 @@ function Controls() {
6466
tooltip={TreeInfo}
6567
options={<>
6668
<ChooseLayout />
69+
<ToggleFocus tooltip={ToggleFocusInfo} />
6770
<ChooseMetric />
6871
<ChooseBranchLabelling />
6972
<ChooseTipLabel />

src/components/controls/miscInfoText.js

+8
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,11 @@ export const ExplodeTreeInfo = (
6464
It works best when the trait doesn&apos;t change value too frequently.
6565
</>
6666
);
67+
68+
export const ToggleFocusInfo = (
69+
<>This functionality is experimental and should be treated with caution!
70+
<br/>When focusing on selected nodes, nodes that do not match the
71+
filter will occupy less vertical space on the tree. Only applicable to
72+
rectangular and radial layouts.
73+
</>
74+
);
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from "react";
2+
import { connect } from "react-redux";
3+
import { FaInfoCircle } from "react-icons/fa";
4+
import { Dispatch } from "@reduxjs/toolkit";
5+
import Toggle from "./toggle";
6+
import { SidebarIconContainer, StyledTooltip } from "./styles";
7+
import { TOGGLE_FOCUS } from "../../actions/types";
8+
import { RootState } from "../../store";
9+
10+
11+
function ToggleFocus({ tooltip, focus, layout, dispatch, mobileDisplay }: {
12+
tooltip: React.ReactElement;
13+
focus: boolean;
14+
layout: "rect" | "radial" | "unrooted" | "clock" | "scatter";
15+
dispatch: Dispatch;
16+
mobileDisplay: boolean;
17+
}) {
18+
// Focus functionality is only available to layouts that have the concept of a unitless y-axis
19+
const validLayouts = new Set(["rect", "radial"]);
20+
if (!validLayouts.has(layout)) return <></>;
21+
22+
const label = (
23+
<div style={{ display: "flex", alignItems: "center" }}>
24+
<span style={{ marginRight: "5px" }}>Focus on Selected</span>
25+
{tooltip && !mobileDisplay && (
26+
<>
27+
<SidebarIconContainer style={{ display: "inline-flex" }} data-tip data-for="toggle-focus">
28+
<FaInfoCircle />
29+
</SidebarIconContainer>
30+
<StyledTooltip place="bottom" type="dark" effect="solid" id="toggle-focus">
31+
{tooltip}
32+
</StyledTooltip>
33+
</>
34+
)}
35+
</div>
36+
);
37+
38+
return (
39+
<Toggle
40+
display
41+
isExperimental={true}
42+
on={focus}
43+
callback={() => dispatch({ type: TOGGLE_FOCUS })}
44+
label={label}
45+
style={{ paddingBottom: "10px" }}
46+
/>
47+
);
48+
}
49+
50+
export default connect((state: RootState) => ({
51+
focus: state.controls.focus,
52+
layout: state.controls.layout,
53+
mobileDisplay: state.general.mobileDisplay,
54+
}))(ToggleFocus);

src/components/controls/toggle.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from "react";
2+
import { ImLab } from "react-icons/im";
23
import styled from 'styled-components';
34
import { SidebarSubtitle } from "./styles";
45

@@ -28,6 +29,11 @@ const ToggleSubtitle = styled(SidebarSubtitle)`
2829
width: 200px;
2930
`;
3031

32+
const ExperimentalIcon = styled.span`
33+
color: ${(props) => props.theme.color};
34+
margin-right: 5px;
35+
`
36+
3137
const Slider = styled.div`
3238
& {
3339
position: absolute;
@@ -73,11 +79,16 @@ const Input = styled.input`
7379
`;
7480

7581

76-
const Toggle = ({display, on, callback, label, style={}}) => {
82+
const Toggle = ({display, isExperimental = false, on, callback, label, style={}}) => {
7783
if (!display) return null;
7884

7985
return (
8086
<ToggleContainer style={style}>
87+
{isExperimental &&
88+
<ExperimentalIcon>
89+
<ImLab />
90+
</ExperimentalIcon>
91+
}
8192
<ToggleBackground>
8293
<Input type="checkbox" checked={on} onChange={callback}/>
8394
<Slider/>

src/components/tree/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const Tree = connect((state: RootState) => ({
88
selectedNode: state.controls.selectedNode,
99
dateMinNumeric: state.controls.dateMinNumeric,
1010
dateMaxNumeric: state.controls.dateMaxNumeric,
11+
filters: state.controls.filters,
1112
quickdraw: state.controls.quickdraw,
1213
colorBy: state.controls.colorBy,
1314
colorByConfidence: state.controls.colorByConfidence,
@@ -16,6 +17,7 @@ const Tree = connect((state: RootState) => ({
1617
temporalConfidence: state.controls.temporalConfidence,
1718
distanceMeasure: state.controls.distanceMeasure,
1819
explodeAttr: state.controls.explodeAttr,
20+
focus: state.controls.focus,
1921
colorScale: state.controls.colorScale,
2022
colorings: state.metadata.colorings,
2123
genomeMap: state.entropy.genomeMap,

src/components/tree/phyloTree/change.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export const change = function change({
270270
tipRadii = undefined,
271271
branchThickness = undefined,
272272
/* other data */
273+
focus = undefined,
273274
scatterVariables = undefined
274275
}) {
275276
// 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({
323324
}
324325

325326
if (changeNodeOrder) {
326-
setDisplayOrder(this.nodes);
327+
setDisplayOrder({nodes: this.nodes, focus});
327328
this.setDistance();
328329
}
329330

@@ -359,7 +360,9 @@ export const change = function change({
359360
/* run calculations as needed - these update properties on the phylotreeNodes (similar to updateNodesWithNewData) */
360361
/* distance */
361362
if (newDistance || updateLayout) this.setDistance(newDistance);
362-
/* layout (must run after distance) */
363+
/* focus */
364+
if (updateLayout) setDisplayOrder({nodes: this.nodes, focus});
365+
/* layout (must run after distance and focus) */
363366
if (newDistance || newLayout || updateLayout || changeNodeOrder) {
364367
this.setLayout(newLayout || this.layout, scatterVariables);
365368
}

src/components/tree/phyloTree/helpers.js

+51-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/* eslint-disable no-param-reassign */
22
import { max } from "d3-array";
33
import {getTraitFromNode, getDivFromNode, getBranchMutations} from "../../../util/treeMiscHelpers";
4+
import { NODE_VISIBLE } from "../../../util/globals";
5+
import { timerStart, timerEnd } from "../../../util/perf";
46

57
/** get a string to be used as the DOM element ID
68
* Note that this cannot have any "special" characters
@@ -33,18 +35,22 @@ export const applyToChildren = (phyloNode, func) => {
3335
* of nodes in a rectangular tree.
3436
* If `yCounter` is undefined then we wish to hide the node and all descendants of it
3537
* @param {PhyloNode} node
38+
* @param {function} incrementer
3639
* @param {int|undefined} yCounter
3740
* @sideeffect modifies node.displayOrder and node.displayOrderRange
3841
* @returns {int|undefined} current yCounter after assignment to the tree originating from `node`
3942
*/
40-
export const setDisplayOrderRecursively = (node, yCounter) => {
43+
export const setDisplayOrderRecursively = (node, incrementer, yCounter) => {
4144
const children = node.n.children; // (redux) tree node
4245
if (children && children.length) {
4346
for (let i = children.length - 1; i >= 0; i--) {
44-
yCounter = setDisplayOrderRecursively(children[i].shell, yCounter);
47+
yCounter = setDisplayOrderRecursively(children[i].shell, incrementer, yCounter);
4548
}
4649
} else {
47-
node.displayOrder = (node.n.fullTipCount===0 || yCounter===undefined) ? yCounter : ++yCounter;
50+
if (node.n.fullTipCount !== 0 && yCounter !== undefined) {
51+
yCounter += incrementer(node);
52+
}
53+
node.displayOrder = yCounter;
4854
node.displayOrderRange = [yCounter, yCounter];
4955
return yCounter;
5056
}
@@ -77,26 +83,63 @@ function _getSpaceBetweenSubtrees(numSubtrees, numTips) {
7783
* PhyloTree can subsequently use this information. Accessed by prototypes
7884
* rectangularLayout, radialLayout, createChildrenAndParents
7985
* side effects: <phyloNode>.displayOrder (i.e. in the redux node) and <phyloNode>.displayOrderRange
80-
* @param {Array<PhyloNode>} nodes
86+
* @param {Object} props
87+
* @param {Array<PhyloNode>} props.nodes
88+
* @param {boolean} props.focus
8189
* @returns {undefined}
8290
*/
83-
export const setDisplayOrder = (nodes) => {
91+
export const setDisplayOrder = ({nodes, focus}) => {
92+
timerStart("setDisplayOrder");
93+
8494
const numSubtrees = nodes[0].n.children.filter((n) => n.fullTipCount!==0).length;
85-
const numTips = nodes[0].n.fullTipCount;
95+
const numTips = focus ? nodes[0].n.tipCount : nodes[0].n.fullTipCount;
8696
const spaceBetweenSubtrees = _getSpaceBetweenSubtrees(numSubtrees, numTips);
97+
98+
// No focus: 1 unit per node
99+
let incrementer = (_node) => 1;
100+
101+
if (focus) {
102+
const nVisible = nodes[0].n.tipCount;
103+
const nTotal = nodes[0].n.fullTipCount;
104+
105+
let yProportionFocused = 0.8;
106+
// Adjust for a small number of visible tips (n<4)
107+
yProportionFocused = Math.min(yProportionFocused, nVisible / 5);
108+
// Adjust for a large number of visible tips (>80% of all tips)
109+
yProportionFocused = Math.max(yProportionFocused, nVisible / nTotal);
110+
111+
const yPerFocused = (yProportionFocused * nTotal) / nVisible;
112+
const yPerUnfocused = ((1 - yProportionFocused) * nTotal) / (nTotal - nVisible);
113+
114+
incrementer = (() => {
115+
let previousWasVisible = false;
116+
return (node) => {
117+
// Focus if the current node is visible or if the previous node was visible (for symmetric padding)
118+
const y = (node.visibility === NODE_VISIBLE || previousWasVisible) ? yPerFocused : yPerUnfocused;
119+
120+
// Update for the next node
121+
previousWasVisible = node.visibility === NODE_VISIBLE;
122+
123+
return y;
124+
}
125+
})();
126+
}
127+
87128
let yCounter = 0;
88129
/* iterate through each subtree, and add padding between each */
89130
for (const subtree of nodes[0].n.children) {
90131
if (subtree.fullTipCount===0) { // don't use screen space for this subtree
91-
setDisplayOrderRecursively(nodes[subtree.arrayIdx], undefined);
132+
setDisplayOrderRecursively(nodes[subtree.arrayIdx], incrementer, undefined);
92133
} else {
93-
yCounter = setDisplayOrderRecursively(nodes[subtree.arrayIdx], yCounter);
134+
yCounter = setDisplayOrderRecursively(nodes[subtree.arrayIdx], incrementer, yCounter);
94135
yCounter+=spaceBetweenSubtrees;
95136
}
96137
}
97138
/* note that nodes[0] is a dummy node holding each subtree */
98139
nodes[0].displayOrder = undefined;
99140
nodes[0].displayOrderRange = [undefined, undefined];
141+
142+
timerEnd("setDisplayOrder");
100143
};
101144

102145

src/components/tree/phyloTree/renderers.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getEmphasizedColor } from "../../../util/colorHelpers";
77
* @param {d3 selection} svg -- the svg into which the tree is drawn
88
* @param {string} layout -- the layout to be used, e.g. "rect"
99
* @param {string} distance -- the property used as branch length, e.g. div or num_date
10+
* @param {string} focus -- whether to focus on filtered nodes
1011
* @param {object} parameters -- an object that contains options that will be added to this.params
1112
* @param {object} callbacks -- an object with call back function defining mouse behavior
1213
* @param {array} branchThickness -- array of branch thicknesses (same ordering as tree nodes)
@@ -21,7 +22,7 @@ import { getEmphasizedColor } from "../../../util/colorHelpers";
2122
* @param {object} scatterVariables -- {x, y} properties to map nodes => scatterplot (only used if layout="scatter")
2223
* @return {null}
2324
*/
24-
export const render = function render(svg, layout, distance, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) {
25+
export const render = function render(svg, layout, distance, focus, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) {
2526
timerStart("phyloTree render()");
2627
this.svg = svg;
2728
this.params = Object.assign(this.params, parameters);
@@ -40,7 +41,7 @@ export const render = function render(svg, layout, distance, parameters, callbac
4041
});
4142

4243
/* set x, y values & scale them to the screen */
43-
setDisplayOrder(this.nodes);
44+
setDisplayOrder({nodes: this.nodes, focus});
4445
this.setDistance(distance);
4546
this.setLayout(layout, scatterVariables);
4647
this.mapToScreen();

src/components/tree/reactD3Interface/change.js

+23-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps,
77
const oldTreeRedux = mainTree ? oldProps.tree : oldProps.treeToo;
88
const newTreeRedux = mainTree ? newProps.tree : newProps.treeToo;
99

10+
/* zoom to a clade / reset zoom to entire tree */
11+
const zoomChange = oldTreeRedux.idxOfInViewRootNode !== newTreeRedux.idxOfInViewRootNode;
12+
13+
const dateRangeChange = oldProps.dateMinNumeric !== newProps.dateMinNumeric ||
14+
oldProps.dateMaxNumeric !== newProps.dateMaxNumeric;
15+
16+
const filterChange = oldProps.filters !== newProps.filters;
17+
1018
/* do any properties on the tree object need to be updated?
1119
Note that updating properties itself won't trigger any visual changes */
1220
phylotree.dateRange = [newProps.dateMinNumeric, newProps.dateMaxNumeric];
@@ -47,6 +55,20 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps,
4755
/* explode! */
4856
if (oldProps.explodeAttr !== newProps.explodeAttr) {
4957
args.changeNodeOrder = true;
58+
args.focus = newProps.focus;
59+
}
60+
61+
/* enable/disable focus */
62+
if (oldProps.focus !== newProps.focus) {
63+
args.focus = newProps.focus;
64+
args.updateLayout = true;
65+
}
66+
/* re-focus on changes */
67+
else if (oldProps.focus === true &&
68+
newProps.focus === true &&
69+
(zoomChange || dateRangeChange || filterChange)) {
70+
args.focus = true;
71+
args.updateLayout = true;
5072
}
5173

5274
/* change in key used to define branch labels, tip labels */
@@ -86,8 +108,7 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps,
86108
}
87109

88110

89-
/* zoom to a clade / reset zoom to entire tree */
90-
if (oldTreeRedux.idxOfInViewRootNode !== newTreeRedux.idxOfInViewRootNode) {
111+
if (zoomChange) {
91112
const rootNode = phylotree.nodes[newTreeRedux.idxOfInViewRootNode];
92113
args.zoomIntoClade = rootNode;
93114
newState.selectedNode = {};

src/components/tree/reactD3Interface/initialRender.js

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const renderTree = (that, main, phylotree, props) => {
2222
select(ref),
2323
props.layout,
2424
props.distanceMeasure,
25+
props.focus,
2526
{ /* parameters (modifies PhyloTree's defaults) */
2627
grid: true,
2728
confidence: props.temporalConfidence.display,

src/components/tree/tangle/untangling.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const untangleTreeToo = (phylotree1, phylotree2) => {
7878
// const init_corr = calculatePearsonCorrelationCoefficient(phylotree1, phylotree2);
7979
flipChildrenPostorder(phylotree1, phylotree2);
8080
// console.log(`Untangling ${init_corr} -> ${calculatePearsonCorrelationCoefficient(phylotree1, phylotree2)}`);
81-
setDisplayOrder(phylotree2.nodes);
81+
// TODO: check the value of focus
82+
setDisplayOrder({nodes: phylotree2.nodes, focus: false});
8283
// console.timeEnd("untangle");
8384
};

0 commit comments

Comments
 (0)