-
+### Histogram
+
+``` python
+import matplotlib.pyplot as plt
+import numpy as np
+
+from plotjs import PlotJS
+
+x = np.random.normal(loc=0.0, scale=1.0, size=100)
+
+fig, ax = plt.subplots()
+counts, bins, _ = ax.hist(x, color="#2a9d8f")
+
+labels = [
+ f"Lower bound: {lo:.2f}
Upper bound: {hi:.2f}
n: {int(n)}"
+ for lo, hi, n in zip(bins[:-1], bins[1:], counts)
+]
+
+PlotJS(fig=fig).add_tooltip(labels=labels).save("iframes/quickstart12.html")
+```
+
+
+
+
+
+### Boxplot
+
+``` python
+from plotjs import PlotJS
+import seaborn as sns
+import matplotlib.pyplot as plt
+
+planets = sns.load_dataset("planets")
+
+fig, ax = plt.subplots(figsize=(7, 6))
+ax.set_xscale("log")
+sns.boxplot(
+ planets,
+ x="distance",
+ y="method",
+ hue="method",
+ whis=[0, 100],
+ width=0.6,
+ palette="vlag",
+)
+sns.stripplot(planets, x="distance", y="method", size=4, color=".3", alpha=0.4)
+ax.xaxis.grid(True)
+ax.set(ylabel="")
+sns.despine()
+
+(
+ PlotJS(bbox_inches="tight")
+ .add_tooltip(labels=planets["method"].unique(), on="bar", hover_nearest=True)
+ .save("iframes/quickstart13.html")
+)
+```
+
+
+
+
+
### Connect legend and plot elements:
- Scatter plot
@@ -267,8 +347,6 @@ import matplotlib.pyplot as plt
import numpy as np
from plotjs import PlotJS
-np.random.seed(0)
-
length = 500
walk1 = np.cumsum(np.random.choice([-1, 1], size=length))
walk2 = np.cumsum(np.random.choice([-1, 1], size=length))
@@ -419,27 +497,6 @@ ax3.scatter(**args)
-## Installation
-
-- From PyPI (recommended):
-
-``` bash
-pip install plotjs
-```
-
-- Latest dev version:
-
-``` bash
-pip install git+https://github.com/y-sunflower/plotjs.git
-```
-
-## Dependencies
-
-- Python 3.10+
-- [matplotlib](https://matplotlib.org/),
- [jinja2](https://jinja.palletsprojects.com/en/stable/) and
- [narwhals](https://narwhals-dev.github.io/narwhals/)
-
## Important limitation
### Plotting order
diff --git a/docs/index.qmd b/docs/index.qmd
index 8ac3f99..528ce81 100644
--- a/docs/index.qmd
+++ b/docs/index.qmd
@@ -9,8 +9,10 @@ execute:
```{python}
# | include: false
import matplotlib.pyplot as plt
+import numpy as np
plt.rcParams["figure.dpi"] = 300
+np.random.seed(0)
```
@@ -25,7 +27,24 @@ plt.rcParams["figure.dpi"] = 300
???+ warning
- `plotjs` is in very early stage: expect regular breaking changes.
+ Consider that `plotjs` is still unstable.
+
+## Installation
+
+- From PyPI (recommended):
+
+```bash
+pip install plotjs
+```
+
+- Latest dev version:
+
+```bash
+pip install git+https://github.com/y-sunflower/plotjs.git
+```
+
+- Python 3.10+
+- [matplotlib](https://matplotlib.org/), [jinja2](https://jinja.palletsprojects.com/en/stable/) and [narwhals](https://narwhals-dev.github.io/narwhals/)
## Get started
@@ -213,6 +232,63 @@ ax.barh(
+### Histogram
+
+```{python}
+import matplotlib.pyplot as plt
+import numpy as np
+
+from plotjs import PlotJS
+
+x = np.random.normal(loc=0.0, scale=1.0, size=100)
+
+fig, ax = plt.subplots()
+counts, bins, _ = ax.hist(x, color="#2a9d8f")
+
+labels = [
+ f"Lower bound: {lo:.2f}
Upper bound: {hi:.2f}
n: {int(n)}"
+ for lo, hi, n in zip(bins[:-1], bins[1:], counts)
+]
+
+PlotJS(fig=fig).add_tooltip(labels=labels).save("iframes/quickstart12.html")
+```
+
+
+
+### Boxplot
+
+```{python}
+from plotjs import PlotJS
+import seaborn as sns
+import matplotlib.pyplot as plt
+
+planets = sns.load_dataset("planets")
+
+fig, ax = plt.subplots(figsize=(7, 6))
+ax.set_xscale("log")
+sns.boxplot(
+ planets,
+ x="distance",
+ y="method",
+ hue="method",
+ whis=[0, 100],
+ width=0.6,
+ palette="vlag",
+)
+sns.stripplot(planets, x="distance", y="method", size=4, color=".3", alpha=0.4)
+ax.xaxis.grid(True)
+ax.set(ylabel="")
+sns.despine()
+
+(
+ PlotJS(bbox_inches="tight")
+ .add_tooltip(labels=planets["method"].unique(), on="bar", hover_nearest=True)
+ .save("iframes/quickstart13.html")
+)
+```
+
+
+
### Connect legend and plot elements:
- Scatter plot
@@ -251,8 +327,6 @@ import matplotlib.pyplot as plt
import numpy as np
from plotjs import PlotJS
-np.random.seed(0)
-
length = 500
walk1 = np.cumsum(np.random.choice([-1, 1], size=length))
walk2 = np.cumsum(np.random.choice([-1, 1], size=length))
@@ -391,26 +465,6 @@ ax3.scatter(**args)
-
-## Installation
-
-- From PyPI (recommended):
-
-```bash
-pip install plotjs
-```
-
-- Latest dev version:
-
-```bash
-pip install git+https://github.com/y-sunflower/plotjs.git
-```
-
-## Dependencies
-
-- Python 3.10+
-- [matplotlib](https://matplotlib.org/), [jinja2](https://jinja.palletsprojects.com/en/stable/) and [narwhals](https://narwhals-dev.github.io/narwhals/)
-
## Important limitation
### Plotting order
diff --git a/plotjs/plotjs.py b/plotjs/plotjs.py
index 0d105dc..92f5389 100644
--- a/plotjs/plotjs.py
+++ b/plotjs/plotjs.py
@@ -4,6 +4,7 @@
import uuid
import webbrowser
import tempfile
+import warnings
from typing import Optional
import numpy as np
@@ -29,7 +30,7 @@
class PlotJS:
"""
- Class to convert static matplotlib plots to interactive charts.
+ Main class to convert static matplotlib plots to interactive charts.
"""
def __init__(
@@ -118,8 +119,8 @@ def add_tooltip(
hover_nearest: When `True`, hover the nearest plot element.
on: Which plot elements to apply interactivity to. Can be a
single element type or a list. Valid values are "point",
- "line", "bar", "area" (plurals like "points" also accepted).
- If `None` (default), applies to all element types.
+ "line", "bar", "area", "pie (plurals like "points" also
+ accepted). If `None` (default), applies to all element types.
ax: A matplotlib Axes. If `None` (default), uses first Axes.
Returns:
@@ -160,16 +161,20 @@ def add_tooltip(
)
```
"""
+ if labels is None and groups is None:
+ warnings.warn("Either `labels` or `groups` must not be `None`.")
+
self._tooltip_x_shift = tooltip_x_shift
self._tooltip_y_shift = tooltip_y_shift
# Normalize and validate the `on` parameter
- valid_elements = {"point", "line", "bar", "area"}
+ valid_elements = {"point", "line", "bar", "area", "pie"}
plural_to_singular = {
"points": "point",
"lines": "line",
"bars": "bar",
"areas": "area",
+ "pies": "pie",
}
if on is None:
@@ -341,7 +346,7 @@ def add_d3js(self, version: int = 7) -> "PlotJS":
(
PlotJS()
.add_d3js()
- .add_javascript("d3.selectAll(".point").on("click", () => alert("I wish cookies were 0 calories..."));"
+ .add_javascript("d3.selectAll('.point').on('click', () => alert('I wish cookies were 0 calories...'));"
)
)
```
@@ -426,7 +431,7 @@ def as_html(self) -> str:
def show(self) -> "PlotJS":
"""
- Open the HTML file in the default browser.
+ Open the HTML file in the default browser, or inside your editor.
If the file hasn't been saved yet, it will be saved to a temporary file.
Returns:
@@ -453,7 +458,7 @@ def show(self) -> "PlotJS":
def _set_plot_data_json(self) -> None:
if not hasattr(self, "_tooltip_labels"):
if self._axes:
- self.add_tooltip()
+ self.add_tooltip(labels=[])
else:
self._tooltip_labels = []
self._tooltip_groups = []
diff --git a/plotjs/static/plotparser.js b/plotjs/static/plotparser.js
index 0502872..8c9736b 100755
--- a/plotjs/static/plotparser.js
+++ b/plotjs/static/plotparser.js
@@ -229,6 +229,59 @@ export default class PlotSVGParser {
return points;
}
+ /**
+ * Find rectangle elements inside a given axes.
+ *
+ * @param {Selection} svg - Selection of the SVG element.
+ * @param {string} axes_class - ID of the axes group (e.g. "axes_1").
+ * @returns {Selection} Selection of rectangle elements.
+ */
+ findRectangles(svg, axes_class) {
+ let rectangles = svg.selectAll(`g#${axes_class} g[id^="QuadMesh"] path`);
+ rectangles.attr("class", "rectangle plot-element");
+ console.log(`Found ${rectangles.size()} "rectangle" element`);
+ return rectangles;
+ }
+
+ /**
+ * Find pie elements (`patch` paths) inside a given axes.
+ *
+ * @param {Selection} svg - Selection of the SVG element.
+ * @param {string} axes_class - ID of the axes group.
+ * @returns {Selection} Selection of pie elements.
+ */
+ findPies(svg, axes_class) {
+ const parser = this;
+ const pies = svg
+ .selectAll(`g#${axes_class} g[id^="patch_"] path`)
+ .filter(function () {
+ const element = this;
+ const clipPath = element.getAttribute("clip-path");
+ const normalizedFill = parser.getFillValue(element);
+ const pathData = element.getAttribute("d") ?? "";
+
+ const parent = element.parentElement;
+ const grandparent = parent?.parentElement;
+ const isInLegend =
+ parent?.tagName?.toLowerCase() === "g" &&
+ grandparent?.tagName?.toLowerCase() === "g" &&
+ /^legend_\d+$/.test(grandparent.id);
+
+ return (
+ !isInLegend &&
+ !clipPath &&
+ normalizedFill !== "" &&
+ normalizedFill !== "none" &&
+ /[CQAST]/.test(pathData)
+ );
+ });
+
+ pies.attr("class", "pie plot-element");
+
+ console.log(`Found ${pies.size()} "pie" element`);
+ return pies;
+ }
+
/**
* Find line elements (`line2d` paths) inside a given axes,
* excluding axis grid lines.
diff --git a/plotjs/static/template.html b/plotjs/static/template.html
index ec071c0..c7ea6d7 100644
--- a/plotjs/static/template.html
+++ b/plotjs/static/template.html
@@ -77,6 +77,12 @@
const lines = shouldProcess("line")
? plotParser.findLines(plotParser.svg, axes_class)
: new Selection([]);
+ const rectangles = shouldProcess("rect")
+ ? plotParser.findRectangles(plotParser.svg, axes_class)
+ : new Selection([]);
+ const pies = shouldProcess("pie")
+ ? plotParser.findPies(plotParser.svg, axes_class)
+ : new Selection([]);
const bars = shouldProcess("bar")
? plotParser.findBars(plotParser.svg, axes_class)
: new Selection([]);
@@ -92,9 +98,14 @@
: new Selection([]);
const totalElements =
- lines.size() + bars.size() + points.size() + areas.size();
+ lines.size() +
+ bars.size() +
+ points.size() +
+ areas.size() +
+ rectangles.size() +
+ pies.size();
console.log(
- `PlotJS: Total elements: ${totalElements} (${lines.size()} lines, ${bars.size()} bars, ${points.size()} points, ${areas.size()} areas)`,
+ `PlotJS: Total elements: ${totalElements} (${lines.size()} lines, ${bars.size()} bars, ${points.size()} points, ${areas.size()} areas, ${pies.size()} pies, ${rectangles.size()} rectangles)`,
);
if (points.size() > 0) {
@@ -125,6 +136,34 @@
);
}
+ if (rectangles.size() > 0) {
+ plotParser.setHoverEffect(
+ rectangles,
+ axes_class,
+ tooltip_labels,
+ tooltip_groups,
+ show_tooltip,
+ hover_nearest,
+ );
+ console.log(
+ `PlotJS: Hover effects attached to ${rectangles.size()} rectangles`,
+ );
+ }
+
+ if (pies.size() > 0) {
+ plotParser.setHoverEffect(
+ pies,
+ axes_class,
+ tooltip_labels,
+ tooltip_groups,
+ show_tooltip,
+ hover_nearest,
+ );
+ console.log(
+ `PlotJS: Hover effects attached to ${pies.size()} pies`,
+ );
+ }
+
if (bars.size() > 0) {
plotParser.setHoverEffect(
bars,
diff --git a/plotjs/utils.py b/plotjs/utils.py
index 85d48a1..0246caf 100644
--- a/plotjs/utils.py
+++ b/plotjs/utils.py
@@ -1,9 +1,24 @@
+import numpy as np
import narwhals.stable.v2 as nw
from narwhals.stable.v2.dependencies import is_numpy_array, is_into_series
import re
+def _is_missing(value) -> bool:
+ if value is None:
+ return True
+ try:
+ return bool(np.isnan(value))
+ except TypeError:
+ pass
+ try:
+ return value != value
+ except Exception:
+ return False
+ return False
+
+
def _vector_to_list(vector, name="labels and groups") -> list:
"""
Function used to easily convert various kind of iterables to
@@ -23,14 +38,19 @@ def _vector_to_list(vector, name="labels and groups") -> list:
A list
"""
if isinstance(vector, (list, tuple)) or is_numpy_array(vector):
- return list(vector)
+ vector_sanitized: list = list(vector)
elif is_into_series(vector):
- return nw.from_native(vector, allow_series=True).to_list()
+ vector_sanitized: list = nw.from_native(vector, allow_series=True).to_list()
else:
raise ValueError(
f"{name} must be a Series or a valid iterable (list, tuple, ndarray...)."
)
+ # Drop NaNs to avoid JSON parsing error
+ # https://github.com/y-sunflower/plotjs/issues/67
+ vector_sanitized = [x for x in vector_sanitized if not _is_missing(x)]
+ return vector_sanitized
+
def _get_and_sanitize_js(file_path, after_pattern):
"""
diff --git a/pyproject.toml b/pyproject.toml
index 5ebdca4..107e1f7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,75 +1,85 @@
[project]
name = "plotjs"
-description = "Turn static matplotlib charts into interactive web visualizations"
version = "0.0.9"
+description = "Turn static matplotlib charts into interactive web visualizations, and export them to HTML files"
+readme = "README.md"
+requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
-keywords = ["matplotlib", "interactive", "javascript", "web", "css", "d3", "mpld3", "plotnine"]
authors = [
- { name="Joseph Barbier", email="joseph.barbierdarnal@mail.com" },
+ { name = "Joseph Barbier", email = "joseph.barbierdarnal@mail.com" },
+]
+keywords = [
+ "css",
+ "d3",
+ "interactive",
+ "javascript",
+ "matplotlib",
+ "mpld3",
+ "plotnine",
+ "web"
]
-readme = "README.md"
-requires-python = ">=3.10"
classifiers = [
- "Programming Language :: Python :: 3",
- "Operating System :: OS Independent",
- "Development Status :: 3 - Alpha"
+ "Development Status :: 3 - Alpha",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3"
]
dependencies = [
- "jinja2>=3.0.0",
- "matplotlib>=3.10.0",
- "narwhals>=2.0.0",
+ "jinja2>=3.0.0",
+ "matplotlib>=3.10.0",
+ "narwhals>=2.0.0",
+]
+
+[project.urls]
+Documentation = "https://y-sunflower.github.io/plotjs/"
+Homepage = "https://y-sunflower.github.io/plotjs/"
+Issues = "https://github.com/y-sunflower/plotjs/issues"
+Repository = "https://github.com/y-sunflower/plotjs"
+
+[dependency-groups]
+dev = [
+ "coverage>=7.9.1",
+ "drawarrow>=0.1.0",
+ "genbadge[coverage]>=1.1.2",
+ "highlight-text>=0.2",
+ "ipywidgets>=8.1.7",
+ "jupyter>=1.1.1",
+ "mkdocs-material>=9.6.9",
+ "mkdocstrings-python>=1.16.5",
+ "morethemes>=0.4.0",
+ "nbclient>=0.10.2",
+ "nbformat>=5.10.4",
+ "pandas>=2.3.1",
+ "playwright>=1.40.0",
+ "plotnine>=0.13.6",
+ "polars>=1.31.0",
+ "prek>=0.3.5",
+ "pyfonts>=1.0.0",
+ "pypalettes>=0.1.4",
+ "pytest>=8.3.5",
+ "pywaffle>=1.1.1",
+ "ruff>=0.11.13",
+ "seaborn>=0.13.2",
+ "ty>=0.0.1a16",
+ "zensical>=0.0.27",
]
[build-system]
requires = [
- "setuptools",
- "setuptools-scm",
+ "setuptools",
+ "setuptools-scm",
]
build-backend = "setuptools.build_meta"
+[tool.ruff]
+extend-exclude = ["docs/index.qmd"]
+
[tool.setuptools]
packages = ["plotjs"]
-[tool.uv.sources]
-plotjs = { workspace = true }
-
-[dependency-groups]
-dev = [
- "pytest>=8.3.5",
- "ruff>=0.11.13",
- "mkdocs-material>=9.6.9",
- "mkdocstrings-python>=1.16.5",
- "coverage>=7.9.1",
- "genbadge[coverage]>=1.1.2",
- "pandas>=2.3.1",
- "pypalettes>=0.1.4",
- "pyfonts>=1.0.0",
- "morethemes>=0.4.0",
- "plotnine>=0.13.6",
- "ty>=0.0.1a16",
- "polars>=1.31.0",
- "nbformat>=5.10.4",
- "nbclient>=0.10.2",
- "jupyter>=1.1.1",
- "ipywidgets>=8.1.7",
- "highlight-text>=0.2",
- "drawarrow>=0.1.0",
- "playwright>=1.40.0",
- "prek>=0.3.5",
- "seaborn>=0.13.2",
- "zensical>=0.0.27",
-]
-
-[project.urls]
-Homepage = "https://y-sunflower.github.io/plotjs/"
-Issues = "https://github.com/y-sunflower/plotjs/issues"
-Documentation = "https://y-sunflower.github.io/plotjs/"
-Repository = "https://github.com/y-sunflower/plotjs"
-
[tool.ty.src]
include = ["plotjs"]
exclude = ["tests", "sandbox"]
-[tool.ruff]
-extend-exclude = ["docs/index.qmd"]
+[tool.uv.sources]
+plotjs = { workspace = true }
diff --git a/tests/test-browser/test_rendering.py b/tests/test-browser/test_rendering.py
index 00ffd2c..84f6be5 100644
--- a/tests/test-browser/test_rendering.py
+++ b/tests/test-browser/test_rendering.py
@@ -1,4 +1,5 @@
import pytest
+import seaborn as sns
import matplotlib.pyplot as plt
from plotjs import PlotJS, data
@@ -96,6 +97,41 @@ def test_multiple_axes_render(page, tmp_output_dir, load_html):
assert axes_groups.count() == 2
+def test_seaborn_with_nans_renders(page, tmp_output_dir, load_html):
+ mpg = sns.load_dataset("mpg").sort_values("origin")
+ sns.relplot(
+ x="horsepower",
+ y="mpg",
+ hue="origin",
+ size="weight",
+ sizes=(40, 400),
+ alpha=0.5,
+ palette="muted",
+ height=6,
+ data=mpg,
+ )
+
+ html_path = tmp_output_dir / "bar.html"
+ (
+ PlotJS()
+ .add_tooltip(labels=mpg["horsepower"], groups=mpg["origin"])
+ .save(str(html_path))
+ )
+
+ plt.close()
+
+ load_html(page, html_path)
+
+ svg = page.locator("svg")
+ assert svg.count() == 1
+
+ # Check for no error messages with NaNs
+ console_messages = []
+ page.on("console", lambda msg: console_messages.append(msg))
+ errors = [msg for msg in console_messages if msg.type == "error"]
+ assert len(errors) == 0, f"JavaScript errors found: {[msg.text for msg in errors]}"
+
+
def test_custom_css_applies(page, tmp_output_dir, load_html):
"""Test that custom CSS is applied correctly."""
fig, ax = plt.subplots()
diff --git a/tests/test-javascript/ParserSelectors.test.js b/tests/test-javascript/ParserSelectors.test.js
index 85e2050..6436a45 100644
--- a/tests/test-javascript/ParserSelectors.test.js
+++ b/tests/test-javascript/ParserSelectors.test.js
@@ -201,6 +201,68 @@ describe("findPoints", () => {
});
});
+describe("findPies", () => {
+ test("should find pie slice patch paths", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const pies = parser.findPies(parser.svg, "axes_1");
+
+ expect(pies.size()).toBe(2);
+ pies.each(function () {
+ expect(this.getAttribute("class")).toBe("pie plot-element");
+ });
+ });
+
+ test("should ignore scatter plot patch paths", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const pies = parser.findPies(parser.svg, "axes_1");
+
+ expect(pies.size()).toBe(0);
+ expect(pies.empty()).toBe(true);
+ });
+});
+
describe("findLines", () => {
test("should find line2d path elements", () => {
const dom = new JSDOM(`
diff --git a/tests/test-python/test_plotjs.py b/tests/test-python/test_plotjs.py
index 5ef595a..ede9f39 100644
--- a/tests/test-python/test_plotjs.py
+++ b/tests/test-python/test_plotjs.py
@@ -1,10 +1,12 @@
-from plotjs import PlotJS, data
+import numpy as np
import matplotlib.pyplot as plt
import os
import tempfile
from unittest.mock import patch
import pytest
+from plotjs import PlotJS, data
+
def test_add_css_method_chaining():
df = data.load_iris()
@@ -387,7 +389,7 @@ def test_add_tooltip_without_axes_raises_clear_error():
with pytest.raises(
ValueError, match=r"Cannot add tooltip because the figure has no Axes\."
):
- PlotJS(fig=fig).add_tooltip()
+ PlotJS(fig=fig).add_tooltip(labels=[])
plt.close(fig)
@@ -407,3 +409,150 @@ def test_init_restores_svg_rcparams_if_savefig_fails():
assert plt.rcParams["svg.id"] == old_svg_id
plt.close(fig)
+
+
+def test_fill_between_with_legend():
+ x = np.arange(10)
+ y1 = np.array([2, 3, 4, 3, 5, 6, 5, 7, 6, 8])
+ y2 = np.array([1, 2, 2, 3, 3, 4, 4, 5, 5, 6])
+
+ fig, ax = plt.subplots()
+
+ ax.fill_between(x, y1, label="Series A")
+ ax.fill_between(x, y2, label="Series B")
+
+ ax.spines[["top", "right"]].set_visible(False)
+ ax.legend()
+
+ plotjs = PlotJS(fig).add_tooltip(
+ labels=["Series A", "Series B"],
+ groups=["Series A", "Series B"],
+ on="area",
+ )
+
+ assert len(plotjs._axes) == 1
+ assert plotjs._tooltip_labels == ["Series A", "Series B", "Series A", "Series B"]
+ assert plotjs._tooltip_groups == ["Series A", "Series B", "Series A", "Series B"]
+
+ assert len(plotjs._legend_handles) == 2
+ assert plotjs._legend_handles_labels == ["Series A", "Series B"]
+ assert plotjs._axes_tooltip == {
+ "axes_1": {
+ "tooltip_labels": ["Series A", "Series B", "Series A", "Series B"],
+ "tooltip_groups": ["Series A", "Series B", "Series A", "Series B"],
+ "hover_nearest": "false",
+ "on": ["area"],
+ }
+ }
+
+
+def test_histogram_with_custom_labels():
+ np.random.seed(0)
+ x = np.random.normal(loc=0.0, scale=1.0, size=100)
+
+ fig, ax = plt.subplots()
+ counts, bins, _ = ax.hist(x, color="#2a9d8f")
+
+ labels = [
+ f"Lower bound: {lo:.2f} Upper bound:{hi:.2f} n: {int(n)}"
+ for lo, hi, n in zip(bins[:-1], bins[1:], counts)
+ ]
+
+ plotjs = PlotJS(fig=fig).add_tooltip(labels=labels)
+
+ assert len(plotjs._axes) == 1
+ assert plotjs._tooltip_labels == [
+ "Lower bound: -2.55 Upper bound:-2.07 n: 1",
+ "Lower bound: -2.07 Upper bound:-1.59 n: 5",
+ "Lower bound: -1.59 Upper bound:-1.11 n: 7",
+ "Lower bound: -1.11 Upper bound:-0.62 n: 13",
+ "Lower bound: -0.62 Upper bound:-0.14 n: 17",
+ "Lower bound: -0.14 Upper bound:0.34 n: 18",
+ "Lower bound: 0.34 Upper bound:0.82 n: 16",
+ "Lower bound: 0.82 Upper bound:1.31 n: 11",
+ "Lower bound: 1.31 Upper bound:1.79 n: 7",
+ "Lower bound: 1.79 Upper bound:2.27 n: 5",
+ ]
+ assert plotjs._tooltip_groups == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+
+ assert plotjs._axes_tooltip == {
+ "axes_1": {
+ "tooltip_labels": [
+ "Lower bound: -2.55 Upper bound:-2.07 n: 1",
+ "Lower bound: -2.07 Upper bound:-1.59 n: 5",
+ "Lower bound: -1.59 Upper bound:-1.11 n: 7",
+ "Lower bound: -1.11 Upper bound:-0.62 n: 13",
+ "Lower bound: -0.62 Upper bound:-0.14 n: 17",
+ "Lower bound: -0.14 Upper bound:0.34 n: 18",
+ "Lower bound: 0.34 Upper bound:0.82 n: 16",
+ "Lower bound: 0.82 Upper bound:1.31 n: 11",
+ "Lower bound: 1.31 Upper bound:1.79 n: 7",
+ "Lower bound: 1.79 Upper bound:2.27 n: 5",
+ ],
+ "tooltip_groups": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+ "hover_nearest": "false",
+ "on": None,
+ }
+ }
+
+
+def test_pie_chart():
+ labels = ["A", "B", "C", "D"]
+ sizes = [15, 10, 25, 10]
+
+ fig, ax = plt.subplots()
+ ax.pie(sizes, labels=labels, autopct="%1.1f%%")
+
+ tooltip = [f"{lab} (n = {size})" for lab, size in zip(labels, sizes)]
+
+ plotjs = PlotJS(fig).add_tooltip(labels=tooltip)
+
+ assert len(plotjs._axes) == 1
+ assert plotjs._tooltip_labels == [
+ "A (n = 15)",
+ "B (n = 10)",
+ "C (n = 25)",
+ "D (n = 10)",
+ "A",
+ "B",
+ "C",
+ "D",
+ ]
+ assert plotjs._tooltip_groups == [0, 1, 2, 3, 4, 5, 6, 7]
+
+ assert plotjs._axes_tooltip == {
+ "axes_1": {
+ "tooltip_labels": [
+ "A (n = 15)",
+ "B (n = 10)",
+ "C (n = 25)",
+ "D (n = 10)",
+ "A",
+ "B",
+ "C",
+ "D",
+ ],
+ "tooltip_groups": [0, 1, 2, 3, 4, 5, 6, 7],
+ "hover_nearest": "false",
+ "on": None,
+ }
+ }
+
+
+def test_warning_message_for_labels_and_groups():
+ df = data.load_iris()
+
+ fig, ax = plt.subplots()
+ ax.scatter(
+ df["sepal_length"],
+ df["sepal_width"],
+ c=df["species"].astype("category").cat.codes,
+ s=300,
+ alpha=0.5,
+ ec="black",
+ )
+
+ with pytest.warns(
+ UserWarning, match="Either `labels` or `groups` must not be `None`."
+ ):
+ PlotJS(fig=fig).add_tooltip()
diff --git a/uv.lock b/uv.lock
index b3fc884..92afc46 100644
--- a/uv.lock
+++ b/uv.lock
@@ -708,6 +708,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924, upload-time = "2024-12-02T10:55:07.599Z" },
]
+[[package]]
+name = "fontawesomefree"
+version = "6.6.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/e9/d43f5133b73e7ef9047bda28daaa6905e00b7d39f093b547f7e78ee2fc40/fontawesomefree-6.6.0-py3-none-any.whl", hash = "sha256:599b574431c9bd92ed5fc054d1045a07c42335da36c17884f2b934755eef9089", size = 25645298, upload-time = "2024-07-16T18:35:44.818Z" },
+]
+
[[package]]
name = "fonttools"
version = "4.59.0"
@@ -2264,6 +2272,7 @@ dev = [
{ name = "pyfonts" },
{ name = "pypalettes" },
{ name = "pytest" },
+ { name = "pywaffle" },
{ name = "ruff" },
{ name = "seaborn" },
{ name = "ty" },
@@ -2298,6 +2307,7 @@ dev = [
{ name = "pyfonts", specifier = ">=1.0.0" },
{ name = "pypalettes", specifier = ">=0.1.4" },
{ name = "pytest", specifier = ">=8.3.5" },
+ { name = "pywaffle", specifier = ">=1.1.1" },
{ name = "ruff", specifier = ">=0.11.13" },
{ name = "seaborn", specifier = ">=0.13.2" },
{ name = "ty", specifier = ">=0.0.1a16" },
@@ -2549,6 +2559,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
+[[package]]
+name = "pywaffle"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fontawesomefree" },
+ { name = "matplotlib" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/75/4b/c0f432b1287ce2df12841929e406a0b466484255b88ca1e1fd6ca684a759/pywaffle-1.1.1.tar.gz", hash = "sha256:d3d98178b76bcf1e179fd3c9d77b18fa231e32d628710441ceddb4451ddf3c4a", size = 37110, upload-time = "2024-06-16T04:18:09.97Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/99/e564248b5a2ec8f005a6209e2e59a458ce4850281fb28e4aa3571fe91f52/pywaffle-1.1.1-py2.py3-none-any.whl", hash = "sha256:48b15575639d2ddf5565a1768520f2b26cc3e92f778edf10fb29ecf27bcdecab", size = 30911, upload-time = "2024-06-16T04:18:08.303Z" },
+]
+
[[package]]
name = "pywin32"
version = "311"
diff --git a/zensical.toml b/zensical.toml
index c407405..932418f 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -12,6 +12,7 @@ nav = [
{ "Home" = "index.md" },
{ "Gallery" = "gallery/index.md" },
{ "Guides" = [
+ "guides/index.md",
"guides/css/index.md",
"guides/javascript/index.md",
"guides/embed-graphs/index.md",