Skip to content

Commit db0d5af

Browse files
authored
Merge pull request #126 from predict-idlab/bug_update_axes
🐛 update layout `axes range` bug
2 parents 5df40fd + c5481a2 commit db0d5af

File tree

10 files changed

+394
-26
lines changed

10 files changed

+394
-26
lines changed

.github/workflows/test.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ jobs:
1818
matrix:
1919
os: ['windows-latest', 'macOS-latest', 'ubuntu-latest']
2020
python-version: ['3.7', '3.8', '3.9', '3.10']
21+
defaults:
22+
run:
23+
shell: bash
2124

2225
steps:
2326
- uses: actions/checkout@v2
@@ -30,8 +33,8 @@ jobs:
3033

3134
- uses: nanasess/setup-chromedriver@master
3235

33-
- name: Install poetry
34-
uses: Gr1N/setup-poetry@v4
36+
- name: Install Poetry
37+
uses: snok/install-poetry@v1
3538
- name: Cache poetry
3639
id: cached-poetry-dependencies
3740
uses: actions/cache@v2

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ poetry build # build the underlying C code
8484
You can run the test with the following code:
8585

8686
```sh
87-
poetry run pytest --cov-report term-missing --cov=plotly-resampler tests
87+
poetry run pytest --cov-report term-missing --cov=plotly_resampler tests
8888
```
8989

9090
To get the selenium tests working you should have Google Chrome installed.

plotly_resampler/figure_resampler/figure_resampler.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,29 @@ def show_dash(
378378
), f"mode must be one of {available_modes}"
379379
graph_properties = {} if graph_properties is None else graph_properties
380380
assert "config" not in graph_properties.keys() # There is a param for config
381+
382+
# 0. Check if the traces need to be updated when there is a xrange set
383+
# This will be the case when the users has set a xrange (via the `update_layout`
384+
# or `update_xaxes` methods`)
385+
relayout_dict = {}
386+
for xaxis_str in self._xaxis_list:
387+
x_range = self.layout[xaxis_str].range
388+
if x_range: # when not None
389+
relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0]
390+
relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1]
391+
if len(relayout_dict):
392+
update_data = self.construct_update_data(relayout_dict)
393+
394+
if not self._is_no_update(update_data): # when there is an update
395+
with self.batch_update():
396+
# First update the layout (first item of update_data)
397+
self.layout.update(update_data[0])
398+
399+
# Then update the data
400+
for updated_trace in update_data[1:]:
401+
trace_idx = updated_trace.pop("index")
402+
self.data[trace_idx].update(updated_trace)
403+
381404
# 1. Construct the Dash app layout
382405
if mode == "inline_persistent":
383406
# Inline persistent mode: we display a static image of the figure when the

plotly_resampler/figure_resampler/figure_resampler_interface.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ def __init__(
149149
# Make sure to reset the layout its range
150150
self.update_layout(
151151
{
152-
axis: {"autorange": True, "range": None}
152+
axis: {"autorange": None, "range": None}
153153
for axis in self._xaxis_list + self._yaxis_list
154154
}
155155
)
@@ -1151,7 +1151,10 @@ def replace(self, figure: go.Figure, convert_existing_traces: bool = True):
11511151
resampled_trace_prefix_suffix=(self._prefix, self._suffix),
11521152
)
11531153

