Skip to content

Commit b96076a

Browse files
committed
Merge pull request plotly#905 from almarklein/figure-state
Dont clone figure.layout
1 parent 73bfbf5 commit b96076a

File tree

4 files changed

+210
-12
lines changed

4 files changed

+210
-12
lines changed

packages/dash-core-components/CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## [UNRELEASED]
66
### Fixed
7-
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
7+
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
8+
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143
89

910
### Updated
10-
- [#911](https://github.com/plotly/dash-core-components/pull/911)
11+
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
12+
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
1113
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
12-
- [#906](https://github.com/plotly/dash-core-components/pull/906)
1314
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)
1415

1516
## [1.14.1] - 2020-12-09

packages/dash-core-components/src/fragments/Graph.react.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
131131
this.getLayoutOverride = this.getLayoutOverride.bind(this);
132132
this.graphResize = this.graphResize.bind(this);
133133
this.isResponsive = this.isResponsive.bind(this);
134+
135+
this.state = {override: {}, originals: {}};
134136
}
135137

136138
plot(props) {
@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
226228
if (!layout) {
227229
return layout;
228230
}
229-
230-
return mergeDeepRight(layout, this.getLayoutOverride(responsive));
231+
const override = this.getLayoutOverride(responsive);
232+
const {override: prev_override, originals: prev_originals} = this.state;
233+
// Store the original data that we're about to override
234+
const originals = {};
235+
for (const key in override) {
236+
if (layout[key] !== prev_override[key]) {
237+
originals[key] = layout[key];
238+
} else if (prev_originals.hasOwnProperty(key)) {
239+
originals[key] = prev_originals[key];
240+
}
241+
}
242+
this.setState({override, originals});
243+
// Undo the previous override, but only for keys that the user did not change
244+
for (const key in prev_originals) {
245+
if (layout[key] === prev_override[key]) {
246+
layout[key] = prev_originals[key];
247+
}
248+
}
249+
// Apply the current override
250+
for (const key in override) {
251+
layout[key] = override[key];
252+
}
253+
return layout; // not really a clone
231254
}
232255

233256
getConfigOverride(responsive) {

packages/dash-core-components/tests/dash_core_components_page.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):
118118

119119
def release(self):
120120
ActionChains(self.driver).release().perform()
121+
122+
def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
123+
elem = self._get_element(elem_or_selector)
124+
125+
ActionChains(self.driver).move_to_element_with_offset(
126+
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
127+
).click_and_hold().move_to_element_with_offset(
128+
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
129+
).release().perform()

packages/dash-core-components/tests/integration/graph/test_graph_varia.py

Lines changed: 172 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):
2525

2626

