Skip to content

Commit

Permalink
Fix to handle log axis
Browse files Browse the repository at this point in the history
This is a fix for Plotly bug plotly/plotly.py#2580
  • Loading branch information
kb- committed Oct 25, 2023
1 parent 8c1671f commit d1bf6dd
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 12 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion dash_tooltip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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).
Expand Down Expand Up @@ -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);"

Expand Down
62 changes: 52 additions & 10 deletions dash_tooltip/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import logging
import math
import re
from typing import Any, Dict, List, Optional, Union

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
110 changes: 110 additions & 0 deletions dash_tooltip_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}<br>y: %{y:0.2f}<br>ID: %{pointNumber}<br>"
"name: %{customdata[0]}<br>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)
4 changes: 3 additions & 1 deletion tests/test_8__display_click_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def test_display_click_data_no_click() -> None:
SAMPLE_APP,
DEFAULT_TEMPLATE,
DEFAULT_ANNOTATION_CONFIG,
True,
False,
)

Expand All @@ -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."
Expand All @@ -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."
Expand Down

0 comments on commit d1bf6dd

Please sign in to comment.