From d1bf6dd99d6af16611492789b1e9a48c60b50f3a Mon Sep 17 00:00:00 2001 From: kb- Date: Wed, 25 Oct 2023 21:18:53 +0200 Subject: [PATCH] Fix to handle log axis This is a fix for Plotly bug https://github.com/plotly/plotly.py/issues/2580 --- README.md | 4 + dash_tooltip/__init__.py | 10 ++- dash_tooltip/utils.py | 62 +++++++++++++--- dash_tooltip_demo.py | 110 ++++++++++++++++++++++++++++ tests/test_8__display_click_data.py | 4 +- 5 files changed, 178 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 59fa362..2c2eaea 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ tooltip(app10, graph_ids=["graph-id"], template=template, debug=True, **custom_c For more examples, refer to the provided `dash_tooltip_demo.py` or its Jupyter counterpart `dash_tooltip_demo.ipynb`. +## Handling Log Axes + +Due to a long-standing bug in Plotly (see [Plotly Issue #2580](https://github.com/plotly/plotly.py/issues/2580)), annotations (`fig.add_annotation`) may not be placed correctly on log-scaled axes. The `dash_tooltip` module provides an option to automatically correct the tooltip placement on log-scaled axes via the `apply_log_fix` argument in the `tooltip` function. By default, `apply_log_fix` is set to `True` to enable the fix. + ## Debugging If you encounter any issues or unexpected behaviors, enable the debug mode by setting the `debug` argument of the `tooltip` function to `True`. The log outputs will be written to `dash_app.log` in the directory where your script or application is located. diff --git a/dash_tooltip/__init__.py b/dash_tooltip/__init__.py index 2422ca1..91eea46 100644 --- a/dash_tooltip/__init__.py +++ b/dash_tooltip/__init__.py @@ -37,6 +37,7 @@ def tooltip( style: Dict[Any, Any] = DEFAULT_ANNOTATION_CONFIG, template: str = DEFAULT_TEMPLATE, graph_ids: Optional[List[str]] = None, + apply_log_fix: bool = True, debug: bool = False, ) -> None: """ @@ -53,6 +54,11 @@ def tooltip( - graph_ids (list, optional): List of graph component IDs for the tooltip functionality. If None, function will try to find graph IDs automatically. + - apply_log_fix (bool): If True, applies a logarithmic transformation fix for + log axes due to a long-standing Plotly bug. + More details can be found at: + https://github.com/plotly/plotly.py/issues/2580 + Default is True. - debug (bool): If True, debug information will be written to a log file (tooltip.log). @@ -82,7 +88,9 @@ def tooltip( def display_click_data( clickData: Dict[str, Any], figure: go.Figure ) -> go.Figure: - return _display_click_data(clickData, figure, app, template, style, debug) + return _display_click_data( + clickData, figure, app, template, style, apply_log_fix, debug + ) dbg_str = "console.log(relayoutData);" diff --git a/dash_tooltip/utils.py b/dash_tooltip/utils.py index 78de3d7..5909528 100644 --- a/dash_tooltip/utils.py +++ b/dash_tooltip/utils.py @@ -1,5 +1,6 @@ import json import logging +import math import re from typing import Any, Dict, List, Optional, Union @@ -126,16 +127,41 @@ def deep_merge_dicts(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, return dict1 +def get_axis_type(fig: go.Figure, axis: str) -> str: + """ + Determines the type of the specified axis in a Plotly figure. + + Parameters: + fig (Union[Figure, dict]): The Plotly figure object or dictionary. + axis (str): The axis identifier, e.g., 'x', 'y', 'x2', 'y2', etc. + + Returns: + str: The type of the specified axis, e.g., 'linear' or 'log'. + """ + # Check if the axis identifier has a number at the end + axis_number = axis[-1] + if axis_number.isnumeric(): + axis_key = axis[:-1] + "axis" + axis_number + else: + axis_key = axis + "axis" + + axis_type = fig.layout[axis_key].type + return axis_type + + def _display_click_data( clickData: Dict[str, Any], figure: Union[go.Figure, Dict[str, Any]], # Allow both go.Figure and dictionary app: dash.Dash, template: str, config: Dict[Any, Any], - debug: bool, + apply_log_fix: bool = True, + debug: bool = False, ) -> go.Figure: """Displays the tooltip on the graph when a data point is clicked.""" + xaxis, yaxis = "x", "y" # Default values + # Check if figure is a dictionary if isinstance(figure, dict): # Extract data and layout from the figure dictionary @@ -164,16 +190,32 @@ def _display_click_data( x_val = point["x"] y_val = point["y"] - # Extract the clicked axis information from the curve data - if "xaxis" in figure["data"][point["curveNumber"]]: - xaxis = figure["data"][point["curveNumber"]]["xaxis"] - else: - xaxis = "x" + try: + # Extract the clicked axis information from the curve data + if "xaxis" in figure["data"][point["curveNumber"]]: + xaxis = figure["data"][point["curveNumber"]]["xaxis"] + else: + xaxis = "x" - if "yaxis" in figure["data"][point["curveNumber"]]: - yaxis = figure["data"][point["curveNumber"]]["yaxis"] - else: - yaxis = "y" + if "yaxis" in figure["data"][point["curveNumber"]]: + yaxis = figure["data"][point["curveNumber"]]["yaxis"] + else: + yaxis = "y" + + # If the x-axis is logarithmic, adjust `x_val` + # This is a fix for longstanding Plotly bug + # https://github.com/plotly/plotly.py/issues/2580 + if apply_log_fix and get_axis_type(fig, xaxis) == "log": + x_val = math.log10(x_val) + + # If the y-axis is logarithmic, adjust `y_val` + if apply_log_fix and get_axis_type(fig, yaxis) == "log": + y_val = math.log10(y_val) + + except KeyError as e: + logger.error(f"Error: {e}, key not found") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") if debug: logger.debug( diff --git a/dash_tooltip_demo.py b/dash_tooltip_demo.py index f29d95c..c4182ea 100644 --- a/dash_tooltip_demo.py +++ b/dash_tooltip_demo.py @@ -973,3 +973,113 @@ def interactive_plot(fig, graphid, template): app13.run(debug=True, port=8093) # %% jupyter={"source_hidden": true} +# ---- Test 14: log axis ---- + +# Generate exponential data +x_data = np.linspace(1, 100, 100) # Generating 100 points from 1 to 100 + +ts_data = {} +for i in range(4): + ts_data[f"ts{i + 1}_1"] = np.exp( + 0.05 * x_data + ) # Exponential growth with a base of exp(1) + ts_data[f"ts{i + 1}_2"] = np.exp( + 0.03 * x_data + ) # Slower exponential growth with a base of exp(1) + + +# Create 2x2 subplots +fig14 = FigureResampler( + make_subplots( + rows=2, + cols=2, + shared_xaxes=True, + subplot_titles=("Plot 1", "Plot 2", "Plot 3", "Plot 4"), + ) +) + +# Add data to subplots +for i in range(1, 3): + for j in range(1, 3): + # noinspection PyTypeChecker + fig14.add_trace( + go.Scatter( + x=x_data, + y=ts_data[f"ts{(i - 1) * 2 + j}_1"], + name=f"Trace 1, Plot {(i - 1) * 2 + j}", + ), + row=i, + col=j, + ) + # noinspection PyTypeChecker + fig14.add_trace( + go.Scatter( + x=x_data, + y=ts_data[f"ts{(i - 1) * 2 + j}_2"], + name=f"Trace 2, Plot {(i - 1) * 2 + j}", + ), + row=i, + col=j, + ) + +# Set log axis +fig14.update_layout( + yaxis1=dict(type="log"), + xaxis1=dict(type="log"), + yaxis2=dict(type="log"), + xaxis3=dict(type="log"), +) + +# Modify each trace to include the desired hovertemplate +template14 = ( + "x: %{x}
y: %{y:0.2f}
ID: %{pointNumber}
" + "name: %{customdata[0]}
unit: %{customdata[1]}" +) +for i, trace in enumerate(fig14.data): + trace.customdata = np.column_stack( + ( + np.repeat( + trace.name, len(x_data) + ), # Updated from len(date_rng) to len(x_data) + np.repeat( + "#{}".format(i + 1), len(x_data) + ), # Updated from len(date_rng) to len(x_data) + ) + ) + trace.hovertemplate = template14 + +# Construct app & its layout +app14 = Dash(__name__) + +app14.layout = html.Div( + [ + dcc.Graph( + id="graph-id14", + figure=fig14, + config={ + "editable": True, + "edits": {"shapePosition": True, "annotationPosition": True}, + }, + ), + TraceUpdater(id="trace-updater14", gdID="graph-id14"), + ] +) + +# Add tooltip functionality +tooltip( + app14, + graph_ids=["graph-id14"], + style={"font": {"size": 10}}, + template=template14, + debug=True, +) + +# Update layout title +fig14.update_layout(title_text="2x2 Subplots with 2 Traces Each", height=800) + +# Register the callback with FigureResampler +fig14.register_update_graph_callback(app14, "graph-id14", "trace-updater14") + +# Code to run the Dash app +# (commented out for now, but can be used in a local environment) +app14.run(debug=True, port=8094, jupyter_height=800) diff --git a/tests/test_8__display_click_data.py b/tests/test_8__display_click_data.py index 5d00db1..ab39df9 100644 --- a/tests/test_8__display_click_data.py +++ b/tests/test_8__display_click_data.py @@ -64,6 +64,7 @@ def test_display_click_data_no_click() -> None: SAMPLE_APP, DEFAULT_TEMPLATE, DEFAULT_ANNOTATION_CONFIG, + True, False, ) @@ -88,6 +89,7 @@ def test_display_click_data_with_click() -> None: SAMPLE_APP, DEFAULT_TEMPLATE, DEFAULT_ANNOTATION_CONFIG, + True, False, ) assert len(fig_after.layout.annotations) == 1, "One annotation should be added." @@ -113,7 +115,7 @@ def test_display_click_data_custom_style() -> None: ] } fig_after = _display_click_data( - click_data, fig_before, SAMPLE_APP, DEFAULT_TEMPLATE, custom_style, False + click_data, fig_before, SAMPLE_APP, DEFAULT_TEMPLATE, custom_style, True, False ) annotation = fig_after.layout.annotations[0] assert annotation.arrowcolor == "red", "Arrow color mismatch."