Skip to content

ENH: Styler tooltips feature #35643

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 50 commits into from
Jan 17, 2021
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
3e83fc6
ENH: Styler can add tooltips directly from a DataFrame of strings
attack68 Aug 9, 2020
f8ffbbe
ENH: black fix
attack68 Aug 9, 2020
b92d255
ENH: test to ensure tooltip class ignored if set_tooltips not called.
attack68 Aug 9, 2020
cd6c5f2
ENH: pep8 line length
attack68 Aug 9, 2020
1f61e87
ENH: pep8 line length
attack68 Aug 9, 2020
af83833
ENH: test fixes: Linting and Typing
attack68 Aug 9, 2020
078c0bb
ENH: add in calling the render step!
attack68 Aug 9, 2020
eeddd19
ENH: mypy Type specification changes
attack68 Aug 9, 2020
6389811
improve tests, integrate into styler.clear()
attack68 Aug 18, 2020
e7557f5
add GH
attack68 Aug 18, 2020
64f789a
black fix
attack68 Aug 18, 2020
0b1e930
update docs
attack68 Aug 18, 2020
5ca86a5
update docs
attack68 Aug 18, 2020
f798852
improve test
attack68 Aug 20, 2020
1e782b7
whats new, typing, and list comps
attack68 Sep 6, 2020
ee952de
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 6, 2020
71c3cc1
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 9, 2020
2c36d4b
reindex instead of same shape. add test cases.
attack68 Sep 9, 2020
4aed112
black fix
attack68 Sep 9, 2020
49ecb50
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 13, 2020
3510ada
revert uuid
attack68 Sep 13, 2020
7f76e8e
revert docs on uuid
attack68 Sep 13, 2020
aa09003
tooltip in individual class architecture
attack68 Sep 14, 2020
f160297
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 14, 2020
776c434
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 17, 2020
c3c0aba
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 19, 2020
dd69ae6
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 20, 2020
4055e1b
fix test after recent merge master
attack68 Sep 20, 2020
6b27ec2
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 21, 2020
8fc8437
revert style.ipynb
attack68 Sep 22, 2020
a96acf8
annotate
attack68 Sep 22, 2020
50c2aa9
improve docs example
attack68 Sep 22, 2020
607cfe6
black pandas
attack68 Sep 22, 2020
ab079b0
Update pandas/io/formats/style.py
attack68 Sep 23, 2020
f6e066e
sequence type change
attack68 Sep 23, 2020
af6401a
doc notes for users
attack68 Sep 23, 2020
0e6b1a3
avoid var name clash
attack68 Sep 23, 2020
5829546
enumerate to range
attack68 Sep 25, 2020
eaa851e
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 25, 2020
9e8612f
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 28, 2020
5507c4f
pre commit errors
attack68 Sep 28, 2020
d0eefca
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Sep 29, 2020
c0b004e
merged into upstream/master
attack68 Jan 5, 2021
e4a5e20
requested changes jreback
attack68 Jan 5, 2021
849b1f4
requested changes jreback
attack68 Jan 5, 2021
950b3b1
requested changes jreback
attack68 Jan 5, 2021
efc7fca
pre commit fails fix
attack68 Jan 5, 2021
eb7fe68
minor requests
attack68 Jan 13, 2021
5a377b8
resolve duplicate typing
attack68 Jan 16, 2021
54c52da
Merge remote-tracking branch 'upstream/master' into styler_tooltips_f…
attack68 Jan 16, 2021
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
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.2.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Other enhancements
- :class:`Index` with object dtype supports division and multiplication (:issue:`34160`)
- :meth:`DataFrame.explode` and :meth:`Series.explode` now support exploding of sets (:issue:`35614`)
- ``Styler`` now allows direct CSS class name addition to individual data cells (:issue:`36159`)
- ``Styler`` now has the ability to add tooltips to styled dataframes from strings (:issue:`35643`)
- :meth:`Rolling.mean()` and :meth:`Rolling.sum()` use Kahan summation to calculate the mean to avoid numerical problems (:issue:`10319`, :issue:`11645`, :issue:`13254`, :issue:`32761`, :issue:`36031`)
- :meth:`DatetimeIndex.searchsorted`, :meth:`TimedeltaIndex.searchsorted`, :meth:`PeriodIndex.searchsorted`, and :meth:`Series.searchsorted` with datetimelike dtypes will now try to cast string arguments (listlike and scalar) to the matching datetimelike type (:issue:`36346`)

Expand Down
263 changes: 263 additions & 0 deletions pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def __init__(
self.cell_ids = cell_ids
self.na_rep = na_rep

self.tooltips = _Tooltips()

self.cell_context: Dict[str, Any] = {}

# display_funcs maps (row, col) -> formatting function
Expand All @@ -204,6 +206,101 @@ def _repr_html_(self) -> str:
"""
return self.render()

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`<td>` elements.

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 `<span class="pd-t"></span>` 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()
"""
if not self.cell_ids:
# tooltips not optimised for individual cell check.
raise NotImplementedError(
"Tooltips can only render with 'cell_ids' is True."
)
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.

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)])
"""
if properties:
self.tooltips.class_properties = properties
if name:
self.tooltips.class_name = name
return self

@doc(NDFrame.to_excel, klass="Styler")
def to_excel(
self,
Expand Down Expand Up @@ -616,6 +713,7 @@ def render(self, **kwargs) -> str:
self._compute()
# TODO: namespace all the pandas keys
d = self._translate()
d = self.tooltips._translate_tooltips(self.data, self.uuid, d)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is best before the update(), because it is a direct extension of the previous _translate() method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is very confusing here, why is this not part of _translate itself?

# filter out empty styles, every cell will have a class
# but the list of props may just be [['', '']].
# so we have the nested anys below
Expand Down Expand Up @@ -685,6 +783,7 @@ def clear(self) -> None:
Returns None.
"""
self.ctx.clear()
self.tooltips = _Tooltips()
self.cell_context = {}
self._todo = []

Expand Down Expand Up @@ -1589,6 +1688,170 @@ 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 _translate_tooltips(self, styler_data, uuid, d):
"""
Mutate the render dictionary to allow for tooltips:

- Add `<span>` 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
"""
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

def _pseudo_css(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
<td> 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
----------
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}"')],
},
]

mask = (self.tt_data.isna()) | (self.tt_data.eq("")) # empty string = no ttip
self.table_styles = [
style
for sublist in [
_pseudo_css(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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am puzzled by this here why this is involved at all

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the above table_styles will be an empty list if there are no tooltips to add, i.e if the mask was empty. In that case this if check provides some efficiency by avoiding adding a <span> html selector to every table cell.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok can you add that as a comment

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you update this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i added a comment line 1938?

for row in d["body"]:
for item in row:
if item["type"] == "td":
item["display_value"] = (
str(item["display_value"])
+ f'<span class="{self.class_name}"></span>'
)
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}).
Expand Down
68 changes: 68 additions & 0 deletions pandas/tests/io/formats/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,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 = pd.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 (
'<td id="T__row0_col0" class="data row0 col0" >0<span class="pd-t">'
+ "</span></td>"
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 (
'<td id="T__row0_col1" class="data row0 col1" >3<span class="pd-t">'
+ "</span></td>"
in s
)

def test_tooltip_ignored(self):
# GH 21266
df = pd.DataFrame(data=[[0, 1], [2, 3]])
s = Styler(df).set_tooltips_class("pd-t").render() # no set_tooltips()
assert '<style type="text/css" >\n</style>' in s
assert '<span class="pd-t"></span>' not in s

def test_tooltip_class(self):
# GH 21266
df = pd.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:
Expand Down