diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8b22f0e..d246510a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- [#850](https://github.com/plotly/dash-core-components/pull/850) Add property `prependData` to `Graph` to support `Plotly.prependTraces` + + refactored the existing `extendTraces` API to be a single `mergeTraces` API that can handle both `prepend` as well as `extend`. - [#840](https://github.com/plotly/dash-table/pull/840) Add styling properties to `dcc.Loading` component + `parent_className`: Add CSS class for the outermost `dcc.Loading` parent div DOM node + `parent_style`: Add CSS style property for the outermost `dcc.Loading` parent div DOM node diff --git a/src/components/Graph.react.js b/src/components/Graph.react.js index 204131bf2..2b4aaf17f 100644 --- a/src/components/Graph.react.js +++ b/src/components/Graph.react.js @@ -9,7 +9,7 @@ import { privateDefaultProps, } from '../fragments/Graph.privateprops'; -const EMPTY_EXTEND_DATA = []; +const EMPTY_DATA = []; /** * Graph can be used to render any plotly.js-powered data visualization. @@ -22,13 +22,19 @@ class PlotlyGraph extends Component { super(props); this.state = { + prependData: [], extendData: [], }; - this.clearExtendData = this.clearExtendData.bind(this); + this.clearState = this.clearState.bind(this); } componentDidMount() { + if (this.props.prependData) { + this.setState({ + prependData: [this.props.prependData], + }); + } if (this.props.extendData) { this.setState({ extendData: [this.props.extendData], @@ -38,15 +44,37 @@ 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_DATA; + } + + if ( + nextProps.prependData && + this.props.prependData !== nextProps.prependData + ) { + prependData.push(nextProps.prependData); + } else { + prependData = EMPTY_DATA; + } + + if (prependData !== EMPTY_DATA) { + this.setState({ + prependData, + }); + } + let extendData = this.state.extendData.slice(0); if (this.props.figure !== nextProps.figure) { - extendData = EMPTY_EXTEND_DATA; + extendData = EMPTY_DATA; } if ( @@ -55,22 +83,23 @@ class PlotlyGraph extends Component { ) { extendData.push(nextProps.extendData); } else { - extendData = EMPTY_EXTEND_DATA; + extendData = EMPTY_DATA; } - if (extendData !== EMPTY_EXTEND_DATA) { + if (extendData !== EMPTY_DATA) { this.setState({ extendData, }); } } - clearExtendData() { - this.setState(({extendData}) => { + clearState(dataKey) { + this.setState(props => { + var data = props[dataKey]; const res = - extendData && extendData.length + data && data.length ? { - extendData: EMPTY_EXTEND_DATA, + [dataKey]: EMPTY_DATA, } : undefined; @@ -82,8 +111,9 @@ class PlotlyGraph extends Component { return ( ); } @@ -191,6 +221,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 +565,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..67fd4fd09 100644 --- a/src/fragments/Graph.react.js +++ b/src/fragments/Graph.react.js @@ -188,18 +188,16 @@ class PlotlyGraph extends Component { }); } - extend(props) { - const {clearExtendData, extendData: extendDataArray} = props; + mergeTraces(props, dataKey, plotlyFnKey) { + const clearState = props.clearState; + const dataArray = props[dataKey]; - extendDataArray.forEach(extendData => { + dataArray.forEach(data => { let updateData, traceIndices, maxPoints; - if ( - Array.isArray(extendData) && - typeof extendData[0] === 'object' - ) { - [updateData, traceIndices, maxPoints] = extendData; + if (Array.isArray(data) && typeof data[0] === 'object') { + [updateData, traceIndices, maxPoints] = data; } else { - updateData = extendData; + updateData = data; } if (!traceIndices) { @@ -214,9 +212,9 @@ class PlotlyGraph extends Component { } const gd = this.gd.current; - return Plotly.extendTraces(gd, updateData, traceIndices, maxPoints); + return Plotly[plotlyFnKey](gd, updateData, traceIndices, maxPoints); }); - clearExtendData(); + clearState(dataKey); } getConfig(config, responsive) { @@ -348,8 +346,11 @@ class PlotlyGraph extends Component { componentDidMount() { this.plot(this.props); + if (this.props.prependData) { + this.mergeTraces(this.props, 'prependData', 'prependTraces'); + } if (this.props.extendData) { - this.extend(this.props); + this.mergeTraces(this.props, 'extendData', 'extendTraces'); } } @@ -392,8 +393,12 @@ class PlotlyGraph extends Component { this.plot(nextProps); } + if (this.props.prependData !== nextProps.prependData) { + this.mergeTraces(nextProps, 'prependData', 'prependTraces'); + } + if (this.props.extendData !== nextProps.extendData) { - this.extend(nextProps); + this.mergeTraces(nextProps, 'extendData', 'extendTraces'); } } @@ -432,14 +437,18 @@ 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]) ), - clearExtendData: PropTypes.func.isRequired, + clearState: 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)