Skip to content

🐛 update layout axes range bug #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
matrix:
os: ['windows-latest', 'macOS-latest', 'ubuntu-latest']
python-version: ['3.7', '3.8', '3.9', '3.10']
defaults:
run:
shell: bash

steps:
- uses: actions/checkout@v2
Expand All @@ -30,8 +33,8 @@ jobs:

- uses: nanasess/setup-chromedriver@master

- name: Install poetry
uses: Gr1N/setup-poetry@v4
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Cache poetry
id: cached-poetry-dependencies
uses: actions/cache@v2
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ poetry build # build the underlying C code
You can run the test with the following code:

```sh
poetry run pytest --cov-report term-missing --cov=plotly-resampler tests
poetry run pytest --cov-report term-missing --cov=plotly_resampler tests
```

To get the selenium tests working you should have Google Chrome installed.
Expand Down
23 changes: 23 additions & 0 deletions plotly_resampler/figure_resampler/figure_resampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,29 @@ def show_dash(
), f"mode must be one of {available_modes}"
graph_properties = {} if graph_properties is None else graph_properties
assert "config" not in graph_properties.keys() # There is a param for config

# 0. Check if the traces need to be updated when there is a xrange set
# This will be the case when the users has set a xrange (via the `update_layout`
# or `update_xaxes` methods`)
relayout_dict = {}
for xaxis_str in self._xaxis_list:
x_range = self.layout[xaxis_str].range
if x_range: # when not None
relayout_dict[f"{xaxis_str}.range[0]"] = x_range[0]
relayout_dict[f"{xaxis_str}.range[1]"] = x_range[1]
if len(relayout_dict):
update_data = self.construct_update_data(relayout_dict)

if not self._is_no_update(update_data): # when there is an update
with self.batch_update():
# First update the layout (first item of update_data)
self.layout.update(update_data[0])

# Then update the data
for updated_trace in update_data[1:]:
trace_idx = updated_trace.pop("index")
self.data[trace_idx].update(updated_trace)

# 1. Construct the Dash app layout
if mode == "inline_persistent":
# Inline persistent mode: we display a static image of the figure when the
Expand Down
13 changes: 10 additions & 3 deletions plotly_resampler/figure_resampler/figure_resampler_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def __init__(
# Make sure to reset the layout its range
self.update_layout(
{
axis: {"autorange": True, "range": None}
axis: {"autorange": None, "range": None}
for axis in self._xaxis_list + self._yaxis_list
}
)
Expand Down Expand Up @@ -1151,7 +1151,10 @@ def replace(self, figure: go.Figure, convert_existing_traces: bool = True):
resampled_trace_prefix_suffix=(self._prefix, self._suffix),
)

def construct_update_data(self, relayout_data: dict) -> List[dict]:
def construct_update_data(
self,
relayout_data: dict
) -> Union[List[dict], dash.no_update]:
"""Construct the to-be-updated front-end data, based on the layout change.

Attention
Expand Down Expand Up @@ -1242,7 +1245,7 @@ def construct_update_data(self, relayout_data: dict) -> List[dict]:
xy_matches = self._re_matches(re.compile(r"[xy]axis\d*.range\[\d+]"), cl_k)
for range_change_axis in xy_matches:
axis = range_change_axis.split(".")[0]
extra_layout_updates[f"{axis}.autorange"] = False
extra_layout_updates[f"{axis}.autorange"] = None
layout_traces_list.append(extra_layout_updates)

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

@staticmethod
def _is_no_update(update_data: Union[List[dict], dash.no_update]) -> bool:
return update_data is dash.no_update

## Magic methods (to use plotly.py words :grin:)

def _get_pr_props_keys(self) -> List[str]:
Expand Down
6 changes: 5 additions & 1 deletion plotly_resampler/figure_resampler/figurewidget_resampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ def _update_x_ranges(self, layout, *x_ranges, force_update: bool = False):
# Construct the update data
update_data = self.construct_update_data(relayout_dict)

if self._is_no_update(update_data):
# Return when no data update
return

if self._print_verbose:
self._relayout_hist.append(dict(zip(self._xaxis_list, x_ranges)))
self._relayout_hist.append(layout)
Expand Down Expand Up @@ -280,7 +284,7 @@ def reset_axes(self):
# Reset the layout
self.update_layout(
{
axis: {"autorange": True, "range": None}
axis: {"autorange": None, "range": None}
for axis in self._xaxis_list + self._yaxis_list
}
)
Expand Down
19 changes: 6 additions & 13 deletions tests/fr_selenium.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

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

import sys
import json
import time
from typing import List, Union
Expand All @@ -20,21 +19,15 @@
from seleniumwire.request import Request
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


def not_on_linux():
"""Return True if the current platform is not Linux.

