Skip to content

Commit

Permalink
Merge pull request #1257 from nextstrain/zoom-to-selected
Browse files Browse the repository at this point in the history
Zoom to selected
  • Loading branch information
jameshadfield committed Jan 6, 2021
2 parents cefefb9 + 0259bb2 commit 6651658
Show file tree
Hide file tree
Showing 5 changed files with 141 additions and 56 deletions.
2 changes: 2 additions & 0 deletions src/actions/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const updateVisibleTipsAndBranchThicknesses = (
branchThickness: data.branchThickness,
branchThicknessVersion: data.branchThicknessVersion,
idxOfInViewRootNode: rootIdxTree1,
idxOfFilteredRoot: data.idxOfFilteredRoot,
cladeName: cladeSelected,
selectedClade: cladeSelected,
stateCountAttrs: Object.keys(controls.filters)
Expand All @@ -94,6 +95,7 @@ export const updateVisibleTipsAndBranchThicknesses = (
dispatchObj.branchThicknessToo = dataToo.branchThickness;
dispatchObj.branchThicknessVersionToo = dataToo.branchThicknessVersion;
dispatchObj.idxOfInViewRootNodeToo = rootIdxTree2;
dispatchObj.idxOfFilteredRootToo = dataToo.idxOfFilteredRoot;
/* tip selected is the same as the first tree - the reducer uses that */
}

Expand Down
49 changes: 41 additions & 8 deletions src/components/tree/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,33 @@ class Tree extends React.Component {
getStyles = () => {
const activeResetTreeButton = this.props.tree.idxOfInViewRootNode !== 0 ||
this.props.treeToo.idxOfInViewRootNode !== 0;

const filteredTree = !!this.props.tree.idxOfFilteredRoot &&
this.props.tree.idxOfInViewRootNode !== this.props.tree.idxOfFilteredRoot;
const filteredTreeToo = !!this.props.treeToo.idxOfFilteredRoot &&
this.props.treeToo.idxOfInViewRootNode !== this.props.treeToo.idxOfFilteredRoot;
const activeZoomButton = filteredTree || filteredTreeToo;

return {
resetTreeButton: {
treeButtonsDiv: {
zIndex: 100,
position: "absolute",
right: 5,
top: 0,
top: 0
},
resetTreeButton: {
zIndex: 100,
display: "inline-block",
marginLeft: 4,
cursor: activeResetTreeButton ? "pointer" : "auto",
color: activeResetTreeButton ? darkGrey : lightGrey
},
zoomToSelectedButton: {
zIndex: 100,
dispaly: "inline-block",
cursor: activeZoomButton ? "pointer" : "auto",
color: activeZoomButton ? darkGrey : lightGrey,
pointerEvents: activeZoomButton ? "auto" : "none"
}
};
};
Expand All @@ -121,6 +140,12 @@ class Tree extends React.Component {
);
}

zoomToSelected = () => {
this.props.dispatch(updateVisibleTipsAndBranchThicknesses({
root: [this.props.tree.idxOfFilteredRoot, this.props.treeToo.idxOfFilteredRoot]
}));
};

render() {
const { t } = this.props;
const styles = this.getStyles();
Expand Down Expand Up @@ -169,12 +194,20 @@ class Tree extends React.Component {
null
}
{this.props.narrativeMode ? null : (
<button
style={{...tabSingle, ...styles.resetTreeButton}}
onClick={this.redrawTree}
>
{t("Reset Layout")}
</button>
<div style={{...styles.treeButtonsDiv}}>
<button
style={{...tabSingle, ...styles.zoomToSelectedButton}}
onClick={this.zoomToSelected}
>
{t("Zoom to Selected")}
</button>
<button
style={{...tabSingle, ...styles.resetTreeButton}}
onClick={this.redrawTree}
>
{t("Reset Layout")}
</button>
</div>
)}
</Card>
);
Expand Down
1 change: 1 addition & 0 deletions src/reducers/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const Tree = (state = getDefaultTreeState(), action) => {
branchThickness: action.branchThickness,
branchThicknessVersion: action.branchThicknessVersion,
idxOfInViewRootNode: action.idxOfInViewRootNode,
idxOfFilteredRoot: action.idxOfFilteredRoot,
cladeName: action.cladeName,
selectedClade: action.cladeName,
visibleStateCounts: countTraitsAcrossTree(state.nodes, action.stateCountAttrs, action.visibility, true),
Expand Down
1 change: 1 addition & 0 deletions src/reducers/treeToo.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const treeToo = (state = getDefaultTreeState(), action) => {
branchThickness: action.branchThicknessToo,
branchThicknessVersion: action.branchThicknessVersionToo,
idxOfInViewRootNode: action.idxOfInViewRootNodeToo,
idxOfFilteredRoot: action.idxOfFilteredRootToo,
selectedStrain: action.selectedStrain
});
}
Expand Down
144 changes: 96 additions & 48 deletions src/util/treeVisibilityHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,97 @@ const makeParentVisible = (visArray, node) => {
makeParentVisible(visArray, node.parent);
};

/* Recursively hide nodes that do not have more than one child node by updating
* the boolean values in the param visArray.
* Relies on visArray having been updated by `makeParentVisible`
* Returns the index of the visible commonn ancestor. */
const hideNodesAboveVisibleCommonAncestor = (visArray, node) => {
if (!node.hasChildren) {
return node.arrayIdx; // Terminal node without children
}
const visibleChildren = node.children.filter((child) => visArray[child.arrayIdx]);
if (visibleChildren.length > 1) {
return node.arrayIdx; // This is the common ancestor of visible children
}
visArray[node.arrayIdx] = false;
for (let i = 0; i < visibleChildren.length; i++) {
const commonAncestorIdx = hideNodesAboveVisibleCommonAncestor(visArray, visibleChildren[i]);
if (commonAncestorIdx) return commonAncestorIdx;
}
// If there is no visible common ancestor, then return null
return null;
};

/* Gets the inView attribute of phyloTree.nodes, accessed through
* redux.tree.nodes[idx].shell.inView Bool. The inView attribute is set by
* phyloTree and determines if the tip is within the view.
* Returns the array of inView booleans. */
const getInView = (tree) => {
if (!tree.nodes) {
console.error("getInView() ran without tree.nodes");
return null;
}
/* inView represents nodes that are within the current view window (i.e. not off the screen) */
let inView;
try {
inView = tree.nodes.map((d) => d.shell.inView);
} catch (e) {
/* edge case: this fn may be called before the shell structure of the nodes
* has been created (i.e. phyloTree's not run yet). In this case, it's
* safe to assume that everything's in view */
inView = tree.nodes.map((d) => d.inView !== undefined ? d.inView : true);
}
return inView;
};

/* Gets all active filters and checks if each tree.node matches the filters.
* Returns an array of filtered booleans and the index of the least common
* ancestor node of the filtered nodes.
* FILTERS:
* - controls.filters (redux) is a dict of trait name -> values
* - filters (in this code) is a list of filters to apply
* e.g. [{trait: "country", values: [...]}, ...] */
const getFilteredAndIdxOfFilteredRoot = (tree, controls, inView) => {
if (!tree.nodes) {
console.error("getFiltered() ran without tree.nodes");
return null;
}
let filtered; // array of bools, same length as tree.nodes. true -> that node should be visible
let idxOfFilteredRoot; // index of last common ancestor of filtered nodes.
const filters = [];
Reflect.ownKeys(controls.filters).forEach((filterName) => {
const items = controls.filters[filterName];
const activeFilterItems = items.filter((item) => item.active).map((item) => item.value);
if (activeFilterItems.length) {
filters.push({trait: filterName, values: activeFilterItems});
}
});

if (filters.length) {
/* find the terminal nodes that were (a) already visibile and (b) match the filters */
filtered = tree.nodes.map((d, idx) => (
!d.hasChildren && inView[idx] && filters.every((f) => f.values.includes(getTraitFromNode(d, f.trait)))
));
const idxsOfFilteredTips = filtered.reduce((a, e, i) => {
if (e) {a.push(i);}
return a;
}, []);
/* for each visibile tip, make the parent nodes visible (recursively) */
for (let i = 0; i < idxsOfFilteredTips.length; i++) {
makeParentVisible(filtered, tree.nodes[idxsOfFilteredTips[i]]);
}
/* Recursivley hide ancestor nodes that are not the last common
* ancestor of selected nodes, starting from the root of the tree */
idxOfFilteredRoot = hideNodesAboveVisibleCommonAncestor(filtered, tree.nodes[0]);
}
return {filtered, idxOfFilteredRoot};
};

/* calcVisibility
USES:
inView: attribute of phyloTree.nodes, but accessible through redux.tree.nodes[idx].shell.inView
Bool. Set by phyloTree, determines if the tip is within the view.
controls.filters
use dates NOT controls.dateMin & controls.dateMax
- use dates NOT controls.dateMin & controls.dateMax
- uses inView array returned by getInView()
- uses filtered array returned by getFilteredAndIdxOfFilteredRoot()
RETURNS:
visibility: array of integers in {0, 1, 2}
Expand All @@ -109,52 +194,12 @@ visibility: array of integers in {0, 1, 2}
ROUGH DESCRIPTION OF HOW FILTERING IS APPLIED:
- inView filtering (reflects tree zooming): Nodes which are not inView always have visibility=0
- time filtering is simple - all nodes (internal + terminal) not within (tmin, tmax) are excluded.
- filters are a bit more tricky - the visibile tips are calculated, and the parent
- filters are a bit more tricky - the visibile tips are calculated, and the parent
branches back to the MRCA are considered visibile. This is then intersected with
the time & inView visibile stuff
FILTERS:
- controls.filters (redux) is a dict of trait name -> values
- filters (in this code) is a list of filters to apply
e.g. [{trait: "country", values: [...]}, ...]
*/
export const calcVisibility = (tree, controls, dates) => {
export const calcVisibility = (tree, controls, dates, inView, filtered) => {
if (tree.nodes) {
/* inView represents nodes that are within the current view window (i.e. not off the screen) */
let inView;
try {
inView = tree.nodes.map((d) => d.shell.inView);
} catch (e) {
/* edge case: this fn may be called before the shell structure of the nodes
* has been created (i.e. phyloTree's not run yet). In this case, it's
* safe to assume that everything's in view */
inView = tree.nodes.map((d) => d.inView !== undefined ? d.inView : true);
}

// FILTERS
let filtered; // array of bools, same length as tree.nodes. true -> that node should be visible
const filters = [];
Reflect.ownKeys(controls.filters).forEach((filterName) => {
const items = controls.filters[filterName];
const activeFilterItems = items.filter((item) => item.active).map((item) => item.value);
if (activeFilterItems.length) {
filters.push({trait: filterName, values: activeFilterItems});
}
});
if (filters.length) {
/* find the terminal nodes that were (a) already visibile and (b) match the filters */
filtered = tree.nodes.map((d, idx) => (
!d.hasChildren && inView[idx] && filters.every((f) => f.values.includes(getTraitFromNode(d, f.trait)))
));
const idxsOfFilteredTips = filtered.reduce((a, e, i) => {
if (e) {a.push(i);}
return a;
}, []);
/* for each visibile tip, make the parent nodes visible (recursively) */
for (let i = 0; i < idxsOfFilteredTips.length; i++) {
makeParentVisible(filtered, tree.nodes[idxsOfFilteredTips[i]]);
}
}
/* intersect the various arrays contributing to visibility */
const visibility = tree.nodes.map((node, idx) => {
if (inView[idx] && (filtered ? filtered[idx] : true)) {
Expand Down Expand Up @@ -185,14 +230,17 @@ export const calcVisibility = (tree, controls, dates) => {
};

export const calculateVisiblityAndBranchThickness = (tree, controls, dates) => {
const visibility = calcVisibility(tree, controls, dates);
const inView = getInView(tree);
const {filtered, idxOfFilteredRoot} = getFilteredAndIdxOfFilteredRoot(tree, controls, inView) || {};
const visibility = calcVisibility(tree, controls, dates, inView, filtered);
/* recalculate tipCounts over the tree - modifies redux tree nodes in place (yeah, I know) */
calcTipCounts(tree.nodes[0], visibility);
/* re-calculate branchThickness (inline) */
return {
visibility: visibility,
visibilityVersion: tree.visibilityVersion + 1,
branchThickness: calcBranchThickness(tree.nodes, visibility),
branchThicknessVersion: tree.branchThicknessVersion + 1
branchThicknessVersion: tree.branchThicknessVersion + 1,
idxOfFilteredRoot: idxOfFilteredRoot
};
};

0 comments on commit 6651658

Please sign in to comment.