Skip to content

Commit

Permalink
Add table preview to data cards (#97)
Browse files Browse the repository at this point in the history
* Add ideas for table preview

* Use solution with fixed grid

* Add first working CSS table solution

* Extract header from table object

* Include CSS and JS file in sphinx extension

* Add ideas how to include table preview

* Continue work

* Do not include table preview as text

* Fix border of tables

* Add more comments

* Fix CSS

* Fix padding

* Add fix for vertical alignment

* Remove first table

* Reenable sphinx-audeering-theme

* Add more CSS fixes

* Enforce normal font size in nested table header

* Cleanup CSS file

* Finetune borders

* Improve docstring

* Mark clicked row

* Call nested table preview

* Adjust expected test results

* Add doctest test

* Try to fix doctests

* Cleanup template

* Fix tests

* Ensure text in table preview is valid

* Extend docstring of utils.parse_text()

* Extend docstring of CSS and JS file

* Add docstring to new test

* Manage tables_preview with load_tables

* Be more explicit in NA handling

* Make parse_text private static method
  • Loading branch information
hagenw authored Jul 25, 2024
1 parent 9ad60d4 commit 96c3beb
Show file tree
Hide file tree
Showing 11 changed files with 412 additions and 24 deletions.
90 changes: 85 additions & 5 deletions audbcards/core/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect
import os
import pickle
import re
import typing

import jinja2
Expand All @@ -13,13 +14,16 @@
import audeer
import audformat

from audbcards.core import utils
from audbcards.core.config import config
from audbcards.core.utils import format_schemes
from audbcards.core.utils import limit_presented_samples


class _Dataset:
_table_related_cached_properties = ["segment_durations", "segments"]
_table_related_cached_properties = [
"segment_durations",
"segments",
"tables_preview",
]
"""Cached properties relying on table data.
Most of the cached properties
Expand Down Expand Up @@ -163,6 +167,33 @@ def _load_pickled(path: str):
with open(path, "rb") as f:
return pickle.load(f)

@staticmethod
def _parse_text(text: str) -> str:
"""Remove unsupported characters and restrict length.
The text is stripped from HTML tags or newlines,
and limited to a maximum length of 100 characters.
Args:
text: input text
Returns:
parsed text
"""
# Missing text
if pd.isna(text):
return ""
# Remove newlines
text = text.replace("\n", "\\n")
# Remove HTML tags
text = re.sub("<[^<]+?>", "", text)
# Limit length
max_characters_per_entry = 100
if len(text) > max_characters_per_entry:
text = text[: max_characters_per_entry - 3] + "..."
return text

@staticmethod
def _save_pickled(obj, path: str):
"""Save object instance to path as pickle."""
Expand Down Expand Up @@ -413,7 +444,7 @@ def schemes_summary(self) -> str:
e.g. ``'speaker: [age, gender, language]'``.
"""
return format_schemes(self.header.schemes)
return utils.format_schemes(self.header.schemes)

@functools.cached_property
def schemes_table(self) -> typing.List[typing.List[str]]:
Expand Down Expand Up @@ -479,6 +510,51 @@ def tables(self) -> typing.List[str]:
tables = list(db)
return tables

@functools.cached_property
def tables_preview(self) -> typing.Dict[str, typing.List[typing.List[str]]]:
"""Table preview for each table of the dataset.
Shows the header
and the first 5 lines for each table
as a list of lists.
All table values are converted to strings,
stripped from HTML tags or newlines,
and limited to a maximum length of 100 characters.
Returns:
dictionary with table IDs as keys
and table previews as values
Examples:
>>> from tabulate import tabulate
>>> ds = Dataset("emodb", "1.4.1")
>>> preview = ds.tables_preview["speaker"]
>>> print(tabulate(preview, headers="firstrow", tablefmt="github"))
| speaker | age | gender | language |
|-----------|-------|----------|------------|
| 3 | 31 | male | deu |
| 8 | 34 | female | deu |
| 9 | 21 | female | deu |
| 10 | 32 | male | deu |
| 11 | 26 | male | deu |
"""
preview = {}
for table in list(self.header):
df = audb.load_table(
self.name,
table,
version=self.version,
verbose=False,
)
df = df.reset_index()
header = [df.columns.tolist()]
body = df.head(5).astype("string").values.tolist()
# Remove unwanted chars and limit length of each entry
body = [[self._parse_text(column) for column in row] for row in body]
preview[table] = header + body
return preview

@functools.cached_property
def tables_table(self) -> typing.List[str]:
"""Tables of the dataset."""
Expand Down Expand Up @@ -623,7 +699,7 @@ def _scheme_to_list(self, scheme_id):
label[:-1] + r"\_" if label.endswith("_") else label
for label in labels
]
labels = limit_presented_samples(
labels = utils.limit_presented_samples(
labels,
15,
replacement_text="[...]",
Expand Down Expand Up @@ -776,6 +852,10 @@ def _load_pickled(path: str):
ds = _Dataset._load_pickled(path)
return ds

@staticmethod
def _parse_text(text: str) -> str:
return _Dataset._parse_text(text)

@staticmethod
def _save_pickled(obj, path: str):
"""Save object instance to path as pickle."""
Expand Down
57 changes: 51 additions & 6 deletions audbcards/core/templates/datacard_tables.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,56 @@
Tables
^^^^^^

.. csv-table::
:header-rows: 1
:widths: 20, 10, 70
.. raw:: html

<table class="clickable docutils align-default">
{# Table header is given by first row #}
<thead>
<tr class="row-odd grid header">
{% for column in tables_table[0] %}
<th class="head"><p>{{ column }}</p></th>
{% endfor %}
</tr>
</thead>
{# Table body by remaining rows #}
<tbody>
{% for row in tables_table %}
{% if not loop.first %}
<tr onClick="toggleRow(this)" class="row-{{ loop.cycle('odd', 'even') }} clickable grid">
{% for column in row %}
<td><p>{{ column }}</p></td>
{% endfor %}
<td class="expanded-row-content hide-row">

{##### START TABLE PREVIEW #####}

<table class="docutils field-list align-default preview">
<thead>
<tr>
{% for column in tables_preview[row[0]][0] %}
<th class="head"><p>{{ column }}</p></th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in tables_preview[row[0]] %}
{% if not loop.first %}
<tr>
{% for column in row %}
<td><p>{{ column }}</p></td>
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>

{##### END TABLE PREVIEW #####}

</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

{% for row in tables_table %}
"{{ row|join('", "') }}"
{% endfor %}
{% endif %}
6 changes: 6 additions & 0 deletions audbcards/sphinx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@


__version__ = "0.1.0"
table_preview_css_file = audeer.path(audeer.script_dir(), "table-preview.css")
table_preview_js_file = audeer.path(audeer.script_dir(), "table-preview.js")


# ===== MAIN FUNCTION SPHINX EXTENSION ====================================
Expand Down Expand Up @@ -54,6 +56,10 @@ def builder_inited(app: sphinx.application.Sphinx):
# Read config values
sections = app.config.audbcards_datasets

# Add CSS and JS files for table preview feature
app.add_css_file(table_preview_css_file)
app.add_js_file(table_preview_js_file)

# Gather and build data cards for each requested section
for path, header, repositories, example in sections:
# Clear existing data cards
Expand Down
82 changes: 82 additions & 0 deletions audbcards/sphinx/table-preview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/* Expand rows in Tables table to show preview of each tables content.
/*
/* Implementation based on https://github.com/chhikaradi1993/Expandable-table-row
*/
.expanded-row-content {
font-size: 13px;
/* Add scroll bar if table preview is too big */
overflow: auto !important;
/* Let column appear as additional row in next line */
display: grid;
grid-column: 1/-1;
justify-content: flex-start;
border-left: none;
}
.hide-row {
display: None;
}
table.clickable {
/* Ensure we don't get double border lines */
border-bottom: none;
border-right: none;
/* Force to use full width */
width: 100%;
}
table.clickable td,
table.clickable th {
/* Ensure we don't get double border lines */
border-left: none;
border-top: none;
}
table.preview td {
/* Remove all borders inside preview table cells */
border-left: none;
border-top: none;
border-bottom: none;
}
table.clickable td:not(.expanded-row-content),
table.clickable th {
/* Allow to center cell copntent with `margin: auto` */
display: flex;
}
table.clickable td:not(.expanded-row-content) p,
table.clickable th p {
/* Verrtically center cell content */
margin: auto 0;
}
table.clickable td:not(.expanded-row-content) p:last-child,
table.clickable th p:last-child {
/* Verrtically center cell content for ReadTheDocs based themes*/
margin: auto 0 !important;
}
table.clickable td.expanded-row-content td,
table.clickable td.expanded-row-content th {
display: table-cell;
}
table.clickable tr.grid {
/* Fixed grid of 3 columns */
display: grid;
grid-template-columns: repeat(1, 1.1fr) 15% repeat(1, 1fr);
}
table.clickable tr.clickable {
/* Show pointer as cursor to highlight the row can be clicked */
cursor: pointer;
/* Overflow of table preview column */
justify-content: flex-start;
}
table.clickable tr.clicked td:not(.expanded-row-content) {
/* Remove bottom border on clicked row when preview is shown */
border-bottom: none;
}
table.preview {
/* Padding around table preview */
padding: 10px;
}
table.preview td {
/* Ensure minimal distance between columns */
padding-right: 0.3em;
}
table.preview th {
/* Use normal font in header row of preview table */
font-weight: normal !important;
}
17 changes: 17 additions & 0 deletions audbcards/sphinx/table-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Expand rows in Tables table to show preview of each tables content.
//
// Implementation based on https://github.com/chhikaradi1993/Expandable-table-row
//
const toggleRow = (row) => {
// Toggle visibility of table preview
row.getElementsByClassName('expanded-row-content')[0].classList.toggle('hide-row');
// Toggle clicked attribute on clicked table row.
// This can be used to adjust appearance of clicked table,
// e.g. remove bottom border
if (row.className.indexOf("clicked") === -1) {
row.classList.add("clicked");
} else {
row.classList.remove("clicked");
}
console.log(event);
}
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ classifiers = [
requires-python = '>=3.9'
dependencies = [
'audb >=1.7.0',
'audeer >=2.2.0',
'audplot >=1.4.6',
'jinja2',
'pandas >=2.1.0',
Expand Down Expand Up @@ -70,6 +71,10 @@ skip = './audbcards.egg-info,./build'
[tool.pytest.ini_options]
cache_dir = '.cache/pytest'
xfail_strict = true
addopts = '''
--doctest-modules
--ignore=audbcards/sphinx/
'''


# ----- ruff --------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
audb >=1.6.5 # for audb.Dependencies.__eq__()
audeer >=1.21.0
pytest
tabulate
Loading

0 comments on commit 96c3beb

Please sign in to comment.