Note: this will be used to add more waiting time to windows & mac os tests as
- on these OS's serialization of the figure is necessary (to start the dash app in a
multiprocessing.Process)
https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
- on linux, the browser (i.e., sending & getting requests) goes a lot faster
"""
return not sys.platform.startswith("linux")
# Note: this will be used to add more waiting time to windows & mac os tests as
# - on these OS's serialization of the figure is necessary (to start the dash app in a
# multiprocessing.Process)
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
# - on linux, the browser (i.e., sending & getting requests) goes a lot faster
from .utils import not_on_linux


# https://www.blazemeter.com/blog/improve-your-selenium-webdriver-tests-with-pytest
Expand Down
190 changes: 188 additions & 2 deletions tests/test_figure_resampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@

import pytest
import time
import multiprocessing

import numpy as np
import pandas as pd
import multiprocessing
import plotly.graph_objects as go

from selenium.webdriver.common.by import By
from typing import List

from plotly.subplots import make_subplots
from plotly_resampler import FigureResampler, LTTB, EveryNthPoint
from typing import List

# Note: this will be used to skip / alter behavior when running browser tests on
# non-linux platforms.
from .utils import not_on_linux


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


def test_fr_update_layout_axes_range(driver):
nb_datapoints = 2_000
n_shown = 500 # < nb_datapoints

# Checks whether the update_layout method works as expected
f_orig = go.Figure().add_scatter(y=np.arange(nb_datapoints))
f_pr = FigureResampler(default_n_shown_samples=n_shown).add_scatter(
y=np.arange(nb_datapoints)
)

def check_data(fr: FigureResampler, min_v=0, max_v=nb_datapoints-1):
# closure for n_shown and nb_datapoints
assert len(fr.data[0]["y"]) == min(n_shown, nb_datapoints)
assert len(fr.data[0]["x"]) == min(n_shown, nb_datapoints)
assert fr.data[0]["y"][0] == min_v
assert fr.data[0]["y"][-1] == max_v
assert fr.data[0]["x"][0] == min_v
assert fr.data[0]["x"][-1] == max_v

# Check the initial data
check_data(f_pr)

# The xaxis (auto)range should be the same for both figures

assert f_orig.layout.xaxis.range == None
assert f_pr.layout.xaxis.range == None
assert f_orig.layout.xaxis.autorange == None
assert f_pr.layout.xaxis.autorange == None

f_orig.update_layout(xaxis_range=[100, 1000])
f_pr.update_layout(xaxis_range=[100, 1000])

assert f_orig.layout.xaxis.range == (100, 1000)
assert f_pr.layout.xaxis.range == (100, 1000)
assert f_orig.layout.xaxis.autorange == None
assert f_pr.layout.xaxis.autorange == None

# The yaxis (auto)range should be the same for both figures

assert f_orig.layout.yaxis.range == None
assert f_pr.layout.yaxis.range == None
assert f_orig.layout.yaxis.autorange == None
assert f_pr.layout.yaxis.autorange == None

f_orig.update_layout(yaxis_range=[100, 1000])
f_pr.update_layout(yaxis_range=[100, 1000])

assert list(f_orig.layout.yaxis.range) == [100, 1000]
assert list(f_pr.layout.yaxis.range) == [100, 1000]
assert f_orig.layout.yaxis.autorange == None
assert f_pr.layout.yaxis.autorange == None

# Before showing the figure, the f_pr contains the full original data (downsampled to 500 samples)
# Even after updating the axes ranges
check_data(f_pr)

if not_on_linux():
# TODO: eventually we should run this test on Windows & MacOS too
return

f_pr.stop_server()
proc = multiprocessing.Process(target=f_pr.show_dash, kwargs=dict(mode="external"))
proc.start()
try:
time.sleep(1)
driver.get(f"http://localhost:8050")
time.sleep(3)
# Get the data property from the front-end figure
el = driver.find_element(by=By.ID, value="resample-figure")
el = el.find_element(by=By.CLASS_NAME, value="js-plotly-plot")
f_pr_data = el.get_property("data")
f_pr_layout = el.get_property("layout")

# After showing the figure, the f_pr contains the data of the selected xrange (downsampled to 500 samples)
assert len(f_pr_data[0]["y"]) == 500
assert len(f_pr_data[0]["x"]) == 500
assert f_pr_data[0]["y"][0] >= 100 and f_pr_data[0]["y"][-1] <= 1000
assert f_pr_data[0]["x"][0] >= 100 and f_pr_data[0]["x"][-1] <= 1000
# Check the front-end layout
assert list(f_pr_layout["xaxis"]["range"]) == [100, 1000]
assert list(f_pr_layout["yaxis"]["range"]) == [100, 1000]
except Exception as e:
raise e
finally:
proc.terminate()
f_pr.stop_server()


def test_fr_update_layout_axes_range_no_update(driver):
nb_datapoints = 2_000
n_shown = 20_000 # > nb. datapoints

# Checks whether the update_layout method works as expected
f_orig = go.Figure().add_scatter(y=np.arange(nb_datapoints))
f_pr = FigureResampler(default_n_shown_samples=n_shown).add_scatter(
y=np.arange(nb_datapoints)
)

def check_data(fr: FigureResampler, min_v=0, max_v=nb_datapoints-1):
# closure for n_shown and nb_datapoints
assert len(fr.data[0]["y"]) == min(n_shown, nb_datapoints)
assert len(fr.data[0]["x"]) == min(n_shown, nb_datapoints)
assert fr.data[0]["y"][0] == min_v
assert fr.data[0]["y"][-1] == max_v
assert fr.data[0]["x"][0] == min_v
assert fr.data[0]["x"][-1] == max_v

# Check the initial data
check_data(f_pr)

# The xaxis (auto)range should be the same for both figures

assert f_orig.layout.xaxis.range == None
assert f_pr.layout.xaxis.range == None
assert f_orig.layout.xaxis.autorange == None
assert f_pr.layout.xaxis.autorange == None

f_orig.update_layout(xaxis_range=[100, 1000])
f_pr.update_layout(xaxis_range=[100, 1000])

assert f_orig.layout.xaxis.range == (100, 1000)
assert f_pr.layout.xaxis.range == (100, 1000)
assert f_orig.layout.xaxis.autorange == None
assert f_pr.layout.xaxis.autorange == None

# The yaxis (auto)range should be the same for both figures

assert f_orig.layout.yaxis.range == None
assert f_pr.layout.yaxis.range == None
assert f_orig.layout.yaxis.autorange == None
assert f_pr.layout.yaxis.autorange == None

f_orig.update_layout(yaxis_range=[100, 1000])
f_pr.update_layout(yaxis_range=[100, 1000])

assert list(f_orig.layout.yaxis.range) == [100, 1000]
assert list(f_pr.layout.yaxis.range) == [100, 1000]
assert f_orig.layout.yaxis.autorange == None
assert f_pr.layout.yaxis.autorange == None

# Before showing the figure, the f_pr contains the full original data (not downsampled)
# Even after updating the axes ranges
check_data(f_pr)

if not_on_linux():
# TODO: eventually we should run this test on Windows & MacOS too
return

f_pr.stop_server()
proc = multiprocessing.Process(target=f_pr.show_dash, kwargs=dict(mode="external"))
proc.start()
try:
time.sleep(1)
driver.get(f"http://localhost:8050")
time.sleep(3)
# Get the data & layout property from the front-end figure
el = driver.find_element(by=By.ID, value="resample-figure")
el = el.find_element(by=By.CLASS_NAME, value="js-plotly-plot")
f_pr_data = el.get_property("data")
f_pr_layout = el.get_property("layout")

# After showing the figure, the f_pr contains the original data (not downsampled), but shown xrange is [100, 1000]
assert len(f_pr_data[0]["y"]) == 2_000
assert len(f_pr_data[0]["x"]) == 2_000
assert f_pr.data[0]["y"][0] == 0
assert f_pr.data[0]["y"][-1] == 1999
assert f_pr.data[0]["x"][0] == 0
assert f_pr.data[0]["x"][-1] == 1999
# Check the front-end layout
assert list(f_pr_layout["xaxis"]["range"]) == [100, 1000]
assert list(f_pr_layout["yaxis"]["range"]) == [100, 1000]
except Exception as e:
raise e
finally:
proc.terminate()
f_pr.stop_server()


def test_fr_copy_grid():
# Checks whether _grid_ref and _grid_str are correctly maintained

Expand Down
28 changes: 28 additions & 0 deletions tests/test_figure_resampler_selenium.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,3 +657,31 @@ def test_multi_trace_go_figure(driver, multi_trace_go_figure):
raise e
finally:
proc.terminate()


def test_multi_trace_go_figure_updated_xrange(driver, multi_trace_go_figure):
# This test checks that the xaxis range is updated when the xaxis range is set
# Notet hat this test just hits the .show_dash() method
from pytest_cov.embed import cleanup_on_sigterm

cleanup_on_sigterm()

multi_trace_go_figure.update_xaxes(range=[100, 200_000])

port = 9038
proc = multiprocessing.Process(
target=multi_trace_go_figure.show_dash,
kwargs=dict(mode="external", port=port),
)
proc.start()
try:
# Just hit the code of the .show_dash method when an x-range is set
time.sleep(1)
fr = FigureResamplerGUITests(driver, port=port)
time.sleep(1)
fr.go_to_page()

except Exception as e:
raise e
finally:
proc.terminate()
Loading