2727
@pytest.mark.parametrize("is_eager", [True, False])
28-
def test_candlestick(dash_dcc, is_eager):
28+
def test_grva001_candlestick(dash_dcc, is_eager):
2929
app = dash.Dash(__name__, eager_loading=is_eager)
3030
app.layout = html.Div(
3131
[
@@ -75,7 +75,7 @@ def update_graph(n_clicks):
7575

7676

7777
@pytest.mark.parametrize("is_eager", [True, False])
78-
def test_graphs_with_different_figures(dash_dcc, is_eager):
78+
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
7979
app = dash.Dash(__name__, eager_loading=is_eager)
8080
app.layout = html.Div(
8181
[
@@ -160,7 +160,7 @@ def show_relayout_data(data):
160160

161161

162162
@pytest.mark.parametrize("is_eager", [True, False])
163-
def test_empty_graph(dash_dcc, is_eager):
163+
def test_grva003_empty_graph(dash_dcc, is_eager):
164164
app = dash.Dash(__name__, eager_loading=is_eager)
165165

166166
app.layout = html.Div(
@@ -193,7 +193,7 @@ def render_content(click, prev_graph):
193193

194194

195195
@pytest.mark.parametrize("is_eager", [True, False])
196-
def test_graph_prepend_trace(dash_dcc, is_eager):
196+
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
197197
app = dash.Dash(__name__, eager_loading=is_eager)
198198

199199
def generate_with_id(id, data=None):
@@ -358,7 +358,7 @@ def display_data(trigger, fig):
358358

359359

360360
@pytest.mark.parametrize("is_eager", [True, False])
361-
def test_graph_extend_trace(dash_dcc, is_eager):
361+
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
362362
app = dash.Dash(__name__, eager_loading=is_eager)
363363

364364
def generate_with_id(id, data=None):
@@ -521,7 +521,7 @@ def display_data(trigger, fig):
521521

522522

523523
@pytest.mark.parametrize("is_eager", [True, False])
524-
def test_unmounted_graph_resize(dash_dcc, is_eager):
524+
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
525525
app = dash.Dash(__name__, eager_loading=is_eager)
526526

527527
app.layout = html.Div(
@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
619619
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])
620620

621621

622-
def test_external_plotlyjs_prevents_lazy(dash_dcc):
622+
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
623623
app = dash.Dash(
624624
__name__,
625625
eager_loading=False,
@@ -658,3 +658,168 @@ def load_chart(n_clicks):
658658
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
659659
assert findSyncPlotlyJs(scripts) is None
660660
assert findAsyncPlotlyJs(scripts) is None
661+
662+
663+
def test_grva008_shapes_not_lost(dash_dcc):
664+
# See issue #879 and pr #905
665+
app = dash.Dash(__name__)
666+
667+
fig = {"data": [], "layout": {"dragmode": "drawrect"}}
668+
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})
669+
670+
app.layout = html.Div(
671+
[
672+
graph,
673+
html.Br(),
674+
html.Button(id="button", children="Clone figure"),
675+
html.Div(id="output", children=""),
676+
]
677+
)
678+
679+
app.clientside_callback(
680+
"""
681+
function clone_figure(_, figure) {
682+
const new_figure = {...figure};
683+
const shapes = new_figure.layout.shapes || [];
684+
return [new_figure, shapes.length];
685+
}
686+
""",
687+
Output("graph", "figure"),
688+
Output("output", "children"),
689+
Input("button", "n_clicks"),
690+
State("graph", "figure"),
691+
)
692+
693+
dash_dcc.start_server(app)
694+
button = dash_dcc.wait_for_element("#button")
695+
dash_dcc.wait_for_text_to_equal("#output", "0")
696+
697+
# Draw a shape
698+
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
699+
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
700+
dash_dcc.release()
701+
702+
# Click to trigger an update of the output, the shape should survive
703+
dash_dcc.wait_for_text_to_equal("#output", "0")
704+
button.click()
705+
dash_dcc.wait_for_text_to_equal("#output", "1")
706+
707+
# Draw another shape
708+
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
709+
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
710+
dash_dcc.release()
711+
712+
# Click to trigger an update of the output, the shape should survive
713+
dash_dcc.wait_for_text_to_equal("#output", "1")
714+
button.click()
715+
dash_dcc.wait_for_text_to_equal("#output", "2")
716+
717+
718+
@pytest.mark.parametrize("mutate_fig", [True, False])
719+
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
720+
# In #905 we made changes to prevent shapes from being lost.
721+
# This test makes sure that the overrides applied by the `responsive`
722+
# prop are "undone" when the `responsive` prop changes.
723+
724+
app = dash.Dash(__name__)
725+
726+
graph = dcc.Graph(
727+
id="graph",
728+
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
729+
style={"height": "400px", "width": "500px"},
730+
)
731+
responsive_size = [500, 400]
732+
fixed_size = [300, 250]
733+
734+
app.layout = html.Div(
735+
[
736+
graph,
737+
html.Br(),
738+
html.Button(id="edit_figure", children="Edit figure"),
739+
html.Button(id="edit_responsive", children="Edit responsive"),
740+
html.Div(id="output", children=""),
741+
]
742+
)
743+
744+
if mutate_fig:
745+
# Modify the layout in place (which still has changes made by responsive)
746+
change_fig = """
747+
figure.layout.title = {text: String(n_fig || 0)};
748+
const new_figure = {...figure};
749+
"""
750+
else:
751+
# Or create a new one each time
752+
change_fig = """
753+
const new_figure = {
754+
data: [{y: [1, 2]}],
755+
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
756+
};
757+
"""
758+
759+
callback = (
760+
"""
761+
function clone_figure(n_fig, n_resp, figure) {
762+
"""
763+
+ change_fig
764+
+ """
765+
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
766+
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
767+
}
768+
"""
769+
)
770+
771+
app.clientside_callback(
772+
callback,
773+
Output("graph", "figure"),
774+
Output("graph", "responsive"),
775+
Output("output", "children"),
776+
Input("edit_figure", "n_clicks"),
777+
Input("edit_responsive", "n_clicks"),
778+
State("graph", "figure"),
779+
)
780+
781+
dash_dcc.start_server(app)
782+
edit_figure = dash_dcc.wait_for_element("#edit_figure")
783+
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")
784+
785+
def graph_dims():
786+
return dash_dcc.driver.execute_script(
787+
"""
788+
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
789+
return [layout.width, layout.height];
790+
"""
791+
)
792+
793+
dash_dcc.wait_for_text_to_equal("#output", "0 true")
794+
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
795+
assert graph_dims() == responsive_size
796+
797+
edit_figure.click()
798+
dash_dcc.wait_for_text_to_equal("#output", "1 true")
799+
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
800+
assert graph_dims() == responsive_size
801+
802+
edit_responsive.click()
803+
dash_dcc.wait_for_text_to_equal("#output", "1 false")
804+
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
805+
assert graph_dims() == fixed_size
806+
807+
edit_figure.click()
808+
dash_dcc.wait_for_text_to_equal("#output", "2 false")
809+
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
810+
assert graph_dims() == fixed_size
811+
812+
edit_responsive.click()
813+
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
814+
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
815+
assert graph_dims() == fixed_size
816+
817+
edit_figure.click()
818+
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
819+
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
820+
assert graph_dims() == fixed_size
821+
822+
edit_responsive.click()
823+
dash_dcc.wait_for_text_to_equal("#output", "3 true")
824+
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
825+
assert graph_dims() == responsive_size

0 commit comments

Comments
 (0)