From 19b5837420ec284e9683b4e568eaea5de8366537 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Wed, 10 Jun 2020 12:09:14 +1200 Subject: [PATCH 1/3] Implement basic framework for keyboard shortcuts Uses as a proof-of-principle the simple yet useful case of using the key "c" to loop through available color-bys. --- src/actions/colors.js | 12 ++++++ .../framework/keyboard-shortcuts.js | 38 +++++++++++++++++++ src/root.js | 2 + 3 files changed, 52 insertions(+) create mode 100644 src/components/framework/keyboard-shortcuts.js diff --git a/src/actions/colors.js b/src/actions/colors.js index c89005908..5e6af14fe 100644 --- a/src/actions/colors.js +++ b/src/actions/colors.js @@ -64,3 +64,15 @@ export const changeColorBy = (providedColorBy = undefined) => { // eslint-disabl return null; }; }; + + +export const changeToNextColorBy = () => { + return (dispatch, getState) => { + const {controls, metadata} = getState(); + const current = controls.colorBy; + const available = Object.keys(metadata.colorings) + .filter((c) => c!=="gt"); // filter out genotypes + const nextColorBy = available[(available.indexOf(current)+1) % available.length]; + dispatch(changeColorBy(nextColorBy)); + }; +}; diff --git a/src/components/framework/keyboard-shortcuts.js b/src/components/framework/keyboard-shortcuts.js new file mode 100644 index 000000000..a26ba615d --- /dev/null +++ b/src/components/framework/keyboard-shortcuts.js @@ -0,0 +1,38 @@ +import Mousetrap from "mousetrap"; +import React from "react"; +import { connect } from "react-redux"; +import {changeToNextColorBy} from "../../actions/colors"; + +/** + * Here we have a react component which currently renders nothing. + * It acts as a listener for keypresses and triggers the appropriate + * (redux) actions. + * + * NOTE 1: There are already a few places in the codebase where we + * listen for key-presses (search for "mousetrap"). Consider + * centralising them here, if possible and as desired. + * + * NOTE 2: If we want to persue this direction, a overlay-style + * UI could be implemented here to describe what key-presses + * are available. See https://excalidraw.com/ for a nice example + * of this. + */ + + +@connect(() => ({})) +class KeyboardShortcuts extends React.Component { + componentDidMount() { + Mousetrap.bind(['c'], () => { + console.log("c", this.dispatch); + this.props.dispatch(changeToNextColorBy()); + }); + } + componentWillUnmount() { + Mousetrap.unbind(['c']); + } + render() { + return null; + } +} + +export default KeyboardShortcuts; diff --git a/src/root.js b/src/root.js index fd45ae273..8ec9300f6 100644 --- a/src/root.js +++ b/src/root.js @@ -2,6 +2,7 @@ import React, { lazy, Suspense } from 'react'; import { connect } from "react-redux"; import { hot } from 'react-hot-loader/root'; import Monitor from "./components/framework/monitor"; +import KeyboardShortcuts from "./components/framework/keyboard-shortcuts"; import DatasetLoader from "./components/datasetLoader"; import Spinner from "./components/framework/spinner"; import Head from "./components/framework/head"; @@ -56,6 +57,7 @@ const Root = () => {
+
From 237accc536fd23506fa07afbdadcf98c145a0c2a Mon Sep 17 00:00:00 2001 From: james hadfield Date: Wed, 10 Jun 2020 14:36:00 +1200 Subject: [PATCH 2/3] Allow resampling from trait confidences This commit is a proof-of-principle to resample color-by and/or geo-resolution values if available. New values for discrete traits are picked for each node independently based on the discrete probabilities supplied by the dataset. For continuous values we take a uniform sample from the bounds provided by the dataset (often 95% CI). This is achieved via the following key-board shortcuts: "s" + "c": resample currently selected color-by "s" + "r": resample currently selected geo-resolution. Using capitol letters returns to the starting values rather than resampling. There remain a number of "to-do"s before this can be merged. --- src/actions/colors.js | 2 +- src/actions/sample.js | 71 +++++++++++++++++++ src/actions/types.js | 1 + .../framework/keyboard-shortcuts.js | 17 +++-- src/components/map/map.js | 6 +- src/components/tree/index.js | 3 +- .../tree/reactD3Interface/change.js | 5 ++ src/reducers/controls.js | 5 +- src/reducers/tree.js | 7 +- src/reducers/treeToo.js | 2 +- 10 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 src/actions/sample.js diff --git a/src/actions/colors.js b/src/actions/colors.js index 5e6af14fe..b0aac7045 100644 --- a/src/actions/colors.js +++ b/src/actions/colors.js @@ -53,7 +53,7 @@ export const changeColorBy = (providedColorBy = undefined) => { // eslint-disabl colorScale, nodeColors, nodeColorsToo, - version: colorScale.version + nodeColorsVersion: tree.nodeColorsVersion+1 }); /* step 5 - frequency dispatch */ diff --git a/src/actions/sample.js b/src/actions/sample.js new file mode 100644 index 000000000..51ad16e37 --- /dev/null +++ b/src/actions/sample.js @@ -0,0 +1,71 @@ +import { calcNodeColor } from "../util/colorHelpers"; +import {RESAMPLE} from "./types"; + +/** + * Currently this is changing the values behind the selected color-by. + * + * TODO: generalise to any trait + * TODO: Frequencies need updating? + * TODO: second tree + */ +export const sampleTraitFromUncertainty = ({trait, returnToOriginal=false}) => { + console.log(`sampleTraitFromUncertainty trait=${trait} returnToOriginal=${returnToOriginal}`); + return (dispatch, getState) => { + const { controls, tree } = getState(); + + tree.nodes.forEach((n) => { + if (n.node_attrs[trait] && n.node_attrs[trait].confidence) { + if (returnToOriginal) { + if (!n.node_attrs[trait].originalValue) { + console.error("Original state not saved..."); + return; + } + n.node_attrs[trait].value = n.node_attrs[trait].originalValue; + } else { + if (!n.node_attrs[trait].originalValue) { + n.node_attrs[trait].originalValue = n.node_attrs[trait].value; // allows us to go back to original state + } + if (Array.isArray(n.node_attrs[trait].confidence)) { + n.node_attrs[trait].value = sampleUniformlyBetweenBounds(n.node_attrs[trait].confidence); + } else { + n.node_attrs[trait].value = sampleFromDiscreteDistribution(n.node_attrs[trait].confidence); + } + } + } + }); + + const colorBy = controls.colorBy; + const dispatchObj = {type: RESAMPLE}; + + if (trait === colorBy) { + /* if the current color-by is the trait we're resampling, then we need to update the node colors */ + dispatchObj.nodeColors = calcNodeColor(tree, controls.colorScale); + dispatchObj.nodeColorsVersion = tree.nodeColorsVersion+1; + } + dispatch(dispatchObj); + }; +}; + + +function sampleFromDiscreteDistribution(confidenceObj) { + /* based on the algorithm behind R's `sample` function */ + // to-do: if the probabilities don't sum to 1 the output will be biased towards the final value + const values = Object.keys(confidenceObj); + const probabilities = Object.values(confidenceObj); + const n = values.length; + const cumulativeProbs = new Array(n); + cumulativeProbs[0] = probabilities[0]; + for (let i = 1; i ({})) +@connect((state) => ({ + colorBy: state.controls.colorBy, + geoResolution: state.controls.geoResolution +})) class KeyboardShortcuts extends React.Component { componentDidMount() { - Mousetrap.bind(['c'], () => { - console.log("c", this.dispatch); - this.props.dispatch(changeToNextColorBy()); + Mousetrap.bind(['c'], () => {this.props.dispatch(changeToNextColorBy());}); + Mousetrap.bind(['s c', 'S C', 's r', 'S R'], (e, combo) => { + this.props.dispatch(sampleTraitFromUncertainty({ + trait: combo[2].toLowerCase() === 'c' ? this.props.colorBy : this.props.geoResolution, + returnToOriginal: combo[0]==="S" + })); }); } componentWillUnmount() { - Mousetrap.unbind(['c']); + Mousetrap.unbind(['c', 's c', 'S C', 's r', 'S R']); } render() { return null; diff --git a/src/components/map/map.js b/src/components/map/map.js index 3ec2b2c5b..85567dff2 100644 --- a/src/components/map/map.js +++ b/src/components/map/map.js @@ -58,7 +58,8 @@ import "../../css/mapbox.css"; state.controls.geoResolution !== state.controls.colorScale.colorBy // geo circles match colorby == no pie chart ), legendValues: state.controls.colorScale.legendValues, - showTransmissionLines: state.controls.showTransmissionLines + showTransmissionLines: state.controls.showTransmissionLines, + resamplingCounter: state.controls.resamplingCounter }; }) @@ -276,7 +277,8 @@ class Map extends React.Component { const transmissionLinesToggleChanged = this.props.showTransmissionLines !== nextProps.showTransmissionLines; const dataChanged = (!nextProps.treeLoaded || this.props.treeVersion !== nextProps.treeVersion); const colorByChanged = (nextProps.colorScaleVersion !== this.props.colorScaleVersion); - if (mapIsDrawn && (geoResolutionChanged || dataChanged || colorByChanged || transmissionLinesToggleChanged)) { + const traitResampling = this.props.resamplingCounter !== nextProps.resamplingCounter; + if (mapIsDrawn && (geoResolutionChanged || dataChanged || colorByChanged || transmissionLinesToggleChanged || traitResampling)) { this.state.d3DOMNode.selectAll("*").remove(); this.setState({ d3elems: null, diff --git a/src/components/tree/index.js b/src/components/tree/index.js index 229d056aa..6ddbc90ec 100644 --- a/src/components/tree/index.js +++ b/src/components/tree/index.js @@ -20,7 +20,8 @@ const Tree = connect((state) => ({ panelsToDisplay: state.controls.panelsToDisplay, selectedBranchLabel: state.controls.selectedBranchLabel, narrativeMode: state.narrative.display, - animationPlayPauseButton: state.controls.animationPlayPauseButton + animationPlayPauseButton: state.controls.animationPlayPauseButton, + resamplingCounter: state.controls.resamplingCounter }))(UnconnectedTree); export default Tree; diff --git a/src/components/tree/reactD3Interface/change.js b/src/components/tree/reactD3Interface/change.js index 73a634aa3..f0e4f268f 100644 --- a/src/components/tree/reactD3Interface/change.js +++ b/src/components/tree/reactD3Interface/change.js @@ -85,6 +85,11 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, } } + /* todo - this is too hacky */ + if (newProps.distanceMeasure === "num_date" && newProps.colorBy === "num_date" && oldProps.resamplingCounter !== newProps.resamplingCounter) { + args.newDistance = true; + } + if (oldProps.width !== newProps.width || oldProps.height !== newProps.height) { args.svgHasChangedDimensions = true; } diff --git a/src/reducers/controls.js b/src/reducers/controls.js index b6f30df37..b677034c8 100644 --- a/src/reducers/controls.js +++ b/src/reducers/controls.js @@ -83,7 +83,8 @@ export const getDefaultControlsState = () => { mapLegendOpen: undefined, showOnlyPanels: false, showTransmissionLines: true, - normalizeFrequencies: true + normalizeFrequencies: true, + resamplingCounter: 0, }; }; @@ -113,6 +114,8 @@ const Controls = (state = getDefaultControlsState(), action) => { return Object.assign({}, state, { selectedNode: null }); + case types.RESAMPLE: + return {...state, resamplingCounter: state.resamplingCounter+1}; case types.CHANGE_BRANCH_LABEL: return Object.assign({}, state, { selectedBranchLabel: action.value }); case types.CHANGE_LAYOUT: diff --git a/src/reducers/tree.js b/src/reducers/tree.js index 30961ed71..26d63e4d4 100644 --- a/src/reducers/tree.js +++ b/src/reducers/tree.js @@ -60,8 +60,13 @@ const Tree = (state = getDefaultTreeState(), action) => { case types.NEW_COLORS: return Object.assign({}, state, { nodeColors: action.nodeColors, - nodeColorsVersion: action.version + nodeColorsVersion: action.nodeColorsVersion }); + case types.RESAMPLE: + if (action.nodeColors) { + return {...state, nodeColors: action.nodeColors, nodeColorsVersion: action.nodeColorsVersion}; + } + return state; case types.TREE_TOO_DATA: return action.tree; case types.ADD_COLOR_BYS: diff --git a/src/reducers/treeToo.js b/src/reducers/treeToo.js index fac1dd8b1..4bf6be32b 100644 --- a/src/reducers/treeToo.js +++ b/src/reducers/treeToo.js @@ -45,7 +45,7 @@ const treeToo = (state = getDefaultTreeState(), action) => { if (action.nodeColorsToo) { return Object.assign({}, state, { nodeColors: action.nodeColorsToo, - nodeColorsVersion: action.version + nodeColorsVersion: action.nodeColorsVersion }); } return state; From 04220e4af4279ed032d4a6903aa8facd617ff7a7 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Wed, 10 Jun 2020 15:10:14 +1200 Subject: [PATCH 3/3] Allow animation of sampling uncertainty Pressing `x` or `e` starts an animation where each frame resamples the selected colorby or geo-resolution, respectively. The UI implementation should be considered temporary. --- src/components/framework/keyboard-shortcuts.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/framework/keyboard-shortcuts.js b/src/components/framework/keyboard-shortcuts.js index 913487cfd..7fee94f3f 100644 --- a/src/components/framework/keyboard-shortcuts.js +++ b/src/components/framework/keyboard-shortcuts.js @@ -25,6 +25,10 @@ import {sampleTraitFromUncertainty} from "../../actions/sample"; geoResolution: state.controls.geoResolution })) class KeyboardShortcuts extends React.Component { + constructor(props) { + super(props); + this.rafId = 0; + } componentDidMount() { Mousetrap.bind(['c'], () => {this.props.dispatch(changeToNextColorBy());}); Mousetrap.bind(['s c', 'S C', 's r', 'S R'], (e, combo) => { @@ -33,6 +37,20 @@ class KeyboardShortcuts extends React.Component { returnToOriginal: combo[0]==="S" })); }); + Mousetrap.bind(['x', 'e'], (e, combo) => { + if (this.rafId) { + window.cancelAnimationFrame(this.rafId); + this.rafId = 0; + return; + } + const cb = () => { + this.props.dispatch(sampleTraitFromUncertainty({ + trait: combo==="x" ? this.props.colorBy : this.props.geoResolution + })); + this.rafId = window.requestAnimationFrame(cb); + }; + cb(); + }); } componentWillUnmount() { Mousetrap.unbind(['c', 's c', 'S C', 's r', 'S R']);