From 0c91724786d5b7f556defbcd002a9c003fac4962 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 28 Oct 2024 13:52:20 +1300 Subject: [PATCH 1/5] [entropy] improve performance of entropy calcs by not recalculating data on the leading edge of the debounced recalculation action. For big trees (which take >500ms of time to redraw) the main thread is still blocked for roughly the same amount of time, but the tree is redrawn faster. For small trees which redraw quicker than that the entropy doesn't update until the debounce 500ms timeout is reached, resulting in slightly odd behaviour. (Reducing this timout also results in less-than-ideal behaviour as (e.g.) dragging the date range of a tree results in interruptions while the entropy calcs run which is worse IMO.) --- src/actions/entropy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/entropy.js b/src/actions/entropy.js index e449c3f1a..c3cdb59b7 100644 --- a/src/actions/entropy.js +++ b/src/actions/entropy.js @@ -15,7 +15,7 @@ export const updateEntropyVisibility = debounce((dispatch, getState) => { ) {return;} const [data, maxYVal] = calcEntropyInView(tree.nodes, tree.visibility, entropy.selectedCds, entropy.showCounts); dispatch({type: types.ENTROPY_DATA, data, maxYVal}); -}, 500, { leading: true, trailing: true }); +}, 500, { leading: false, trailing: true }); /** * Returns a thunk which makes zero or one dispatches to update the entropy reducer From ff7e2efbd9571d55dadc0466c0fe0457d980c501 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 28 Oct 2024 15:27:30 +1300 Subject: [PATCH 2/5] [entropy] skip recalc if panel's not rendered Improves the performance of most interactions in Auspice when the `` panel's not displayed by not constantly updating the entropy calculations. The downside is when the panel's toggled on things are a little slower as we recalculate at that point. Note that the Entropy panel should never be rendered with invalid / stale / uncomputed data. Any action which results in the panel being shown is responsible for ensuring the underlying data is updated as appropriate. --- src/actions/entropy.js | 9 +++++++++ src/actions/panelDisplay.js | 16 +++++++++++++--- src/reducers/entropy.js | 5 +++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/actions/entropy.js b/src/actions/entropy.js index c3cdb59b7..4a067c16f 100644 --- a/src/actions/entropy.js +++ b/src/actions/entropy.js @@ -13,6 +13,15 @@ export const updateEntropyVisibility = debounce((dispatch, getState) => { !entropy.genomeMap || controls.animationPlayPauseButton !== "Play" ) {return;} + + if (!controls.panelsToDisplay.includes("entropy")) { + if (entropy.bars===undefined) { + return; // no need to dispatch another action - the state's already been invalidated + } + // clear the entropy data so we don't keep an out-of-date copy + return dispatch({type: types.ENTROPY_DATA, data: undefined, maxYVal: 1}); + } + const [data, maxYVal] = calcEntropyInView(tree.nodes, tree.visibility, entropy.selectedCds, entropy.showCounts); dispatch({type: types.ENTROPY_DATA, data, maxYVal}); }, 500, { leading: false, trailing: true }); diff --git a/src/actions/panelDisplay.js b/src/actions/panelDisplay.js index 4002881f1..972abe28b 100644 --- a/src/actions/panelDisplay.js +++ b/src/actions/panelDisplay.js @@ -1,15 +1,17 @@ import { intersection } from "lodash"; import { TOGGLE_PANEL_DISPLAY } from "./types"; +import { calcEntropyInView } from "../util/entropy"; const gridPanels = ["tree", "measurements", "map"]; export const numberOfGridPanels = (panels) => intersection(panels, gridPanels).length; export const hasMultipleGridPanels = (panels) => numberOfGridPanels(panels) > 1; export const togglePanelDisplay = (panelName) => (dispatch, getState) => { - const { controls } = getState(); + const { controls, entropy, tree } = getState(); const idx = controls.panelsToDisplay.indexOf(panelName); let panelsToDisplay; - if (idx === -1) {/* add */ + const addPanel = idx===-1; + if (addPanel) {/* add */ panelsToDisplay = controls.panelsAvailable.filter((n) => controls.panelsToDisplay.indexOf(n) !== -1 || n === panelName ); @@ -19,5 +21,13 @@ export const togglePanelDisplay = (panelName) => (dispatch, getState) => { } const canTogglePanelLayout = hasMultipleGridPanels(panelsToDisplay); const panelLayout = canTogglePanelLayout ? controls.panelLayout : "full"; - dispatch({type: TOGGLE_PANEL_DISPLAY, panelsToDisplay, panelLayout, canTogglePanelLayout}); + + /* If we're toggling on the entropy panel, and the entropy data is stale (it + becomes stale if an action which would normally update it is skipped due to + the entropy panel not being rendered) then we need to recalculate it here */ + let entropyData, entropyMaxYVal; + if (addPanel && panelName==='entropy' && !entropy.bars) { + ([entropyData, entropyMaxYVal] = calcEntropyInView(tree.nodes, tree.visibility, entropy.selectedCds, entropy.showCounts)); + } + dispatch({type: TOGGLE_PANEL_DISPLAY, panelsToDisplay, panelLayout, canTogglePanelLayout, entropyData, entropyMaxYVal}); }; diff --git a/src/reducers/entropy.js b/src/reducers/entropy.js index a17b212b2..bb1157c6d 100644 --- a/src/reducers/entropy.js +++ b/src/reducers/entropy.js @@ -22,6 +22,11 @@ const Entropy = (state = defaultEntropyState(), action) => { bars: action.data, maxYVal: action.maxYVal }); + case types.TOGGLE_PANEL_DISPLAY: + if (action.entropyData) { + return {...state, bars: action.entropyData, maxYVal: action.entropyMaxYVal} + } + return state; case types.ENTROPY_COUNTS_TOGGLE: return Object.assign({}, state, { showCounts: action.showCounts From 5fd5df590ad227e016adabc523c904a812718de2 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 28 Oct 2024 16:28:39 +1300 Subject: [PATCH 3/5] [entropy] don't update if not on screen Use an intersection observer to detect when the entropy panel is visible on the screen (viewport). When it's offscreen we don't update entropy data (an expensive calculation). When it comes back onscreen we recalculate the entropy data if it has become stale. This results in slightly strange behaviour when the entropy panel will be shown with no bars and they'll be drawn after a slight delay (while the data's recalculated). The wins are much improved performance when the entropy panel is not on screen (which is common). --- src/actions/entropy.js | 2 +- src/actions/recomputeReduxState.js | 1 + src/actions/types.js | 1 + src/components/entropy/entropyD3.js | 4 ++++ src/components/entropy/index.js | 25 +++++++++++++++++++++++-- src/reducers/entropy.js | 5 +++++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/actions/entropy.js b/src/actions/entropy.js index 4a067c16f..de15c6bb7 100644 --- a/src/actions/entropy.js +++ b/src/actions/entropy.js @@ -14,7 +14,7 @@ export const updateEntropyVisibility = debounce((dispatch, getState) => { controls.animationPlayPauseButton !== "Play" ) {return;} - if (!controls.panelsToDisplay.includes("entropy")) { + if (!controls.panelsToDisplay.includes("entropy") || entropy.onScreen===false) { if (entropy.bars===undefined) { return; // no need to dispatch another action - the state's already been invalidated } diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 588e5dc62..18e55b07d 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -1014,6 +1014,7 @@ export const createStateFromQueryOrJSONs = ({ const [entropyBars, entropyMaxYVal] = calcEntropyInView(tree.nodes, tree.visibility, entropy.selectedCds, entropy.showCounts); entropy.bars = entropyBars; entropy.maxYVal = entropyMaxYVal; + entropy.onScreen = true; } /* update frequencies if they exist (not done for new JSONs) */ diff --git a/src/actions/types.js b/src/actions/types.js index fb59b92fe..fbe3d3cd8 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -35,6 +35,7 @@ export const CHANGE_TREE_ROOT_IDX = "CHANGE_TREE_ROOT_IDX"; export const TOGGLE_NARRATIVE = "TOGGLE_NARRATIVE"; export const ENTROPY_DATA = "ENTROPY_DATA"; export const ENTROPY_COUNTS_TOGGLE = "ENTROPY_COUNTS_TOGGLE"; +export const ENTROPY_ONSCREEN_CHANGE = "ENTROPY_ONSCREEN_CHANGE"; export const PAGE_CHANGE = "PAGE_CHANGE"; export const MIDDLEWARE_ONLY_ANIMATION_STARTED = "MIDDLEWARE_ONLY_ANIMATION_STARTED"; export const URL_QUERY_CHANGE_WITH_COMPUTED_STATE = "URL_QUERY_CHANGE_WITH_COMPUTED_STATE"; diff --git a/src/components/entropy/entropyD3.js b/src/components/entropy/entropyD3.js index bd2b95a70..409e27f5d 100644 --- a/src/components/entropy/entropyD3.js +++ b/src/components/entropy/entropyD3.js @@ -470,6 +470,10 @@ EntropyChart.prototype._drawBars = function _drawBars() { if (!this.okToDrawBars) {return;} this._groups.mainBars.selectAll("*").remove(); + // bars may be undefined when the underlying data is marked as stale but the panel's still rendered + // (it's necessarily off-screen for this to occur, but we still call rendering code) + if (!this.bars) {return;} + /* Calculate bar width */ const validXPos = this.scales.xMain.domain()[0]; // any value inside the scale's domain will do let barWidth = this.scales.xMain(validXPos+1) - this.scales.xMain(validXPos); // pixels between 2 nucleotides diff --git a/src/components/entropy/index.js b/src/components/entropy/index.js index ee1067da1..947996a8e 100644 --- a/src/components/entropy/index.js +++ b/src/components/entropy/index.js @@ -12,10 +12,11 @@ import { tabGroup, tabGroupMember, tabGroupMemberSelected } from "../../globalSt import EntropyChart from "./entropyD3"; import InfoPanel from "./infoPanel"; import { changeEntropyCdsSelection, showCountsNotEntropy } from "../../actions/entropy"; +import { ENTROPY_ONSCREEN_CHANGE } from "../../actions/types"; import { timerStart, timerEnd } from "../../util/perf"; import { encodeColorByGenotype } from "../../util/getGenotype"; import { nucleotide_gene } from "../../util/globals"; -import { getCdsByName } from "../../util/entropy"; +import { getCdsByName, calcEntropyInView } from "../../util/entropy"; import { StyledTooltip } from "../controls/styles"; import "../../css/entropy.css"; @@ -69,6 +70,7 @@ const getStyles = (width) => { maxYVal: state.entropy.maxYVal, showCounts: state.entropy.showCounts, loaded: state.entropy.loaded, + onScreen: state.entropy.onScreen, colorBy: state.controls.colorBy, /** * Note that zoomMin & zoomMax only represent the state when changed by a URL @@ -194,9 +196,28 @@ class Entropy extends React.Component { } this.setState({chart}); } + visibilityOnScreenChange(entries) { + if (entries.length!==1) { + return console.error(`Unexpected IntersectionObserver callback entries of length`, entries.length); + } + const onScreen = entries[0].isIntersecting; + if (onScreen===this.props.onScreen) return; // can happen when component initially rendered + // if gone off screen or come back on screen with the bars still valid then we don't need to recalculate entropy data + if (!onScreen || this.props.bars) { + return this.props.dispatch({type: ENTROPY_ONSCREEN_CHANGE, onScreen}) + } + // else if back on screen and the bars are invalid then we need to regenerate them + this.props.dispatch((dispatch, getState) => { + const { entropy, tree } = getState(); + const [entropyData, entropyMaxYVal] = calcEntropyInView(tree.nodes, tree.visibility, entropy.selectedCds, entropy.showCounts); + dispatch({type: ENTROPY_ONSCREEN_CHANGE, onScreen, entropyData, entropyMaxYVal}); + }); + } componentDidMount() { if (this.props.loaded) { - this.setUp(this.props); + this.setUp(this.props); + const observer = new IntersectionObserver(this.visibilityOnScreenChange.bind(this), {threshold: 0.0}); + observer.observe(this.d3entropy) } } UNSAFE_componentWillReceiveProps(nextProps) { diff --git a/src/reducers/entropy.js b/src/reducers/entropy.js index bb1157c6d..f36934221 100644 --- a/src/reducers/entropy.js +++ b/src/reducers/entropy.js @@ -27,6 +27,11 @@ const Entropy = (state = defaultEntropyState(), action) => { return {...state, bars: action.entropyData, maxYVal: action.entropyMaxYVal} } return state; + case types.ENTROPY_ONSCREEN_CHANGE: + if (action.entropyData) { + return {...state, onScreen: action.onScreen, bars: action.entropyData, maxYVal: action.entropyMaxYVal} + } + return {...state, onScreen: action.onScreen}; case types.ENTROPY_COUNTS_TOGGLE: return Object.assign({}, state, { showCounts: action.showCounts From b15dbe2542e60147f70c2a872ee8c9e14174484e Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 5 Nov 2024 13:22:33 +1300 Subject: [PATCH 4/5] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c37da00d0..823752545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +* Performance improvement: We no longer recompute the entropy data (which can be expensive) when the entropy panel is toggled off or off-screen. ([#1879](https://github.com/nextstrain/auspice/pull/1879)) + ## version 2.59.1 - 2024/10/23 From 5543a869ee5c6d5431e1c03dae39cc067498e247 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 5 Nov 2024 13:43:39 +1300 Subject: [PATCH 5/5] [entropy] visually indicate when data is reloading Prompted by --- src/components/entropy/entropyD3.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/entropy/entropyD3.js b/src/components/entropy/entropyD3.js index 409e27f5d..e02b1aae3 100644 --- a/src/components/entropy/entropyD3.js +++ b/src/components/entropy/entropyD3.js @@ -465,6 +465,28 @@ EntropyChart.prototype._highlightSelectedBars = function _highlightSelectedBars( } }; +EntropyChart.prototype._drawLoadingState = function _drawLoadingState() { + this._groups.mainBars + .append("rect") + .attr("class", "loading") + .attr("x", 0) + .attr("y", 0) + .attr("width", () => this.scales.xMain.range()[1]) + .attr("height", () => this.scales.y.range()[0]) + .attr("fill-opacity", 0.1) + this._groups.mainBars + .append("text") + .attr("y", () => this.scales.y.range()[0]/2) + .attr("x", () => this.scales.xMain.range()[1]/2) + .attr("pointer-events", "none") + .attr("text-anchor", "middle") // horizontal axis + .attr("dominant-baseline", "middle") // vertical axis + .style("fill", darkGrey) + .style("font-size", '5rem') + .style("font-weight", 200) + .text('data loading') +} + /* draw the bars (for each base / aa) */ EntropyChart.prototype._drawBars = function _drawBars() { if (!this.okToDrawBars) {return;} @@ -472,7 +494,9 @@ EntropyChart.prototype._drawBars = function _drawBars() { // bars may be undefined when the underlying data is marked as stale but the panel's still rendered // (it's necessarily off-screen for this to occur, but we still call rendering code) - if (!this.bars) {return;} + if (!this.bars) { + return this._drawLoadingState(); + } /* Calculate bar width */ const validXPos = this.scales.xMain.domain()[0]; // any value inside the scale's domain will do