1154-
def construct_update_data(self, relayout_data: dict) -> List[dict]:
1154+
def construct_update_data(
1155+
self,
1156+
relayout_data: dict
1157+
) -> Union[List[dict], dash.no_update]:
11551158
"""Construct the to-be-updated front-end data, based on the layout change.
11561159
11571160
Attention
@@ -1242,7 +1245,7 @@ def construct_update_data(self, relayout_data: dict) -> List[dict]:
12421245
xy_matches = self._re_matches(re.compile(r"[xy]axis\d*.range\[\d+]"), cl_k)
12431246
for range_change_axis in xy_matches:
12441247
axis = range_change_axis.split(".")[0]
1245-
extra_layout_updates[f"{axis}.autorange"] = False
1248+
extra_layout_updates[f"{axis}.autorange"] = None
12461249
layout_traces_list.append(extra_layout_updates)
12471250

12481251
# 2. Create the additional trace data for the frond-end
@@ -1268,6 +1271,10 @@ def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]:
12681271
matches.append(m.string)
12691272
return sorted(matches)
12701273

1274+
@staticmethod
1275+
def _is_no_update(update_data: Union[List[dict], dash.no_update]) -> bool:
1276+
return update_data is dash.no_update
1277+
12711278
## Magic methods (to use plotly.py words :grin:)
12721279

12731280
def _get_pr_props_keys(self) -> List[str]:

plotly_resampler/figure_resampler/figurewidget_resampler.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ def _update_x_ranges(self, layout, *x_ranges, force_update: bool = False):
172172
# Construct the update data
173173
update_data = self.construct_update_data(relayout_dict)
174174

175+
if self._is_no_update(update_data):
176+
# Return when no data update
177+
return
178+
175179
if self._print_verbose:
176180
self._relayout_hist.append(dict(zip(self._xaxis_list, x_ranges)))
177181
self._relayout_hist.append(layout)
@@ -280,7 +284,7 @@ def reset_axes(self):
280284
# Reset the layout
281285
self.update_layout(
282286
{
283-
axis: {"autorange": True, "range": None}
287+
axis: {"autorange": None, "range": None}
284288
for axis in self._xaxis_list + self._yaxis_list
285289
}
286290
)

tests/fr_selenium.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt"
1313

14-
import sys
1514
import json
1615
import time
1716
from typing import List, Union
@@ -20,21 +19,15 @@
2019
from seleniumwire.request import Request
2120
from selenium.webdriver.common.action_chains import ActionChains
2221
from selenium.webdriver.common.by import By
23-
from selenium.webdriver.firefox.options import Options
2422
from selenium.webdriver.support import expected_conditions as EC
2523
from selenium.webdriver.support.ui import WebDriverWait
2624

27-
28-
def not_on_linux():
29-
"""Return True if the current platform is not Linux.
30-
31-
Note: this will be used to add more waiting time to windows & mac os tests as
32-
- on these OS's serialization of the figure is necessary (to start the dash app in a
33-
multiprocessing.Process)
34-
https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
35-
- on linux, the browser (i.e., sending & getting requests) goes a lot faster
36-
"""
37-
return not sys.platform.startswith("linux")
25+
# Note: this will be used to add more waiting time to windows & mac os tests as
26+
# - on these OS's serialization of the figure is necessary (to start the dash app in a
27+
# multiprocessing.Process)
28+
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
29+
# - on linux, the browser (i.e., sending & getting requests) goes a lot faster
30+
from .utils import not_on_linux
3831

3932

4033
# https://www.blazemeter.com/blog/improve-your-selenium-webdriver-tests-with-pytest

tests/test_figure_resampler.py

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@
55

66
import pytest
77
import time
8+
import multiprocessing
9+
810
import numpy as np
911
import pandas as pd
10-
import multiprocessing
1112
import plotly.graph_objects as go
13+
14+
from selenium.webdriver.common.by import By
15+
from typing import List
16+
1217
from plotly.subplots import make_subplots
1318
from plotly_resampler import FigureResampler, LTTB, EveryNthPoint
14-
from typing import List
19+
20+
# Note: this will be used to skip / alter behavior when running browser tests on
21+
# non-linux platforms.
22+
from .utils import not_on_linux
1523

1624

1725
def test_add_trace_kwarg_space(float_series, bool_series, cat_series):
@@ -1027,6 +1035,184 @@ def test_fr_object_binary_data():
10271035
assert np.all(fig.data[0]["y"] == binary_series)
10281036

10291037

1038+
def test_fr_update_layout_axes_range(driver):
1039+
nb_datapoints = 2_000
1040+
n_shown = 500 # < nb_datapoints
1041+
1042+
# Checks whether the update_layout method works as expected
1043+
f_orig = go.Figure().add_scatter(y=np.arange(nb_datapoints))
1044+
f_pr = FigureResampler(default_n_shown_samples=n_shown).add_scatter(
1045+
y=np.arange(nb_datapoints)
1046+
)
1047+
1048+
def check_data(fr: FigureResampler, min_v=0, max_v=nb_datapoints-1):
1049+
# closure for n_shown and nb_datapoints
1050+
assert len(fr.data[0]["y"]) == min(n_shown, nb_datapoints)
1051+
assert len(fr.data[0]["x"]) == min(n_shown, nb_datapoints)
1052+
assert fr.data[0]["y"][0] == min_v
1053+
assert fr.data[0]["y"][-1] == max_v
1054+
assert fr.data[0]["x"][0] == min_v
1055+
assert fr.data[0]["x"][-1] == max_v
1056+
1057+
# Check the initial data
1058+
check_data(f_pr)
1059+
1060+
# The xaxis (auto)range should be the same for both figures
1061+
1062+
assert f_orig.layout.xaxis.range == None
1063+
assert f_pr.layout.xaxis.range == None
1064+
assert f_orig.layout.xaxis.autorange == None
1065+
assert f_pr.layout.xaxis.autorange == None
1066+
1067+
f_orig.update_layout(xaxis_range=[100, 1000])
1068+
f_pr.update_layout(xaxis_range=[100, 1000])
1069+
1070+
assert f_orig.layout.xaxis.range == (100, 1000)
1071+
assert f_pr.layout.xaxis.range == (100, 1000)
1072+
assert f_orig.layout.xaxis.autorange == None
1073+
assert f_pr.layout.xaxis.autorange == None
1074+
1075+
# The yaxis (auto)range should be the same for both figures
1076+
1077+
assert f_orig.layout.yaxis.range == None
1078+
assert f_pr.layout.yaxis.range == None
1079+
assert f_orig.layout.yaxis.autorange == None
1080+
assert f_pr.layout.yaxis.autorange == None
1081+
1082+
f_orig.update_layout(yaxis_range=[100, 1000])
1083+
f_pr.update_layout(yaxis_range=[100, 1000])
1084+
1085+
assert list(f_orig.layout.yaxis.range) == [100, 1000]
1086+
assert list(f_pr.layout.yaxis.range) == [100, 1000]
1087+
assert f_orig.layout.yaxis.autorange == None
1088+
assert f_pr.layout.yaxis.autorange == None
1089+
1090+
# Before showing the figure, the f_pr contains the full original data (downsampled to 500 samples)
1091+
# Even after updating the axes ranges
1092+
check_data(f_pr)
1093+
1094+
if not_on_linux():
1095+
# TODO: eventually we should run this test on Windows & MacOS too
1096+
return
1097+
1098+
f_pr.stop_server()
1099+
proc = multiprocessing.Process(target=f_pr.show_dash, kwargs=dict(mode="external"))
1100+
proc.start()
1101+
try:
1102+
time.sleep(1)
1103+
driver.get(f"http://localhost:8050")
1104+
time.sleep(3)
1105+
# Get the data property from the front-end figure
1106+
el = driver.find_element(by=By.ID, value="resample-figure")
1107+
el = el.find_element(by=By.CLASS_NAME, value="js-plotly-plot")
1108+
f_pr_data = el.get_property("data")
1109+
f_pr_layout = el.get_property("layout")
1110+
1111+
# After showing the figure, the f_pr contains the data of the selected xrange (downsampled to 500 samples)
1112+
assert len(f_pr_data[0]["y"]) == 500
1113+
assert len(f_pr_data[0]["x"]) == 500
1114+
assert f_pr_data[0]["y"][0] >= 100 and f_pr_data[0]["y"][-1] <= 1000
1115+
assert f_pr_data[0]["x"][0] >= 100 and f_pr_data[0]["x"][-1] <= 1000
1116+
# Check the front-end layout
1117+
assert list(f_pr_layout["xaxis"]["range"]) == [100, 1000]
1118+
assert list(f_pr_layout["yaxis"]["range"]) == [100, 1000]
1119+
except Exception as e:
1120+
raise e
1121+
finally:
1122+
proc.terminate()
1123+
f_pr.stop_server()
1124+
1125+
1126+
def test_fr_update_layout_axes_range_no_update(driver):
1127+
nb_datapoints = 2_000
1128+
n_shown = 20_000 # > nb. datapoints
1129+
1130+
# Checks whether the update_layout method works as expected
1131+
f_orig = go.Figure().add_scatter(y=np.arange(nb_datapoints))
1132+
f_pr = FigureResampler(default_n_shown_samples=n_shown).add_scatter(
1133+
y=np.arange(nb_datapoints)
1134+
)
1135+
1136+
def check_data(fr: FigureResampler, min_v=0, max_v=nb_datapoints-1):
1137+
# closure for n_shown and nb_datapoints
1138+
assert len(fr.data[0]["y"]) == min(n_shown, nb_datapoints)
1139+
assert len(fr.data[0]["x"]) == min(n_shown, nb_datapoints)
1140+
assert fr.data[0]["y"][0] == min_v
1141+
assert fr.data[0]["y"][-1] == max_v
1142+
assert fr.data[0]["x"][0] == min_v
1143+
assert fr.data[0]["x"][-1] == max_v
1144+
1145+
# Check the initial data
1146+
check_data(f_pr)
1147+
1148+
# The xaxis (auto)range should be the same for both figures
1149+
1150+
assert f_orig.layout.xaxis.range == None
1151+
assert f_pr.layout.xaxis.range == None
1152+
assert f_orig.layout.xaxis.autorange == None
1153+
assert f_pr.layout.xaxis.autorange == None
1154+
1155+
f_orig.update_layout(xaxis_range=[100, 1000])
1156+
f_pr.update_layout(xaxis_range=[100, 1000])
1157+
1158+
assert f_orig.layout.xaxis.range == (100, 1000)
1159+
assert f_pr.layout.xaxis.range == (100, 1000)
1160+
assert f_orig.layout.xaxis.autorange == None
1161+
assert f_pr.layout.xaxis.autorange == None
1162+
1163+
# The yaxis (auto)range should be the same for both figures
1164+
1165+
assert f_orig.layout.yaxis.range == None
1166+
assert f_pr.layout.yaxis.range == None
1167+
assert f_orig.layout.yaxis.autorange == None
1168+
assert f_pr.layout.yaxis.autorange == None
1169+
1170+
f_orig.update_layout(yaxis_range=[100, 1000])
1171+
f_pr.update_layout(yaxis_range=[100, 1000])
1172+
1173+
assert list(f_orig.layout.yaxis.range) == [100, 1000]
1174+
assert list(f_pr.layout.yaxis.range) == [100, 1000]
1175+
assert f_orig.layout.yaxis.autorange == None
1176+
assert f_pr.layout.yaxis.autorange == None
1177+
1178+
# Before showing the figure, the f_pr contains the full original data (not downsampled)
1179+
# Even after updating the axes ranges
1180+
check_data(f_pr)
1181+
1182+
if not_on_linux():
1183+
# TODO: eventually we should run this test on Windows & MacOS too
1184+
return
1185+
1186+
f_pr.stop_server()
1187+
proc = multiprocessing.Process(target=f_pr.show_dash, kwargs=dict(mode="external"))
1188+
proc.start()
1189+
try:
1190+
time.sleep(1)
1191+
driver.get(f"http://localhost:8050")
1192+
time.sleep(3)
1193+
# Get the data & layout property from the front-end figure
1194+
el = driver.find_element(by=By.ID, value="resample-figure")
1195+
el = el.find_element(by=By.CLASS_NAME, value="js-plotly-plot")
1196+
f_pr_data = el.get_property("data")
1197+
f_pr_layout = el.get_property("layout")
1198+
1199+
# After showing the figure, the f_pr contains the original data (not downsampled), but shown xrange is [100, 1000]
1200+
assert len(f_pr_data[0]["y"]) == 2_000
1201+
assert len(f_pr_data[0]["x"]) == 2_000
1202+
assert f_pr.data[0]["y"][0] == 0
1203+
assert f_pr.data[0]["y"][-1] == 1999
1204+
assert f_pr.data[0]["x"][0] == 0
1205+
assert f_pr.data[0]["x"][-1] == 1999
1206+
# Check the front-end layout
1207+
assert list(f_pr_layout["xaxis"]["range"]) == [100, 1000]
1208+
assert list(f_pr_layout["yaxis"]["range"]) == [100, 1000]
1209+
except Exception as e:
1210+
raise e
1211+
finally:
1212+
proc.terminate()
1213+
f_pr.stop_server()
1214+
1215+
10301216
def test_fr_copy_grid():
10311217
# Checks whether _grid_ref and _grid_str are correctly maintained
10321218

tests/test_figure_resampler_selenium.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,3 +657,31 @@ def test_multi_trace_go_figure(driver, multi_trace_go_figure):
657657
raise e
658658
finally:
659659
proc.terminate()
660+
661+
662+
def test_multi_trace_go_figure_updated_xrange(driver, multi_trace_go_figure):
663+
# This test checks that the xaxis range is updated when the xaxis range is set
664+
# Notet hat this test just hits the .show_dash() method
665+
from pytest_cov.embed import cleanup_on_sigterm
666+
667+
cleanup_on_sigterm()
668+
669+
multi_trace_go_figure.update_xaxes(range=[100, 200_000])
670+
671+
port = 9038
672+
proc = multiprocessing.Process(
673+
target=multi_trace_go_figure.show_dash,
674+
kwargs=dict(mode="external", port=port),
675+
)
676+
proc.start()
677+
try:
678+
# Just hit the code of the .show_dash method when an x-range is set
679+
time.sleep(1)
680+
fr = FigureResamplerGUITests(driver, port=port)
681+
time.sleep(1)
682+
fr.go_to_page()
683+
684+
except Exception as e:
685+
raise e
686+
finally:
687+
proc.terminate()

0 commit comments

Comments
 (0)