From 6f5269a906d130a43d20e6ae63b7e248246b7df9 Mon Sep 17 00:00:00 2001 From: Julian Niedermeier Date: Sun, 23 Aug 2020 22:48:33 +0200 Subject: [PATCH] Enhancement: add property Graph.prependData to support Plotly.prependTraces --- src/components/Graph.react.js | 58 ++++++ src/fragments/Graph.react.js | 48 +++++ tests/integration/graph/test_graph_varia.py | 188 ++++++++++++++++++++ 3 files changed, 294 insertions(+) diff --git a/src/components/Graph.react.js b/src/components/Graph.react.js index 204131bf2..575b38eeb 100644 --- a/src/components/Graph.react.js +++ b/src/components/Graph.react.js @@ -9,6 +9,7 @@ import { privateDefaultProps, } from '../fragments/Graph.privateprops'; +const EMPTY_PREPEND_DATA = []; const EMPTY_EXTEND_DATA = []; /** @@ -22,13 +23,20 @@ class PlotlyGraph extends Component { super(props); this.state = { + prependData: [], extendData: [], }; + this.clearPrependData = this.clearPrependData.bind(this); this.clearExtendData = this.clearExtendData.bind(this); } componentDidMount() { + if (this.props.prependData) { + this.setState({ + prependData: [this.props.prependData], + }); + } if (this.props.extendData) { this.setState({ extendData: [this.props.extendData], @@ -38,11 +46,33 @@ class PlotlyGraph extends Component { componentWillUnmount() { this.setState({ + prependData: [], extendData: [], }); } UNSAFE_componentWillReceiveProps(nextProps) { + let prependData = this.state.prependData.slice(0); + + if (this.props.figure !== nextProps.figure) { + prependData = EMPTY_PREPEND_DATA; + } + + if ( + nextProps.prependData && + this.props.prependData !== nextProps.prependData + ) { + prependData.push(nextProps.prependData); + } else { + prependData = EMPTY_PREPEND_DATA; + } + + if (prependData !== EMPTY_PREPEND_DATA) { + this.setState({ + prependData, + }); + } + let extendData = this.state.extendData.slice(0); if (this.props.figure !== nextProps.figure) { @@ -65,6 +95,19 @@ class PlotlyGraph extends Component { } } + clearPrependData() { + this.setState(({prependData}) => { + const res = + prependData && prependData.length + ? { + prependData: EMPTY_PREPEND_DATA, + } + : undefined; + + return res; + }); + } + clearExtendData() { this.setState(({extendData}) => { const res = @@ -82,6 +125,8 @@ class PlotlyGraph extends Component { return ( @@ -191,6 +236,18 @@ PlotlyGraph.propTypes = { */ extendData: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + /** + * Data that should be prepended to existing traces. Has the form + * `[updateData, traceIndices, maxPoints]`, where `updateData` is an object + * containing the data to prepend, `traceIndices` (optional) is an array of + * trace indices that should be prepended, and `maxPoints` (optional) is + * either an integer defining the maximum number of points allowed or an + * object with key:value pairs matching `updateData` + * Reference the Plotly.prependTraces API for full usage: + * https://plotly.com/javascript/plotlyjs-function-reference/#plotlyprependtraces + */ + prependData: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + /** * Data from latest restyle event which occurs * when the user toggles a legend item, changes @@ -523,6 +580,7 @@ PlotlyGraph.defaultProps = { hoverData: null, selectedData: null, relayoutData: null, + prependData: null, extendData: null, restyleData: null, figure: { diff --git a/src/fragments/Graph.react.js b/src/fragments/Graph.react.js index 7ef855787..232007505 100644 --- a/src/fragments/Graph.react.js +++ b/src/fragments/Graph.react.js @@ -188,6 +188,42 @@ class PlotlyGraph extends Component { }); } + prepend(props) { + const {clearPrependData, prependData: prependDataArray} = props; + + prependDataArray.forEach(prependData => { + let updateData, traceIndices, maxPoints; + if ( + Array.isArray(prependData) && + typeof prependData[0] === 'object' + ) { + [updateData, traceIndices, maxPoints] = prependData; + } else { + updateData = prependData; + } + + if (!traceIndices) { + function getFirstProp(data) { + return data[Object.keys(data)[0]]; + } + + function generateIndices(data) { + return Array.from(Array(getFirstProp(data).length).keys()); + } + traceIndices = generateIndices(updateData); + } + + const gd = this.gd.current; + return Plotly.prependTraces( + gd, + updateData, + traceIndices, + maxPoints + ); + }); + clearPrependData(); + } + extend(props) { const {clearExtendData, extendData: extendDataArray} = props; @@ -348,6 +384,9 @@ class PlotlyGraph extends Component { componentDidMount() { this.plot(this.props); + if (this.props.prependData) { + this.prepend(this.props); + } if (this.props.extendData) { this.extend(this.props); } @@ -392,6 +431,10 @@ class PlotlyGraph extends Component { this.plot(nextProps); } + if (this.props.prependData !== nextProps.prependData) { + this.prepend(nextProps); + } + if (this.props.extendData !== nextProps.extendData) { this.extend(nextProps); } @@ -432,14 +475,19 @@ class PlotlyGraph extends Component { PlotlyGraph.propTypes = { ...graphPropTypes, + prependData: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.array, PropTypes.object]) + ), extendData: PropTypes.arrayOf( PropTypes.oneOfType([PropTypes.array, PropTypes.object]) ), + clearPrependData: PropTypes.func.isRequired, clearExtendData: PropTypes.func.isRequired, }; PlotlyGraph.defaultProps = { ...graphDefaultProps, + prependData: [], extendData: [], }; diff --git a/tests/integration/graph/test_graph_varia.py b/tests/integration/graph/test_graph_varia.py index 5bb193830..e2fa43ce3 100644 --- a/tests/integration/graph/test_graph_varia.py +++ b/tests/integration/graph/test_graph_varia.py @@ -203,6 +203,194 @@ def render_content(click, prev_graph): dash_dcc.percy_snapshot("render-empty-graph ({})".format("eager" if is_eager else "lazy")) +@pytest.mark.parametrize("is_eager", [True, False]) +def test_graph_prepend_trace(dash_dcc, is_eager): + app = dash.Dash(__name__, eager_loading=is_eager) + + def generate_with_id(id, data=None): + if data is None: + data = [{"x": [10, 11, 12, 13, 14], "y": [0, 0.5, 1, 0.5, 0]}] + + return html.Div( + [ + html.P(id), + dcc.Graph(id=id, figure=dict(data=data)), + html.Div(id="output_{}".format(id)), + ] + ) + + figs = [ + "trace_will_prepend", + "trace_will_prepend_with_no_indices", + "trace_will_prepend_with_max_points", + ] + + layout = [generate_with_id(id) for id in figs] + + figs.append("trace_will_allow_repeated_prepend") + data = [{"y": [0, 0, 0]}] + layout.append(generate_with_id(figs[-1], data)) + + figs.append("trace_will_prepend_selectively") + data = [ + {"x": [10, 11, 12, 13, 14], "y": [0, 0.5, 1, 0.5, 0]}, + {"x": [10, 11, 12, 13, 14], "y": [1, 1, 1, 1, 1]}, + ] + layout.append(generate_with_id(figs[-1], data)) + + layout.append( + dcc.Interval( + id="interval_prependablegraph_update", + interval=10, + n_intervals=0, + max_intervals=1, + ) + ) + + layout.append( + dcc.Interval( + id="interval_prependablegraph_prependtwice", + interval=500, + n_intervals=0, + max_intervals=2, + ) + ) + + app.layout = html.Div(layout) + + @app.callback( + Output("trace_will_allow_repeated_prepend", "prependData"), + [Input("interval_prependablegraph_prependtwice", "n_intervals")], + ) + def trace_will_allow_repeated_prepend(n_intervals): + if n_intervals is None or n_intervals < 1: + raise PreventUpdate + + return dict(y=[[0.1, 0.2, 0.3, 0.4, 0.5]]) + + @app.callback( + Output("trace_will_prepend", "prependData"), + [Input("interval_prependablegraph_update", "n_intervals")], + ) + def trace_will_prepend(n_intervals): + if n_intervals is None or n_intervals < 1: + raise PreventUpdate + + x_new = [5, 6, 7, 8, 9] + y_new = [0.1, 0.2, 0.3, 0.4, 0.5] + return dict(x=[x_new], y=[y_new]), [0] + + @app.callback( + Output("trace_will_prepend_selectively", "prependData"), + [Input("interval_prependablegraph_update", "n_intervals")], + ) + def trace_will_prepend_selectively(n_intervals): + if n_intervals is None or n_intervals < 1: + raise PreventUpdate + + x_new = [5, 6, 7, 8, 9] + y_new = [0.1, 0.2, 0.3, 0.4, 0.5] + return dict(x=[x_new], y=[y_new]), [1] + + @app.callback( + Output("trace_will_prepend_with_no_indices", "prependData"), + [Input("interval_prependablegraph_update", "n_intervals")], + ) + def trace_will_prepend_with_no_indices(n_intervals): + if n_intervals is None or n_intervals < 1: + raise PreventUpdate + + x_new = [5, 6, 7, 8, 9] + y_new = [0.1, 0.2, 0.3, 0.4, 0.5] + return dict(x=[x_new], y=[y_new]) + + @app.callback( + Output("trace_will_prepend_with_max_points", "prependData"), + [Input("interval_prependablegraph_update", "n_intervals")], + ) + def trace_will_prepend_with_max_points(n_intervals): + if n_intervals is None or n_intervals < 1: + raise PreventUpdate + + x_new = [5, 6, 7, 8, 9] + y_new = [0.1, 0.2, 0.3, 0.4, 0.5] + return dict(x=[x_new], y=[y_new]), [0], 7 + + for id in figs: + + @app.callback( + Output("output_{}".format(id), "children"), + [Input(id, "prependData")], + [State(id, "figure")], + ) + def display_data(trigger, fig): + return json.dumps(fig["data"]) + + dash_dcc.start_server(app) + + comparison = json.dumps( + [ + dict( + x=[5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + y=[0.1, 0.2, 0.3, 0.4, 0.5, 0, 0.5, 1, 0.5, 0], + ) + ] + ) + dash_dcc.wait_for_text_to_equal("#output_trace_will_prepend", comparison) + dash_dcc.wait_for_text_to_equal( + "#output_trace_will_prepend_with_no_indices", comparison + ) + comparison = json.dumps( + [ + dict(x=[10, 11, 12, 13, 14], y=[0, 0.5, 1, 0.5, 0]), + dict( + x=[5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + y=[0.1, 0.2, 0.3, 0.4, 0.5, 1, 1, 1, 1, 1], + ), + ] + ) + dash_dcc.wait_for_text_to_equal( + "#output_trace_will_prepend_selectively", comparison + ) + + comparison = json.dumps( + [ + dict( + x=[5, 6, 7, 8, 9, 10, 11], + y=[0.1, 0.2, 0.3, 0.4, 0.5, 0, 0.5], + ) + ] + ) + dash_dcc.wait_for_text_to_equal( + "#output_trace_will_prepend_with_max_points", comparison + ) + + comparison = json.dumps( + [ + dict( + y=[ + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0, + 0, + 0, + ] + ) + ] + ) + dash_dcc.wait_for_text_to_equal( + "#output_trace_will_allow_repeated_prepend", comparison + ) + + @pytest.mark.parametrize("is_eager", [True, False]) def test_graph_extend_trace(dash_dcc, is_eager): app = dash.Dash(__name__, eager_loading=is_eager)