diff --git a/doc/source/reference/style.rst b/doc/source/reference/style.rst index e80dc1b57ff80..3a8d912fa6ffe 100644 --- a/doc/source/reference/style.rst +++ b/doc/source/reference/style.rst @@ -39,6 +39,8 @@ Style application Styler.set_td_classes Styler.set_table_styles Styler.set_table_attributes + Styler.set_tooltips + Styler.set_tooltips_class Styler.set_caption Styler.set_properties Styler.set_uuid diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index ab00b749d5725..6a85bfd852e19 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -52,6 +52,7 @@ Other enhancements - :meth:`DataFrame.apply` can now accept NumPy unary operators as strings, e.g. ``df.apply("sqrt")``, which was already the case for :meth:`Series.apply` (:issue:`39116`) - :meth:`DataFrame.apply` can now accept non-callable DataFrame properties as strings, e.g. ``df.apply("size")``, which was already the case for :meth:`Series.apply` (:issue:`39116`) - :meth:`Series.apply` can now accept list-like or dictionary-like arguments that aren't lists or dictionaries, e.g. ``ser.apply(np.array(["sum", "mean"]))``, which was already the case for :meth:`DataFrame.apply` (:issue:`39140`) +- :meth:`.Styler.set_tooltips` allows on hover tooltips to be added to styled HTML dataframes. .. --------------------------------------------------------------------------- diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index b6c1336ede597..49eb579f9bd99 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -182,6 +182,8 @@ def __init__( self.cell_ids = cell_ids self.na_rep = na_rep + self.tooltips: Optional[_Tooltips] = None + self.cell_context: Dict[str, Any] = {} # display_funcs maps (row, col) -> formatting function @@ -205,6 +207,117 @@ def _repr_html_(self) -> str: """ return self.render() + def _init_tooltips(self): + """ + Checks parameters compatible with tooltips and creates instance if necessary + """ + if not self.cell_ids: + # tooltips not optimised for individual cell check. requires reasonable + # redesign and more extensive code for a feature that might be rarely used. + raise NotImplementedError( + "Tooltips can only render with 'cell_ids' is True." + ) + if self.tooltips is None: + self.tooltips = _Tooltips() + + def set_tooltips(self, ttips: DataFrame) -> "Styler": + """ + Add string based tooltips that will appear in the `Styler` HTML result. These + tooltips are applicable only to`` elements. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + ttips : DataFrame + DataFrame containing strings that will be translated to tooltips, mapped + by identical column and index values that must exist on the underlying + `Styler` data. None, NaN values, and empty strings will be ignored and + not affect the rendered HTML. + + Returns + ------- + self : Styler + + Notes + ----- + Tooltips are created by adding `` to each data cell + and then manipulating the table level CSS to attach pseudo hover and pseudo + after selectors to produce the required the results. For styling control + see `:meth:Styler.set_tooltips_class`. + Tooltips are not designed to be efficient, and can add large amounts of + additional HTML for larger tables, since they also require that `cell_ids` + is forced to `True`. + + Examples + -------- + >>> df = pd.DataFrame(data=[[0, 1], [2, 3]]) + >>> ttips = pd.DataFrame( + ... data=[["Min", ""], [np.nan, "Max"]], columns=df.columns, index=df.index + ... ) + >>> s = df.style.set_tooltips(ttips).render() + """ + self._init_tooltips() + assert self.tooltips is not None # mypy requiremen + self.tooltips.tt_data = ttips + return self + + def set_tooltips_class( + self, + name: Optional[str] = None, + properties: Optional[Sequence[Tuple[str, Union[str, int, float]]]] = None, + ) -> "Styler": + """ + Manually configure the name and/or properties of the class for + creating tooltips on hover. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + name : str, default None + Name of the tooltip class used in CSS, should conform to HTML standards. + properties : list-like, default None + List of (attr, value) tuples; see example. + + Returns + ------- + self : Styler + + Notes + ----- + If arguments are `None` will not make any changes to the underlying ``Tooltips`` + existing values. + + The default properties for the tooltip CSS class are: + + - visibility: hidden + - position: absolute + - z-index: 1 + - background-color: black + - color: white + - transform: translate(-20px, -20px) + + The property ('visibility', 'hidden') is a key prerequisite to the hover + functionality, and should always be included in any manual properties + specification. + + Examples + -------- + >>> df = pd.DataFrame(np.random.randn(10, 4)) + >>> df.style.set_tooltips_class(name='tt-add', properties=[ + ... ('visibility', 'hidden'), + ... ('position', 'absolute'), + ... ('z-index', 1)]) + """ + self._init_tooltips() + assert self.tooltips is not None # mypy requirement + if properties: + self.tooltips.class_properties = properties + if name: + self.tooltips.class_name = name + return self + @doc( NDFrame.to_excel, klass="Styler", @@ -434,7 +547,7 @@ def format_attr(pair): else: table_attr += ' class="tex2jax_ignore"' - return { + d = { "head": head, "cellstyle": cellstyle, "body": body, @@ -444,6 +557,10 @@ def format_attr(pair): "caption": caption, "table_attributes": table_attr, } + if self.tooltips: + d = self.tooltips._translate(self.data, self.uuid, d) + + return d def format(self, formatter, subset=None, na_rep: Optional[str] = None) -> "Styler": """ @@ -689,6 +806,7 @@ def clear(self) -> None: Returns None. """ self.ctx.clear() + self.tooltips = None self.cell_context = {} self._todo = [] @@ -1658,6 +1776,179 @@ def pipe(self, func: Callable, *args, **kwargs): return com.pipe(self, func, *args, **kwargs) +class _Tooltips: + """ + An extension to ``Styler`` that allows for and manipulates tooltips on hover + of table data-cells in the HTML result. + + Parameters + ---------- + css_name: str, default "pd-t" + Name of the CSS class that controls visualisation of tooltips. + css_props: list-like, default; see Notes + List of (attr, value) tuples defining properties of the CSS class. + tooltips: DataFrame, default empty + DataFrame of strings aligned with underlying ``Styler`` data for tooltip + display. + + Notes + ----- + The default properties for the tooltip CSS class are: + + - visibility: hidden + - position: absolute + - z-index: 1 + - background-color: black + - color: white + - transform: translate(-20px, -20px) + + Hidden visibility is a key prerequisite to the hover functionality, and should + always be included in any manual properties specification. + """ + + def __init__( + self, + css_props: Sequence[Tuple[str, Union[str, int, float]]] = [ + ("visibility", "hidden"), + ("position", "absolute"), + ("z-index", 1), + ("background-color", "black"), + ("color", "white"), + ("transform", "translate(-20px, -20px)"), + ], + css_name: str = "pd-t", + tooltips: DataFrame = DataFrame(), + ): + self.class_name = css_name + self.class_properties = css_props + self.tt_data = tooltips + self.table_styles: List[Dict[str, Union[str, List[Tuple[str, str]]]]] = [] + + @property + def _class_styles(self): + """ + Combine the ``_Tooltips`` CSS class name and CSS properties to the format + required to extend the underlying ``Styler`` `table_styles` to allow + tooltips to render in HTML. + + Returns + ------- + styles : List + """ + return [{"selector": f".{self.class_name}", "props": self.class_properties}] + + def _pseudo_css(self, uuid: str, name: str, row: int, col: int, text: str): + """ + For every table data-cell that has a valid tooltip (not None, NaN or + empty string) must create two pseudo CSS entries for the specific + element id which are added to overall table styles: + an on hover visibility change and a content change + dependent upon the user's chosen display string. + + For example: + [{"selector": "T__row1_col1:hover .pd-t", + "props": [("visibility", "visible")]}, + {"selector": "T__row1_col1 .pd-t::after", + "props": [("content", "Some Valid Text String")]}] + + Parameters + ---------- + uuid: str + The uuid of the Styler instance + name: str + The css-name of the class used for styling tooltips + row : int + The row index of the specified tooltip string data + col : int + The col index of the specified tooltip string data + text : str + The textual content of the tooltip to be displayed in HTML. + + Returns + ------- + pseudo_css : List + """ + return [ + { + "selector": "#T_" + + uuid + + "row" + + str(row) + + "_col" + + str(col) + + f":hover .{name}", + "props": [("visibility", "visible")], + }, + { + "selector": "#T_" + + uuid + + "row" + + str(row) + + "_col" + + str(col) + + f" .{name}::after", + "props": [("content", f'"{text}"')], + }, + ] + + def _translate(self, styler_data: FrameOrSeriesUnion, uuid: str, d: Dict): + """ + Mutate the render dictionary to allow for tooltips: + + - Add `` HTML element to each data cells `display_value`. Ignores + headers. + - Add table level CSS styles to control pseudo classes. + + Parameters + ---------- + styler_data : DataFrame + Underlying ``Styler`` DataFrame used for reindexing. + uuid : str + The underlying ``Styler`` uuid for CSS id. + d : dict + The dictionary prior to final render + + Returns + ------- + render_dict : Dict + """ + self.tt_data = ( + self.tt_data.reindex_like(styler_data) + .dropna(how="all", axis=0) + .dropna(how="all", axis=1) + ) + if self.tt_data.empty: + return d + + name = self.class_name + + mask = (self.tt_data.isna()) | (self.tt_data.eq("")) # empty string = no ttip + self.table_styles = [ + style + for sublist in [ + self._pseudo_css(uuid, name, i, j, str(self.tt_data.iloc[i, j])) + for i in range(len(self.tt_data.index)) + for j in range(len(self.tt_data.columns)) + if not mask.iloc[i, j] + ] + for style in sublist + ] + + if self.table_styles: + # add span class to every cell only if at least 1 non-empty tooltip + for row in d["body"]: + for item in row: + if item["type"] == "td": + item["display_value"] = ( + str(item["display_value"]) + + f'' + ) + d["table_styles"].extend(self._class_styles) + d["table_styles"].extend(self.table_styles) + + return d + + def _is_visible(idx_row, idx_col, lengths) -> bool: """ Index -> {(idx_row, idx_col): bool}). diff --git a/pandas/tests/io/formats/test_style.py b/pandas/tests/io/formats/test_style.py index 0bb422658df25..c61d81d565459 100644 --- a/pandas/tests/io/formats/test_style.py +++ b/pandas/tests/io/formats/test_style.py @@ -1769,6 +1769,74 @@ def test_uuid_len_raises(self, len_): with pytest.raises(TypeError, match=msg): Styler(df, uuid_len=len_, cell_ids=False).render() + @pytest.mark.parametrize( + "ttips", + [ + DataFrame( + data=[["Min", "Max"], [np.nan, ""]], + columns=["A", "B"], + index=["a", "b"], + ), + DataFrame(data=[["Max", "Min"]], columns=["B", "A"], index=["a"]), + DataFrame( + data=[["Min", "Max", None]], columns=["A", "B", "C"], index=["a"] + ), + ], + ) + def test_tooltip_render(self, ttips): + # GH 21266 + df = DataFrame(data=[[0, 3], [1, 2]], columns=["A", "B"], index=["a", "b"]) + s = Styler(df, uuid_len=0).set_tooltips(ttips).render() + + # test tooltip table level class + assert "#T__ .pd-t {\n visibility: hidden;\n" in s + + # test 'Min' tooltip added + assert ( + "#T__ #T__row0_col0:hover .pd-t {\n visibility: visible;\n } " + + ' #T__ #T__row0_col0 .pd-t::after {\n content: "Min";\n }' + in s + ) + assert ( + '0' + + "" + in s + ) + + # test 'Max' tooltip added + assert ( + "#T__ #T__row0_col1:hover .pd-t {\n visibility: visible;\n } " + + ' #T__ #T__row0_col1 .pd-t::after {\n content: "Max";\n }' + in s + ) + assert ( + '3' + + "" + in s + ) + + def test_tooltip_ignored(self): + # GH 21266 + df = DataFrame(data=[[0, 1], [2, 3]]) + s = Styler(df).set_tooltips_class("pd-t").render() # no set_tooltips() + assert '' in s + assert '' not in s + + def test_tooltip_class(self): + # GH 21266 + df = DataFrame(data=[[0, 1], [2, 3]]) + s = ( + Styler(df, uuid_len=0) + .set_tooltips(DataFrame([["tooltip"]])) + .set_tooltips_class(name="other-class", properties=[("color", "green")]) + .render() + ) + assert "#T__ .other-class {\n color: green;\n" in s + assert ( + '#T__ #T__row0_col0 .other-class::after {\n content: "tooltip";\n' + in s + ) + @td.skip_if_no_mpl class TestStylerMatplotlibDep: