diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b05a9094f..ab4811baae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## Dev version + +### Added + +- The `hover_data` parameter of `px` functions can now be a dictionary. This + makes it possible to skip hover information for some arguments or to change + the formatting of hover informatiom [#2377](https://github.com/plotly/plotly.py/pull/2377). + ## [4.6] - 2020-03-31 ### Updated diff --git a/doc/python/hover-text-and-formatting.md b/doc/python/hover-text-and-formatting.md index 9331a4831ee..eb5957cb71a 100644 --- a/doc/python/hover-text-and-formatting.md +++ b/doc/python/hover-text-and-formatting.md @@ -123,7 +123,7 @@ fig.show() ### Customizing Hover text with Plotly Express -Plotly Express functions automatically add all the data being plotted (x, y, color etc) to the hover label. Many Plotly Express functions also support configurable hover text. The `hover_data` argument accepts a list of column names to be added to the hover tooltip. The `hover_name` property controls which column is displayed in bold as the tooltip title. +Plotly Express functions automatically add all the data being plotted (x, y, color etc) to the hover label. Many Plotly Express functions also support configurable hover text. The `hover_data` argument accepts a list of column names to be added to the hover tooltip, or a dictionary for advanced formatting (see the next section). The `hover_name` property controls which column is displayed in bold as the tooltip title. Here is an example that creates a scatter plot using Plotly Express with custom hover data and a custom hover name. @@ -138,15 +138,42 @@ fig = px.scatter(df_2007, x="gdpPercap", y="lifeExp", log_x=True, fig.show() ``` +### Disabling or customizing hover of columns in plotly express + +`hover_data` can also be a dictionary. Its keys are existing columns of the `dataframe` argument, or new labels. For an existing column, the values can be +* `False` to remove the column from the hover data (for example, if one wishes to remove the column of the `x` argument) +* `True` to add a different column, with default formatting +* a formatting string starting with `:` for numbers [d3-format's syntax](https://github.com/d3/d3-3.x-api-reference/blob/master/Formatting.md#d3_forma), and `|` for dates in [d3-time-format's syntax](https://github.com/d3/d3-3.x-api-reference/blob/master/Time-Formatting.md#format), for example `:.3f`, `|%a`. + +It is also possible to pass new data as values of the `hover_data` dict, either as list-like data, or inside a tuple, which first element is one of the possible values described above for existing columns, and the second element correspond to the list-like data, for example `(True, [1, 2, 3])` or `(':.1f', [1.54, 2.345])`. + +These different cases are illustrated in the following example. + +```python +import plotly.express as px +import numpy as np +df = px.data.iris() +fig = px.scatter(df, x='petal_length', y='sepal_length', facet_col='species', color='species', + hover_data={'species':False, # remove species from hover data + 'sepal_length':':.2f', # customize hover for column of y attribute + 'petal_width':True, # add other column, default formatting + 'sepal_width':':.2f', # add other column, customized formatting + # data not in dataframe, default formatting + 'suppl_1': np.random.random(len(df)), + # data not in dataframe, customized formatting + 'suppl_2': (':.3f', np.random.random(len(df))) + }) +fig.update_layout(height=300) +fig.show() +``` + ### Customizing hover text with a hovertemplate -To customize the tooltip on your graph you can use [hovertemplate](https://plotly.com/python/reference/#pie-hovertemplate), which is a template string used for rendering the information that appear on hoverbox. +To customize the tooltip on your graph you can use the [hovertemplate](https://plotly.com/python/reference/#pie-hovertemplate) attribute of `graph_objects` tracces, which is a template string used for rendering the information that appear on hoverbox. This template string can include `variables` in %{variable} format, `numbers` in [d3-format's syntax](https://github.com/d3/d3-3.x-api-reference/blob/master/Formatting.md#d3_forma), and `date` in [d3-time-format's syntax](https://github.com/d3/d3-3.x-api-reference/blob/master/Time-Formatting.md#format). Hovertemplate customize the tooltip text vs. [texttemplate](https://plotly.com/python/reference/#pie-texttemplate) which customizes the text that appears on your chart.
Set the horizontal alignment of the text within tooltip with [hoverlabel.align](https://plotly.com/python/reference/#layout-hoverlabel-align). -Plotly Express automatically sets the `hovertemplate`, but you can set it manually when using `graph_objects`. - ```python import plotly.graph_objects as go @@ -187,6 +214,24 @@ fig = go.Figure(go.Pie( fig.show() ``` +### Modifying the hovertemplate of a plotly express figure + +`plotly.express` automatically sets the hovertemplate but you can modify it using the `update_traces` method of the generated figure. It helps to print the hovertemplate generated by `plotly.express` in order to be able to modify it. One can also revert to the default hover information of traces by setting the hovertemplate to `None`. + +```python +import plotly.express as px + +df_2007 = px.data.gapminder().query("year==2007") + +fig = px.scatter(df_2007, x="gdpPercap", y="lifeExp", log_x=True, color='continent' + ) +print("plotly express hovertemplate:", fig.data[0].hovertemplate) +fig.update_traces(hovertemplate='GDP: %{x}
Life Expectany: %{y}') # +fig.update_traces(hovertemplate=None, selector={'name':'Europe'}) # revert to default hover +print("user_defined hovertemplate:", fig.data[0].hovertemplate) +fig.show() +``` + ### Advanced Hover Template The following example shows how to format hover template. [Here](https://plotly.com/python/v3/hover-text-and-formatting/#dash-example) is an example to see how to format hovertemplate in Dash. diff --git a/packages/python/plotly/plotly/express/_core.py b/packages/python/plotly/plotly/express/_core.py index 613920d05fa..89b4ffce5e3 100644 --- a/packages/python/plotly/plotly/express/_core.py +++ b/packages/python/plotly/plotly/express/_core.py @@ -290,7 +290,10 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref): go.Histogram2d, go.Histogram2dContour, ]: + hover_is_dict = isinstance(attr_value, dict) for col in attr_value: + if hover_is_dict and not attr_value[col]: + continue try: position = args["custom_data"].index(col) except (ValueError, AttributeError, KeyError): @@ -387,7 +390,20 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref): go.Parcoords, go.Parcats, ]: - hover_lines = [k + "=" + v for k, v in mapping_labels.items()] + # Modify mapping_labels according to hover_data keys + # if hover_data is a dict + mapping_labels_copy = OrderedDict(mapping_labels) + if args["hover_data"] and isinstance(args["hover_data"], dict): + for k, v in mapping_labels.items(): + if k in args["hover_data"]: + if args["hover_data"][k][0]: + if isinstance(args["hover_data"][k][0], str): + mapping_labels_copy[k] = v.replace( + "}", "%s}" % args["hover_data"][k][0] + ) + else: + _ = mapping_labels_copy.pop(k) + hover_lines = [k + "=" + v for k, v in mapping_labels_copy.items()] trace_patch["hovertemplate"] = hover_header + "
".join(hover_lines) trace_patch["hovertemplate"] += "" return trace_patch, fit_results @@ -869,6 +885,22 @@ def _get_reserved_col_names(args, attrables, array_attrables): return reserved_names +def _isinstance_listlike(x): + """Returns True if x is an iterable which can be transformed into a pandas Series, + False for the other types of possible values of a `hover_data` dict. + A tuple of length 2 is a special case corresponding to a (format, data) tuple. + """ + if ( + isinstance(x, str) + or (isinstance(x, tuple) and len(x) == 2) + or isinstance(x, bool) + or x is None + ): + return False + else: + return True + + def build_dataframe(args, attrables, array_attrables): """ Constructs a dataframe and modifies `args` in-place. @@ -890,7 +922,7 @@ def build_dataframe(args, attrables, array_attrables): for field in args: if field in array_attrables and args[field] is not None: args[field] = ( - dict(args[field]) + OrderedDict(args[field]) if isinstance(args[field], dict) else list(args[field]) ) @@ -919,6 +951,19 @@ def build_dataframe(args, attrables, array_attrables): else: df_output[df_input.columns] = df_input[df_input.columns] + # hover_data is a dict + hover_data_is_dict = ( + "hover_data" in args + and args["hover_data"] + and isinstance(args["hover_data"], dict) + ) + # If dict, convert all values of hover_data to tuples to simplify processing + if hover_data_is_dict: + for k in args["hover_data"]: + if _isinstance_listlike(args["hover_data"][k]): + args["hover_data"][k] = (True, args["hover_data"][k]) + if not isinstance(args["hover_data"][k], tuple): + args["hover_data"][k] = (args["hover_data"][k], None) # Loop over possible arguments for field_name in attrables: # Massaging variables @@ -954,6 +999,16 @@ def build_dataframe(args, attrables, array_attrables): if isinstance(argument, str) or isinstance( argument, int ): # just a column name given as str or int + + if ( + field_name == "hover_data" + and hover_data_is_dict + and args["hover_data"][str(argument)][1] is not None + ): + col_name = str(argument) + df_output[col_name] = args["hover_data"][col_name][1] + continue + if not df_provided: raise ValueError( "String or int arguments are only possible when a " @@ -1029,6 +1084,8 @@ def build_dataframe(args, attrables, array_attrables): # Finally, update argument with column name now that column exists if field_name not in array_attrables: args[field_name] = str(col_name) + elif isinstance(args[field_name], dict): + pass else: args[field_name][i] = str(col_name) diff --git a/packages/python/plotly/plotly/express/_doc.py b/packages/python/plotly/plotly/express/_doc.py index 4f17af74756..05a5b214cad 100644 --- a/packages/python/plotly/plotly/express/_doc.py +++ b/packages/python/plotly/plotly/express/_doc.py @@ -180,8 +180,15 @@ "Values from this column or array_like appear in bold in the hover tooltip.", ], hover_data=[ - colref_list_type, - colref_list_desc, + "list of str or int, or Series or array-like, or dict", + "Either a list of names of columns in `data_frame`, or pandas Series,", + "or array_like objects", + "or a dict with column names as keys, with values True (for default formatting)", + "False (in order to remove this column from hover information),", + "or a formatting string, for example ':.3f' or '|%a'", + "or list-like data to appear in the hover tooltip", + "or tuples with a bool or formatting string as first element,", + "and list-like data to appear in hover as second element", "Values from these columns appear as extra data in the hover tooltip.", ], custom_data=[ diff --git a/packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py b/packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py new file mode 100644 index 00000000000..509f48d1991 --- /dev/null +++ b/packages/python/plotly/plotly/tests/test_core/test_px/test_px_hover.py @@ -0,0 +1,97 @@ +import plotly.express as px +import numpy as np +import pandas as pd +import pytest +import plotly.graph_objects as go +from collections import OrderedDict # an OrderedDict is needed for Python 2 + + +def test_skip_hover(): + df = px.data.iris() + fig = px.scatter( + df, + x="petal_length", + y="petal_width", + size="species_id", + hover_data={"petal_length": None, "petal_width": None}, + ) + assert fig.data[0].hovertemplate == "species_id=%{marker.size}" + + +def test_composite_hover(): + df = px.data.tips() + hover_dict = OrderedDict( + {"day": False, "time": False, "sex": True, "total_bill": ":.1f"} + ) + fig = px.scatter( + df, + x="tip", + y="total_bill", + color="day", + facet_row="time", + hover_data=hover_dict, + ) + for el in ["tip", "total_bill", "sex"]: + assert el in fig.data[0].hovertemplate + for el in ["day", "time"]: + assert el not in fig.data[0].hovertemplate + assert ":.1f" in fig.data[0].hovertemplate + + +def test_newdatain_hover_data(): + hover_dicts = [ + {"comment": ["a", "b", "c"]}, + {"comment": (1.234, 45.3455, 5666.234)}, + {"comment": [1.234, 45.3455, 5666.234]}, + {"comment": np.array([1.234, 45.3455, 5666.234])}, + {"comment": pd.Series([1.234, 45.3455, 5666.234])}, + ] + for hover_dict in hover_dicts: + fig = px.scatter(x=[1, 2, 3], y=[3, 4, 5], hover_data=hover_dict) + assert ( + fig.data[0].hovertemplate + == "x=%{x}
y=%{y}
comment=%{customdata[0]}" + ) + fig = px.scatter( + x=[1, 2, 3], y=[3, 4, 5], hover_data={"comment": (True, ["a", "b", "c"])} + ) + assert ( + fig.data[0].hovertemplate + == "x=%{x}
y=%{y}
comment=%{customdata[0]}" + ) + hover_dicts = [ + {"comment": (":.1f", (1.234, 45.3455, 5666.234))}, + {"comment": (":.1f", [1.234, 45.3455, 5666.234])}, + {"comment": (":.1f", np.array([1.234, 45.3455, 5666.234]))}, + {"comment": (":.1f", pd.Series([1.234, 45.3455, 5666.234]))}, + ] + for hover_dict in hover_dicts: + fig = px.scatter(x=[1, 2, 3], y=[3, 4, 5], hover_data=hover_dict,) + assert ( + fig.data[0].hovertemplate + == "x=%{x}
y=%{y}
comment=%{customdata[0]:.1f}" + ) + + +def test_fail_wrong_column(): + with pytest.raises(ValueError): + fig = px.scatter( + {"a": [1, 2], "b": [3, 4], "c": [2, 1]}, + x="a", + y="b", + hover_data={"d": True}, + ) + with pytest.raises(ValueError): + fig = px.scatter( + {"a": [1, 2], "b": [3, 4], "c": [2, 1]}, + x="a", + y="b", + hover_data={"d": ":.1f"}, + ) + with pytest.raises(ValueError): + fig = px.scatter( + {"a": [1, 2], "b": [3, 4], "c": [2, 1]}, + x="a", + y="b", + hover_data={"d": (True, [3, 4, 5])}, + )