From 72c457e32c9fe9e21c2525d3811dc7570564ce5f Mon Sep 17 00:00:00 2001
From: Barbier--Darnal Joseph
Date: Sun, 15 Mar 2026 19:33:05 +0100
Subject: [PATCH 1/5] fix a bunch of issues
---
AGENT.md => AGENTS.md | 0
plotjs/plotjs.py | 45 ++++++++++-----
pyproject.toml | 2 +-
tests/test-python/test_plotjs.py | 51 +++++++++++++++++
uv.lock | 95 ++++++++------------------------
5 files changed, 105 insertions(+), 88 deletions(-)
rename AGENT.md => AGENTS.md (100%)
diff --git a/AGENT.md b/AGENTS.md
similarity index 100%
rename from AGENT.md
rename to AGENTS.md
diff --git a/plotjs/plotjs.py b/plotjs/plotjs.py
index 21f653d..2b97426 100644
--- a/plotjs/plotjs.py
+++ b/plotjs/plotjs.py
@@ -23,6 +23,8 @@
JS_PARSER_PATH: str = os.path.join(TEMPLATE_DIR, "plotparser.js")
env: Environment = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
+DEFAULT_FAVICON_PATH = "https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/favicon.ico?raw=true"
+DEFAULT_DOCUMENT_TITLE = "Made with plotjs"
class PlotJS:
@@ -51,11 +53,13 @@ def __init__(
# https://github.com/y-sunflower/plotjs/issues/54
old_svg_hashsalt = plt.rcParams["svg.hashsalt"]
old_svg_id = plt.rcParams["svg.id"]
- plt.rcParams["svg.hashsalt"] = "svg-hashsalt"
- plt.rcParams["svg.id"] = "svg-id"
- fig.savefig(buf, format="svg", **savefig_kws)
- plt.rcParams["svg.hashsalt"] = old_svg_hashsalt
- plt.rcParams["svg.id"] = old_svg_id
+ try:
+ plt.rcParams["svg.hashsalt"] = "svg-hashsalt"
+ plt.rcParams["svg.id"] = "svg-id"
+ fig.savefig(buf, format="svg", **savefig_kws)
+ finally:
+ plt.rcParams["svg.hashsalt"] = old_svg_hashsalt
+ plt.rcParams["svg.id"] = old_svg_id
buf.seek(0)
self._svg_content = buf.getvalue()
@@ -68,6 +72,8 @@ def __init__(
self.additional_css = ""
self.additional_javascript = ""
self._hover_nearest = False
+ self._favicon_path = DEFAULT_FAVICON_PATH
+ self._document_title = DEFAULT_DOCUMENT_TITLE
self._template = env.get_template("template.html")
with open(CSS_PATH) as f:
@@ -181,7 +187,13 @@ def add_tooltip(
normalized_on.append(element)
if ax is None:
+ if not self._axes:
+ raise ValueError("Cannot add tooltip because the figure has no Axes.")
ax: Axes = self._axes[0]
+ elif ax not in self._axes:
+ raise ValueError(
+ "Cannot add tooltip on an Axes that does not belong to this figure."
+ )
self._legend_handles, self._legend_handles_labels = (
ax.get_legend_handles_labels()
)
@@ -338,8 +350,8 @@ def add_d3js(self, version: int = 7) -> "PlotJS":
def save(
self,
file_path: str,
- favicon_path: str = "https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/favicon.ico?raw=true",
- document_title: str = "Made with plotjs",
+ favicon_path: str = DEFAULT_FAVICON_PATH,
+ document_title: str = DEFAULT_DOCUMENT_TITLE,
) -> "PlotJS":
"""
Save the interactive matplotlib plots to an HTML file.
@@ -371,7 +383,7 @@ def save(
if not file_path.endswith(".html"):
file_path += ".html"
- with open(file_path, "w") as f:
+ with open(file_path, "w", encoding="utf-8") as f:
f.write(self.html)
# store the file path for later use (e.g., show() method)
@@ -427,18 +439,23 @@ def show(self) -> "PlotJS":
```
"""
if not hasattr(self, "_file_path"):
- temp_file = tempfile.NamedTemporaryFile(
- mode="w", suffix=".html", delete=False
- )
- self.save(temp_file.name)
- temp_file.close()
+ temp_fd, temp_path = tempfile.mkstemp(suffix=".html")
+ os.close(temp_fd)
+ self.save(temp_path)
webbrowser.open(f"file://{self._file_path}")
return self
def _set_plot_data_json(self) -> None:
if not hasattr(self, "_tooltip_labels"):
- self.add_tooltip()
+ if self._axes:
+ self.add_tooltip()
+ else:
+ self._tooltip_labels = []
+ self._tooltip_groups = []
+ self._tooltip_x_shift = 0
+ self._tooltip_y_shift = 0
+ self._axes_tooltip = {}
self.plot_data_json = {
"tooltip_labels": self._tooltip_labels,
diff --git a/pyproject.toml b/pyproject.toml
index 0079498..d4597c1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,6 @@ plotjs = { workspace = true }
[dependency-groups]
dev = [
- "pre-commit>=4.2.0",
"pytest>=8.3.5",
"ruff>=0.11.13",
"mkdocs-material>=9.6.9",
@@ -57,6 +56,7 @@ dev = [
"highlight-text>=0.2",
"drawarrow>=0.1.0",
"playwright>=1.40.0",
+ "prek>=0.3.5",
]
[project.urls]
diff --git a/tests/test-python/test_plotjs.py b/tests/test-python/test_plotjs.py
index af277a5..5ef595a 100644
--- a/tests/test-python/test_plotjs.py
+++ b/tests/test-python/test_plotjs.py
@@ -356,3 +356,54 @@ def test_add_tooltip_on_parameter_invalid_in_list():
PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on=["point", "circle"])
plt.close(fig)
+
+
+def test_as_html_without_save_uses_default_metadata():
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ html = PlotJS(fig=fig).as_html()
+
+ assert "Made with plotjs " in html
+ assert 'rel="icon"' in html
+ assert "
Date: Sun, 15 Mar 2026 20:06:25 +0100
Subject: [PATCH 2/5] migrate to zensical
---
.gitignore | 1 +
docs/developers/contributing.md | 2 +-
docs/stylesheets/style.css | 9 ++--
justfile | 2 +-
mkdocs.yaml | 95 ---------------------------------
plotjs/data/__init__.py | 2 +-
pyproject.toml | 2 +
uv.lock | 57 ++++++++++++++++++++
zensical.toml | 81 ++++++++++++++++++++++++++++
9 files changed, 150 insertions(+), 101 deletions(-)
delete mode 100644 mkdocs.yaml
create mode 100644 zensical.toml
diff --git a/.gitignore b/.gitignore
index 0e5f481..8d7c412 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,4 @@ release.sh
node_modules
bun.lockb
.claude/
+site/
diff --git a/docs/developers/contributing.md b/docs/developers/contributing.md
index 3d9efc3..b37f659 100644
--- a/docs/developers/contributing.md
+++ b/docs/developers/contributing.md
@@ -36,7 +36,7 @@ git checkout -b my-feature
```bash
uv sync --all-groups --dev
-uv run pre-commit install
+uv run prek install -f
uv pip install -e .
```
diff --git a/docs/stylesheets/style.css b/docs/stylesheets/style.css
index 2b07eea..e50b048 100644
--- a/docs/stylesheets/style.css
+++ b/docs/stylesheets/style.css
@@ -5,9 +5,12 @@ h4 {
}
footer.custom-footer {
- box-shadow: 0 0 1rem #0000003e, 0 0.2rem 0.4rem #0003;
- transition: transform 0.25s cubic-bezier(0.1, 0.7, 0.1, 1), box-shadow 0.25s;
- color: #000000ff;
+ box-shadow:
+ 0 0 1rem #0000003e,
+ 0 0.2rem 0.4rem #0003;
+ transition:
+ transform 0.25s cubic-bezier(0.1, 0.7, 0.1, 1),
+ box-shadow 0.25s;
padding: 20px 20px;
text-align: center;
margin-top: 50px;
diff --git a/justfile b/justfile
index af5ca23..1a0d5c5 100644
--- a/justfile
+++ b/justfile
@@ -28,7 +28,7 @@ cov:
rm coverage.xml
preview:
- uv run mkdocs serve
+ uv run zensical serve
jsdoc:
bun run docs:js
diff --git a/mkdocs.yaml b/mkdocs.yaml
deleted file mode 100644
index 4272c84..0000000
--- a/mkdocs.yaml
+++ /dev/null
@@ -1,95 +0,0 @@
-site_name: plotjs
-site_url: https://y-sunflower.github.io/plotjs/
-repo_url: https://github.com/y-sunflower/plotjs
-
-theme:
- name: material
- font:
- text: Inter
- code: Jetbrains Mono
- palette:
- # Palette toggle for automatic mode
- - media: "(prefers-color-scheme)"
- toggle:
- icon: material/brightness-auto
- name: Switch to light mode
-
- # Palette toggle for light mode
- - media: "(prefers-color-scheme: light)"
- scheme: default
- toggle:
- icon: material/brightness-7
- name: Switch to dark mode
-
- # Palette toggle for dark mode
- - media: "(prefers-color-scheme: dark)"
- scheme: slate
- toggle:
- icon: material/brightness-4
- name: Switch to system preference
- logo: https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/image.png?raw=true
- custom_dir: overrides
- favicon: https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/favicon.ico?raw=true
- features:
- - content.code.copy
- - navigation.path
- - content.footnote.tooltips
- - navigation.expand
- icon:
- repo: fontawesome/brands/github
-
-plugins:
- - search
- - mkdocstrings:
- default_handler: python
- handlers:
- python:
- options:
- show_source: false
- show_root_heading: true
- heading_level: 3
-
-nav:
- - Home: index.md
- - Gallery: gallery/index.md
- - Guides:
- - Add your own CSS: guides/css/index.md
- - Add your own JavaScript: guides/javascript/index.md
- - Embed graphs in different environments: guides/embed-graphs/index.md
- - Troubleshooting: guides/troubleshooting/index.md
- - Advanced: guides/advanced/index.md
- - API Reference:
- - PlotJS: reference/plotjs.md
- - CSS: reference/css.md
- - JavaScript: reference/javascript.md
- - Datasets: reference/datasets.md
- - For developers:
- - Overview: developers/overview.md
- - Parsing matplotlib's SVG: developers/parsing-matplotlib-svg.md
- - SVG parser reference: developers/svg-parser-reference.md
- - Contributing: developers/contributing.md
-
-extra_css:
- - stylesheets/style.css
-
-markdown_extensions:
- - pymdownx.tabbed:
- alternate_style: true
- - pymdownx.highlight:
- anchor_linenums: true
- line_spans: __span
- pygments_lang_class: true
- - pymdownx.inlinehilite
- - footnotes
- - pymdownx.snippets
- - pymdownx.superfences
- - attr_list
- - md_in_html
- - pymdownx.blocks.caption
- - admonition
- - pymdownx.details
- - pymdownx.critic
- - pymdownx.caret
- - pymdownx.keys
- - pymdownx.mark
- - pymdownx.tilde
diff --git a/plotjs/data/__init__.py b/plotjs/data/__init__.py
index c3fd222..6074032 100644
--- a/plotjs/data/__init__.py
+++ b/plotjs/data/__init__.py
@@ -1,3 +1,3 @@
-from .datasets import load_iris, load_mtcars, load_titanic, _load_data
+from plotjs.data.datasets import load_iris, load_mtcars, load_titanic, _load_data
__all__: list[str] = ["load_iris", "load_mtcars", "_load_data", "load_titanic"]
diff --git a/pyproject.toml b/pyproject.toml
index d4597c1..5ebdca4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,6 +57,8 @@ dev = [
"drawarrow>=0.1.0",
"playwright>=1.40.0",
"prek>=0.3.5",
+ "seaborn>=0.13.2",
+ "zensical>=0.0.27",
]
[project.urls]
diff --git a/uv.lock b/uv.lock
index 543577f..b3fc884 100644
--- a/uv.lock
+++ b/uv.lock
@@ -648,6 +648,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
+[[package]]
+name = "deepmerge"
+version = "2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
+]
+
[[package]]
name = "defusedxml"
version = "0.7.1"
@@ -2256,7 +2265,9 @@ dev = [
{ name = "pypalettes" },
{ name = "pytest" },
{ name = "ruff" },
+ { name = "seaborn" },
{ name = "ty" },
+ { name = "zensical" },
]
[package.metadata]
@@ -2288,7 +2299,9 @@ dev = [
{ name = "pypalettes", specifier = ">=0.1.4" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "ruff", specifier = ">=0.11.13" },
+ { name = "seaborn", specifier = ">=0.13.2" },
{ name = "ty", specifier = ">=0.0.1a16" },
+ { name = "zensical", specifier = ">=0.0.27" },
]
[[package]]
@@ -3029,6 +3042,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/6c/a76329897a7cae4937d403e623aa6aaea616a0bb5b36588f0b9d1c9a3739/scipy-1.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c0c804d60492a0aad7f5b2bb1862f4548b990049e27e828391ff2bf6f7199998", size = 39427705, upload-time = "2025-07-27T16:31:53.96Z" },
]
+[[package]]
+name = "seaborn"
+version = "0.13.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pandas" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" },
+]
+
[[package]]
name = "send2trash"
version = "1.8.3"
@@ -3374,3 +3402,32 @@ sdist = { url = "https://files.pythonhosted.org/packages/41/53/2e0253c5efd69c965
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503, upload-time = "2025-04-10T13:01:23.086Z" },
]
+
+[[package]]
+name = "zensical"
+version = "0.0.27"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "deepmerge" },
+ { name = "markdown" },
+ { name = "pygments" },
+ { name = "pymdown-extensions" },
+ { name = "pyyaml" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/83/969152d927b522a0fed1f20b1730575d86b920ce51530b669d9fad4537de/zensical-0.0.27.tar.gz", hash = "sha256:6d8d74aba4a9f9505e6ba1c43d4c828ba4ff7bb1ff9b005e5174c5b92cf23419", size = 3841776, upload-time = "2026-03-13T17:56:14.494Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/fe/0335f1a521eb6c0ab96028bf67148390eb1d5c742c23e6a4b0f8381508bd/zensical-0.0.27-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d51ebf4b038f3eea99fd337119b99d92ad92bbe674372d5262e6dbbabbe4e9b5", size = 12262017, upload-time = "2026-03-13T17:55:36.403Z" },
+ { url = "https://files.pythonhosted.org/packages/02/cb/ac24334fc7959b49496c97cb9d2bed82a8db8b84eafaf68189048e7fe69a/zensical-0.0.27-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a627cd4599cf2c5a5a5205f0510667227d1fe4579b6f7445adba2d84bab9fbc8", size = 12147361, upload-time = "2026-03-13T17:55:39.736Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/31c981f61006fdaf0460d15bde1248a045178d67307bad61a4588414855d/zensical-0.0.27-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99cbc493022f8749504ef10c71772d360b705b4e2fd1511421393157d07bdccf", size = 12505771, upload-time = "2026-03-13T17:55:42.993Z" },
+ { url = "https://files.pythonhosted.org/packages/30/1e/f6842c94ec89e5e9184f407dbbab2a497b444b28d4fb5b8df631894be896/zensical-0.0.27-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecc20a85e8a23ad9ab809b2f268111321be7b2e214021b3b00f138936a87a434", size = 12455689, upload-time = "2026-03-13T17:55:46.055Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/ad/866c3336381cca7528e792469958fbe2e65b9206a2657bef3dd8ed4ac88b/zensical-0.0.27-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da11e0f0861dbd7d3b5e6fe1e3a53b361b2181c53f3abe9fb4cdf2ed0cea47bf", size = 12791263, upload-time = "2026-03-13T17:55:49.193Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/df/fca5ed6bebdb61aa656dfa65cce4b4d03324a79c75857728230872fbdf7c/zensical-0.0.27-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e11d220181477040a4b22bf2b8678d5b0c878e7aae194fad4133561cb976d69", size = 12549796, upload-time = "2026-03-13T17:55:52.55Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e2/43398b5ec64ed78204a5a5929a3990769fc0f6a3094a30395882bda1399a/zensical-0.0.27-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06b9e308aec8c5db1cd623e2e98e1b25c3f5cab6b25fcc9bac1e16c0c2b93837", size = 12683568, upload-time = "2026-03-13T17:55:56.151Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/3c/5c98f9964c7e30735aacd22a389dacec12bcc5bc8162c58e76b76d20db6e/zensical-0.0.27-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:682085155126965b091cb9f915cd2e4297383ac500122fd4b632cf4511733eb2", size = 12725214, upload-time = "2026-03-13T17:55:59.286Z" },
+ { url = "https://files.pythonhosted.org/packages/50/0f/ebaa159cac6d64b53bf7134420c2b43399acc7096cb79795be4fb10768fc/zensical-0.0.27-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b367c285157c8e1099ae9e2b36564e07d3124bf891e96194a093bc836f3058d2", size = 12860416, upload-time = "2026-03-13T17:56:02.456Z" },
+ { url = "https://files.pythonhosted.org/packages/88/06/d82bfccbf5a1f43256dbc4d1984e398035a65f84f7c1e48b69ba15ea7281/zensical-0.0.27-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:847c881209e65e1db1291c59a9db77966ac50f7c66bf9a733c3c7832144dbfca", size = 12819533, upload-time = "2026-03-13T17:56:05.487Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/1f/d25e421d91f063a9404c59dd032f65a67c7c700e9f5f40436ab98e533482/zensical-0.0.27-cp310-abi3-win32.whl", hash = "sha256:f31ec13c700794be3f9c0b7d90f09a7d23575a3a27c464994b9bb441a22d880b", size = 11862822, upload-time = "2026-03-13T17:56:08.933Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/b5/5b86d126fcc42b96c5dbecde5074d6ea766a1a884e3b25b3524843c5e6a5/zensical-0.0.27-cp310-abi3-win_amd64.whl", hash = "sha256:9d3b1fca7ea99a7b2a8db272dd7f7839587c4ebf4f56b84ff01c97b3893ec9f8", size = 12059658, upload-time = "2026-03-13T17:56:11.859Z" },
+]
diff --git a/zensical.toml b/zensical.toml
new file mode 100644
index 0000000..c407405
--- /dev/null
+++ b/zensical.toml
@@ -0,0 +1,81 @@
+[project]
+site_name = "PlotJS"
+repo_url = "https://github.com/y-sunflower/plotjs"
+site_url = "https://y-sunflower.github.io/plotjs/"
+site_description = "Turn static matplotlib charts into interactive web visualizations"
+site_author = "Joseph Barbier"
+copyright = """
+Copyright © 2026 Yellow Sunflower
+"""
+extra_css = ["stylesheets/style.css"]
+nav = [
+ { "Home" = "index.md" },
+ { "Gallery" = "gallery/index.md" },
+ { "Guides" = [
+ "guides/css/index.md",
+ "guides/javascript/index.md",
+ "guides/embed-graphs/index.md",
+ "guides/troubleshooting/index.md",
+ "guides/advanced/index.md",
+ ] },
+ { "API Reference" = [
+ "reference/plotjs.md",
+ "reference/css.md",
+ "reference/javascript.md",
+ "reference/datasets.md",
+ ] },
+ { "For developers" = [
+ "developers/overview.md",
+ "developers/parsing-matplotlib-svg.md",
+ "developers/svg-parser-reference.md",
+ "developers/contributing.md",
+ ] },
+]
+
+[project.theme]
+language = "en"
+favicon = "images/favicon.png"
+custom_dir = "overrides"
+logo = "https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/image.png?raw=true"
+features = [
+ "announce.dismiss",
+ "content.code.annotate",
+ "content.code.copy",
+ "content.code.select",
+ "content.footnote.tooltips",
+ "content.tooltips",
+ "navigation.footer",
+ "navigation.indexes",
+ "navigation.instant",
+ "navigation.instant.prefetch",
+ "navigation.path",
+ "navigation.tabs",
+ "navigation.sections",
+ "navigation.top",
+ "navigation.tracking",
+ "search.highlight",
+ "navigation.prune",
+]
+
+[[project.theme.palette]]
+scheme = "default"
+toggle.icon = "lucide/sun"
+toggle.name = "Switch to dark mode"
+
+[[project.theme.palette]]
+scheme = "slate"
+toggle.icon = "lucide/moon"
+toggle.name = "Switch to light mode"
+
+[project.theme.icon]
+repo = "fontawesome/brands/github"
+favicon = "https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/favicon.ico?raw=true"
+
+[project.plugins.mkdocstrings.handlers.python]
+inventories = ["https://docs.python.org/3/objects.inv"]
+paths = ["."]
+
+[project.plugins.mkdocstrings.handlers.python.options]
+docstring_style = "google"
+inherited_members = true
+show_source = false
From 121b8874422c6d49074a0dfed964d3fe1d3aeed7 Mon Sep 17 00:00:00 2001
From: Barbier--Darnal Joseph
Date: Sun, 15 Mar 2026 20:06:58 +0100
Subject: [PATCH 3/5] migrate to zensical
---
.github/workflows/doc.yaml | 43 +++++++++++++++-----------------------
1 file changed, 17 insertions(+), 26 deletions(-)
diff --git a/.github/workflows/doc.yaml b/.github/workflows/doc.yaml
index abfcaf5..64cd7ab 100644
--- a/.github/workflows/doc.yaml
+++ b/.github/workflows/doc.yaml
@@ -1,41 +1,32 @@
-name: Build and deploy doc
-
+name: Deploy
on:
push:
- branches: [main]
+ branches:
+ - main
permissions:
contents: write
+concurrency:
+ group: gh-pages
+ cancel-in-progress: true
+
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: Configure Git Credentials
- run: |
- git config user.name github-actions[bot]
- git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ - uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: 3.x
- - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- - uses: actions/cache@v4
- with:
- key: mkdocs-material-${{ env.cache_id }}
- path: .cache
- restore-keys: |
- mkdocs-material-
+ - run: pip install zensical
+ - run: zensical build --clean
+ - name: Add CNAME for custom domain
+ run: echo "typst-in-production.com" > site/CNAME
- - name: Install uv
- uses: astral-sh/setup-uv@v5
+ - name: Deploy to gh-pages
+ uses: peaceiris/actions-gh-pages@v4
with:
- enable-cache: true
-
- - name: Install the project
- run: uv sync --all-groups
-
- - name: Deploy MkDocs
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: PYTHONPATH=$(pwd) uv run mkdocs gh-deploy --force
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_branch: gh-pages
+ publish_dir: site
From 09bd584387528eebdb7dffdfae9087aff4e48eb4 Mon Sep 17 00:00:00 2001
From: Barbier--Darnal Joseph
Date: Sun, 15 Mar 2026 20:59:28 +0100
Subject: [PATCH 4/5] fix legend ignored in area charts #9
---
.github/workflows/doc.yaml | 2 -
README.md | 234 +
docs/developers/svg-parser-reference.md | 21 +-
docs/iframes/CSS-2.html | 42 +-
docs/iframes/CSS.html | 42 +-
docs/iframes/area-natural-disasters.html | 42 +-
docs/iframes/bug.html | 42 +-
docs/iframes/javascript.html | 42 +-
docs/iframes/javascript2.html | 42 +-
docs/iframes/quickstart.html | 42 +-
docs/iframes/quickstart10.html | 42 +-
docs/iframes/quickstart11.html | 42 +-
docs/iframes/quickstart2.html | 42 +-
docs/iframes/quickstart3.html | 42 +-
docs/iframes/quickstart4.html | 42 +-
docs/iframes/quickstart5.html | 1590 +-
docs/iframes/quickstart6.html | 42 +-
docs/iframes/quickstart7.html | 42 +-
docs/iframes/quickstart8.html | 42 +-
docs/iframes/quickstart9.html | 42 +-
docs/iframes/random-walk-1.html | 75415 ++++++++--------
plotjs/plotjs.py | 8 +-
plotjs/static/plotparser.js | 40 +-
tests/test-browser/test_interactions.py | 45 +
tests/test-javascript/ParserSelectors.test.js | 36 +
tests/test-javascript/ParserSetHover.test.js | 59 +
26 files changed, 39360 insertions(+), 38762 deletions(-)
diff --git a/.github/workflows/doc.yaml b/.github/workflows/doc.yaml
index 64cd7ab..5a22095 100644
--- a/.github/workflows/doc.yaml
+++ b/.github/workflows/doc.yaml
@@ -21,8 +21,6 @@ jobs:
python-version: 3.x
- run: pip install zensical
- run: zensical build --clean
- - name: Add CNAME for custom domain
- run: echo "typst-in-production.com" > site/CNAME
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
diff --git a/README.md b/README.md
index c3362ec..cbe94f0 100644
--- a/README.md
+++ b/README.md
@@ -28,3 +28,237 @@ Latest dev version:
```
pip install git+https://github.com/y-sunflower/plotjs.git
```
+
+If you use `uv`:
+
+```bash
+uv add plotjs
+```
+
+## Why `plotjs`?
+
+`plotjs` keeps your existing matplotlib workflow and adds interactivity on top of the SVG that matplotlib already knows how to generate. Instead of rebuilding the chart in another library, you keep the same `Figure`, export it to HTML, and control the browser-side behavior with CSS and JavaScript.
+
+## Features Overview
+
+- Keep your existing matplotlib figure and export it as a standalone interactive HTML file
+- Add hover tooltips from any iterable of labels
+- Highlight related elements together with `groups=...`
+- Support scatter, line, bar, and area charts
+- Restrict interactivity to specific element types with `on=...`
+- Use direct hover or nearest-element hover with `hover_nearest=True`
+- Add custom CSS with strings, dictionaries, or files
+- Add custom JavaScript with strings or files, and optionally load D3.js
+- Work with multiple matplotlib axes in the same figure
+- Export either to disk with `save()` or to an HTML string with `as_html()`
+
+## Quickstart
+
+```python
+import matplotlib.pyplot as plt
+from plotjs import PlotJS, data
+
+df = data.load_iris()
+
+fig, ax = plt.subplots()
+ax.scatter(
+ df["sepal_length"],
+ df["sepal_width"],
+ c=df["species"].astype("category").cat.codes,
+ s=180,
+ alpha=0.6,
+ ec="black",
+)
+
+(
+ PlotJS(fig)
+ .add_tooltip(labels=df["species"])
+ .save("iris-scatter.html")
+)
+```
+
+Open `iris-scatter.html` in your browser to get hover tooltips and default highlight/fade behavior.
+
+## Reprex
+
+### 1. Group hover by category
+
+```python
+import matplotlib.pyplot as plt
+from plotjs import PlotJS, data
+
+df = data.load_iris()
+
+fig, ax = plt.subplots()
+ax.scatter(
+ df["petal_length"],
+ df["petal_width"],
+ c=df["species"].astype("category").cat.codes,
+ s=180,
+ alpha=0.6,
+ ec="black",
+)
+
+(
+ PlotJS(fig)
+ .add_tooltip(
+ labels=df["species"],
+ groups=df["species"],
+ )
+ .save("iris-grouped.html")
+)
+```
+
+All points from the same species will highlight together.
+
+### 2. Customize the tooltip and hover state with CSS
+
+```python
+import matplotlib.pyplot as plt
+from plotjs import PlotJS, data
+
+df = data.load_iris()
+labels = df.apply(
+ lambda row: (
+ f"{row['species']} "
+ f"petal_length = {row['petal_length']} "
+ f"petal_width = {row['petal_width']}"
+ ),
+ axis=1,
+)
+
+fig, ax = plt.subplots()
+ax.scatter(
+ df["petal_length"],
+ df["petal_width"],
+ c=df["species"].astype("category").cat.codes,
+ s=180,
+ alpha=0.6,
+ ec="black",
+)
+
+(
+ PlotJS(fig)
+ .add_tooltip(
+ labels=labels,
+ groups=df["species"],
+ hover_nearest=True,
+ on="point",
+ )
+ .add_css(
+ from_dict={
+ ".tooltip": {
+ "background": "#111827",
+ "color": "white",
+ "font-size": "0.95rem",
+ "text-align": "center",
+ },
+ ".point.hovered": {
+ "stroke": "#111827",
+ "stroke-width": "2px",
+ },
+ ".point.not-hovered": {
+ "opacity": "0.25",
+ },
+ }
+ )
+ .save("iris-custom.html")
+)
+```
+
+### 3. Add interactivity to multiple axes
+
+```python
+import matplotlib.pyplot as plt
+from plotjs import PlotJS, data
+
+df = data.load_iris()
+colors = df["species"].astype("category").cat.codes
+
+fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
+
+ax1.scatter(
+ df["sepal_length"],
+ df["sepal_width"],
+ c=colors,
+ s=120,
+ alpha=0.6,
+ ec="black",
+)
+ax2.scatter(
+ df["petal_length"],
+ df["petal_width"],
+ c=colors,
+ s=120,
+ alpha=0.6,
+ ec="black",
+)
+
+(
+ PlotJS(fig)
+ .add_tooltip(labels=df["species"], groups=df["species"], ax=ax1)
+ .add_tooltip(labels=df["species"], groups=df["species"], ax=ax2)
+ .save("iris-two-axes.html")
+)
+```
+
+### 4. Embed the chart elsewhere with `as_html()`
+
+```python
+import matplotlib.pyplot as plt
+from plotjs import PlotJS, data
+
+df = data.load_iris()
+
+fig, ax = plt.subplots()
+ax.scatter(df["sepal_length"], df["sepal_width"])
+
+html = (
+ PlotJS(fig)
+ .add_tooltip(labels=df["species"])
+ .as_html()
+)
+```
+
+This is useful for environments like marimo, or for embedding the output in your own app or webpage.
+
+## Supported Plot Elements
+
+`plotjs` currently detects these matplotlib SVG elements:
+
+- Scatter points
+- Lines
+- Bars
+- Filled areas
+
+These are also the main selectors you can target from CSS or JavaScript:
+
+- `.point`
+- `.line`
+- `.bar`
+- `.area`
+- `.plot-element`
+- `.tooltip`
+- `svg`
+
+## Important Limitation
+
+The order in which matplotlib draws elements must match the order of your `labels` and `groups`.
+
+If you plot data in chunks or loops, make sure the tooltip arrays follow the exact same order. When possible, plotting all elements at once is the safest option.
+
+## How It Works
+
+1. `plotjs` saves your matplotlib `Figure` as SVG.
+2. It injects that SVG, your tooltip metadata, and optional CSS/JavaScript into an HTML template.
+3. In the browser, JavaScript parses the SVG DOM and attaches hover effects, tooltips, and grouping behavior.
+
+## Documentation
+
+- [Getting started](docs/index.md)
+- [PlotJS API reference](docs/reference/plotjs.md)
+- [CSS guide](docs/guides/css/index.md)
+- [JavaScript guide](docs/guides/javascript/index.md)
+- [Embedding in Quarto, marimo, or websites](docs/guides/embed-graphs/index.md)
+- [Troubleshooting](docs/guides/troubleshooting/index.md)
+- [Developer architecture overview](docs/developers/overview.md)
diff --git a/docs/developers/svg-parser-reference.md b/docs/developers/svg-parser-reference.md
index 09915de..9ebc6fe 100644
--- a/docs/developers/svg-parser-reference.md
+++ b/docs/developers/svg-parser-reference.md
@@ -16,6 +16,9 @@ Provides basic DOM manipulation methods for working with SVG elements.
getPointerPosition(event, svgElement) ⇒ Array.<number>
Get mouse position relative to an SVG element.
+getFillValue(element) ⇒ string
+Extract the raw fill value from an SVG element.
+
findBars(svg, axes_class) ⇒ Selection
Find bar elements (patch groups with clipping) inside a given axes.
@@ -29,7 +32,9 @@ and assigns data-group attributes based on tooltip groups.
excluding axis grid lines.
findAreas(svg, axes_class) ⇒ Selection
-Find filled area elements (FillBetweenPolyCollection paths) inside a given axes.
+Find filled area elements (FillBetweenPolyCollection paths) inside a given axes.
+Also includes legend swatches whose fill matches the plotted areas so legend hover
+can target the same series as the chart area.
nearestElementFromMouse(mouseX, mouseY, elements) ⇒ Element | null
Compute the nearest element to the mouse cursor from a set of elements.
@@ -74,6 +79,18 @@ Get mouse position relative to an SVG element.
| event | MouseEvent | The mouse event |
| svgElement | Element \| [Selection](#Selection) | The SVG element or Selection |
+
+
+## getFillValue(element) ⇒ string
+Extract the raw fill value from an SVG element.
+
+**Kind**: global function
+**Returns**: string - Normalized fill value, or empty string if absent.
+
+| Param | Type | Description |
+| --- | --- | --- |
+| element | Element | SVG element to inspect. |
+
## findBars(svg, axes_class) ⇒ [Selection](#Selection)
@@ -121,6 +138,8 @@ excluding axis grid lines.
## findAreas(svg, axes_class) ⇒ [Selection](#Selection)
Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+Also includes legend swatches whose fill matches the plotted areas so legend hover
+can target the same series as the chart area.
**Kind**: global function
**Returns**: [Selection](#Selection) - Selection of area elements.
diff --git a/docs/iframes/CSS-2.html b/docs/iframes/CSS-2.html
index 61398ab..25a5efa 100644
--- a/docs/iframes/CSS-2.html
+++ b/docs/iframes/CSS-2.html
@@ -65,7 +65,7 @@
- 2026-02-02T14:52:23.438279
+ 2026-03-15T20:57:18.692897
image/svg+xml
@@ -1518,6 +1518,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1594,16 +1611,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/CSS.html b/docs/iframes/CSS.html
index 8c50f54..5c466a3 100644
--- a/docs/iframes/CSS.html
+++ b/docs/iframes/CSS.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:23.343865
+ 2026-03-15T20:57:18.640742
image/svg+xml
@@ -1514,6 +1514,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1590,16 +1607,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/area-natural-disasters.html b/docs/iframes/area-natural-disasters.html
index 0db646d..c6759e2 100644
--- a/docs/iframes/area-natural-disasters.html
+++ b/docs/iframes/area-natural-disasters.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:22.386313
+ 2026-03-15T20:57:17.845442
image/svg+xml
@@ -3822,6 +3822,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -3898,16 +3915,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/bug.html b/docs/iframes/bug.html
index 90eddf0..65014ce 100644
--- a/docs/iframes/bug.html
+++ b/docs/iframes/bug.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:20.263135
+ 2026-03-15T20:57:15.050060
image/svg+xml
@@ -933,6 +933,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1009,16 +1026,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/javascript.html b/docs/iframes/javascript.html
index 323e388..93738ec 100644
--- a/docs/iframes/javascript.html
+++ b/docs/iframes/javascript.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:24.191469
+ 2026-03-15T20:57:19.351940
image/svg+xml
@@ -1197,6 +1197,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1273,16 +1290,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/javascript2.html b/docs/iframes/javascript2.html
index a8cc1b2..40af739 100644
--- a/docs/iframes/javascript2.html
+++ b/docs/iframes/javascript2.html
@@ -68,7 +68,7 @@
- 2026-02-02T14:52:24.248147
+ 2026-03-15T20:57:19.393021
image/svg+xml
@@ -1268,6 +1268,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1344,16 +1361,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart.html b/docs/iframes/quickstart.html
index 81ceff7..8ae12fe 100644
--- a/docs/iframes/quickstart.html
+++ b/docs/iframes/quickstart.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:18.922576
+ 2026-03-15T20:57:13.886877
image/svg+xml
@@ -1197,6 +1197,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1273,16 +1290,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart10.html b/docs/iframes/quickstart10.html
index f4c033c..7860a77 100644
--- a/docs/iframes/quickstart10.html
+++ b/docs/iframes/quickstart10.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.859810
+ 2026-03-15T20:57:14.708540
image/svg+xml
@@ -933,6 +933,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1009,16 +1026,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart11.html b/docs/iframes/quickstart11.html
index f925231..4600c67 100644
--- a/docs/iframes/quickstart11.html
+++ b/docs/iframes/quickstart11.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:20.009575
+ 2026-03-15T20:57:14.836735
image/svg+xml
@@ -2351,6 +2351,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -2427,16 +2444,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart2.html b/docs/iframes/quickstart2.html
index 248a601..166ffd4 100644
--- a/docs/iframes/quickstart2.html
+++ b/docs/iframes/quickstart2.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:18.952473
+ 2026-03-15T20:57:13.909929
image/svg+xml
@@ -1197,6 +1197,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1273,16 +1290,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart3.html b/docs/iframes/quickstart3.html
index e08218e..efd64bf 100644
--- a/docs/iframes/quickstart3.html
+++ b/docs/iframes/quickstart3.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:18.983212
+ 2026-03-15T20:57:13.929348
image/svg+xml
@@ -1197,6 +1197,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1273,16 +1290,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart4.html b/docs/iframes/quickstart4.html
index f609ae6..ac933f9 100644
--- a/docs/iframes/quickstart4.html
+++ b/docs/iframes/quickstart4.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.019290
+ 2026-03-15T20:57:13.954602
image/svg+xml
@@ -1197,6 +1197,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1273,16 +1290,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart5.html b/docs/iframes/quickstart5.html
index a6b24ab..deb522a 100644
--- a/docs/iframes/quickstart5.html
+++ b/docs/iframes/quickstart5.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.054428
+ 2026-03-15T20:57:13.979453
image/svg+xml
@@ -343,12 +343,12 @@
" style="stroke: #000000; stroke-width: 0.8"/>
-
+
-
+
-
+
-
+
@@ -382,12 +382,12 @@
-
+
-
+
@@ -395,12 +395,12 @@
-
+
-
+
@@ -409,813 +409,759 @@
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
-
-
+
@@ -1413,6 +1359,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1489,16 +1452,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart6.html b/docs/iframes/quickstart6.html
index 846c620..bd9c5c0 100644
--- a/docs/iframes/quickstart6.html
+++ b/docs/iframes/quickstart6.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.204102
+ 2026-03-15T20:57:14.119874
image/svg+xml
@@ -978,6 +978,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1054,16 +1071,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart7.html b/docs/iframes/quickstart7.html
index 2c9d6c6..a645be7 100644
--- a/docs/iframes/quickstart7.html
+++ b/docs/iframes/quickstart7.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.290789
+ 2026-03-15T20:57:14.198616
image/svg+xml
@@ -1261,6 +1261,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1337,16 +1354,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart8.html b/docs/iframes/quickstart8.html
index 8be08ba..535bce9 100644
--- a/docs/iframes/quickstart8.html
+++ b/docs/iframes/quickstart8.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.456197
+ 2026-03-15T20:57:14.344118
image/svg+xml
@@ -1721,6 +1721,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1797,16 +1814,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/quickstart9.html b/docs/iframes/quickstart9.html
index 82020a6..1d85252 100644
--- a/docs/iframes/quickstart9.html
+++ b/docs/iframes/quickstart9.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:19.622193
+ 2026-03-15T20:57:14.503003
image/svg+xml
@@ -1896,6 +1896,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -1972,16 +1989,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/docs/iframes/random-walk-1.html b/docs/iframes/random-walk-1.html
index 1f3a181..e82f213 100644
--- a/docs/iframes/random-walk-1.html
+++ b/docs/iframes/random-walk-1.html
@@ -61,7 +61,7 @@
- 2026-02-02T14:52:22.497753
+ 2026-03-15T20:57:17.902450
image/svg+xml
@@ -361,12 +361,12 @@
" style="stroke: #000000; stroke-width: 0.8"/>
-
+
-
-
+
+
+
-
+
@@ -386,18998 +411,18770 @@
-
+
-
-
-
+
+
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
@@ -19414,13 +19211,13 @@
z
" style="fill: #ffffff; opacity: 0.8; stroke: #cccccc; stroke-linejoin: miter"/>
-
+
-
+
@@ -19515,31 +19312,6 @@
L 628 0
L 628 4666
z
-" transform="scale(0.015625)"/>
-
@@ -19550,13 +19322,13 @@
-
+
-
+
@@ -19605,13 +19377,13 @@
-
+
-
+
@@ -19752,13 +19524,13 @@
-
+
-
+
@@ -19835,13 +19607,13 @@
-
+
-
+
@@ -19970,12 +19742,12 @@
-
+
-
+
@@ -19983,12 +19755,12 @@
-
+
-
+
@@ -19999,12 +19771,12 @@
-
+
-
+
@@ -20015,12 +19787,12 @@
-
+
-
+
@@ -20031,12 +19803,12 @@
-
+
-
+
@@ -20047,12 +19819,12 @@
-
+
-
+
@@ -20065,18917 +19837,18718 @@
-
-
+
+
-
+
-
-
-
+
+
+
-
+
-
-
+
+
-
+
-
-
-
+
+
+
-
+
+
-
-
+
+
-
+
-
+
-
+
-
-
+
+
-
+
-
-
-
-
+
+
+
+
+
-
-
+
+
-
+
-
-
-
-
+
+
+
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
@@ -39000,27 +38573,27 @@
-
-
-
+
-
+
-
+
@@ -39029,15 +38602,15 @@
-
-
+
-
+
-
+
@@ -39045,15 +38618,15 @@
-
-
+
-
+
-
+
@@ -39063,15 +38636,15 @@
-
-
+
-
+
-
+
@@ -39082,15 +38655,15 @@
-
-
+
-
+
-
+
@@ -39279,6 +38852,23 @@
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -39355,16 +38945,35 @@
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/plotjs/plotjs.py b/plotjs/plotjs.py
index 2b97426..0d105dc 100644
--- a/plotjs/plotjs.py
+++ b/plotjs/plotjs.py
@@ -21,11 +21,11 @@
TEMPLATE_DIR: str = MAIN_DIR / "static"
CSS_PATH: str = os.path.join(TEMPLATE_DIR, "default.css")
JS_PARSER_PATH: str = os.path.join(TEMPLATE_DIR, "plotparser.js")
-
-env: Environment = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
DEFAULT_FAVICON_PATH = "https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/plotjs/favicon.ico?raw=true"
DEFAULT_DOCUMENT_TITLE = "Made with plotjs"
+env: Environment = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
+
class PlotJS:
"""
@@ -35,6 +35,7 @@ class PlotJS:
def __init__(
self,
fig: Figure | None = None,
+ _debug: bool = False,
**savefig_kws: dict,
):
"""
@@ -57,6 +58,9 @@ def __init__(
plt.rcParams["svg.hashsalt"] = "svg-hashsalt"
plt.rcParams["svg.id"] = "svg-id"
fig.savefig(buf, format="svg", **savefig_kws)
+
+ if _debug:
+ fig.savefig("debug-plotjs.svg", **savefig_kws)
finally:
plt.rcParams["svg.hashsalt"] = old_svg_hashsalt
plt.rcParams["svg.id"] = old_svg_id
diff --git a/plotjs/static/plotparser.js b/plotjs/static/plotparser.js
index 9fe96f8..0502872 100755
--- a/plotjs/static/plotparser.js
+++ b/plotjs/static/plotparser.js
@@ -160,6 +160,23 @@ export default class PlotSVGParser {
this.tooltip_y_shift = tooltip_y_shift;
}
+ /**
+ * Extract the raw fill value from an SVG element.
+ *
+ * @param {Element} element - SVG element to inspect.
+ * @returns {string} Normalized fill value, or empty string if absent.
+ */
+ getFillValue(element) {
+ const style = element?.getAttribute("style") ?? "";
+ const styleMatch = style.match(/(?:^|;)\s*fill:\s*([^;]+)/i);
+
+ if (styleMatch) {
+ return styleMatch[1].trim().toLowerCase();
+ }
+
+ return (element?.getAttribute("fill") ?? "").trim().toLowerCase();
+ }
+
/**
* Find bar elements (`patch` groups with clipping) inside a given axes.
*
@@ -236,16 +253,35 @@ export default class PlotSVGParser {
/**
* Find filled area elements (`FillBetweenPolyCollection` paths) inside a given axes.
+ * Also includes legend swatches whose fill matches the plotted areas so legend hover
+ * can target the same series as the chart area.
*
* @param {Selection} svg - Selection of the SVG element.
* @param {string} axes_class - ID of the axes group.
* @returns {Selection} Selection of area elements.
*/
findAreas(svg, axes_class) {
- // select all of FillBetweenPolyCollection elements within the specific axes
- const areas = svg.selectAll(
+ const parser = this;
+ const plottedAreas = svg.selectAll(
`g#${axes_class} g[id^="FillBetweenPolyCollection"] path`,
);
+ const areaFills = new Set(
+ plottedAreas
+ .nodes()
+ .map((element) => this.getFillValue(element))
+ .filter((fill) => fill && fill !== "none"),
+ );
+
+ const legendAreas = svg
+ .selectAll(`g#${axes_class} g[id^="legend"] g[id^="patch"] path`)
+ .filter(function () {
+ return areaFills.size > 0 && areaFills.has(parser.getFillValue(this));
+ });
+
+ const areas = new Selection([
+ ...plottedAreas.nodes(),
+ ...legendAreas.nodes(),
+ ]);
areas.attr("class", "area plot-element");
console.log(`Found ${areas.size()} "area" element`);
diff --git a/tests/test-browser/test_interactions.py b/tests/test-browser/test_interactions.py
index cc9fad6..c7dff4b 100644
--- a/tests/test-browser/test_interactions.py
+++ b/tests/test-browser/test_interactions.py
@@ -254,6 +254,51 @@ def test_line_chart_hover(page, tmp_output_dir, load_html):
assert len(line_paths) > 0, "Should have at least one line path"
+def test_area_legend_hover_highlights_matching_area(page, tmp_output_dir, load_html):
+ """Test that hovering an area legend swatch highlights the matching filled area."""
+ import numpy as np
+
+ x = np.arange(4)
+ y1 = np.array([2, 3, 2, 4])
+ y2 = np.array([1, 1.5, 1, 2])
+
+ fig, ax = plt.subplots()
+ ax.fill_between(x, y1, label="Series A")
+ ax.fill_between(x, y2, label="Series B")
+ ax.legend()
+
+ html_path = tmp_output_dir / "area_legend_hover.html"
+ PlotJS(fig).add_tooltip(
+ labels=["Series A", "Series B"],
+ groups=["Series A", "Series B"],
+ on="area",
+ ).save(str(html_path))
+ plt.close(fig)
+
+ load_html(page, html_path)
+
+ areas = page.locator('svg g[id^="FillBetweenPolyCollection"] path.plot-element')
+ legend_swatches = page.locator(
+ 'svg g[id^="legend"] g[id^="patch"] path.plot-element'
+ )
+
+ assert areas.count() == 2, f"Expected 2 plotted areas, got {areas.count()}"
+ assert legend_swatches.count() == 2, (
+ f"Expected 2 interactive legend swatches, got {legend_swatches.count()}"
+ )
+
+ legend_swatches.nth(0).hover(force=True)
+ page.wait_for_timeout(200)
+
+ first_area_classes = areas.nth(0).get_attribute("class") or ""
+ second_area_classes = areas.nth(1).get_attribute("class") or ""
+ assert "hovered" in first_area_classes, first_area_classes
+ assert "not-hovered" in second_area_classes, second_area_classes
+
+ tooltip = page.locator(".tooltip")
+ assert "Series A" in tooltip.inner_text()
+
+
def test_multiple_axes_independent_hover(page, tmp_output_dir, load_html):
"""Test that multiple axes have independent hover interactions."""
df = data.load_iris().head(6)
diff --git a/tests/test-javascript/ParserSelectors.test.js b/tests/test-javascript/ParserSelectors.test.js
index 91b8399..85e2050 100644
--- a/tests/test-javascript/ParserSelectors.test.js
+++ b/tests/test-javascript/ParserSelectors.test.js
@@ -347,6 +347,42 @@ describe("findAreas", () => {
expect(parser.findAreas(parser.svg, "axes_1").size()).toBe(1);
expect(parser.findAreas(parser.svg, "axes_2").size()).toBe(1);
});
+
+ test("should include matching legend swatches and exclude legend background", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const areas = parser.findAreas(parser.svg, "axes_1");
+
+ expect(areas.size()).toBe(4);
+ expect(areas.nodes().map((node) => node.parentNode.id)).toEqual([
+ "FillBetweenPolyCollection_1",
+ "FillBetweenPolyCollection_2",
+ "patch_6",
+ "patch_7",
+ ]);
+ });
});
describe("nearestElementFromMouse", () => {
diff --git a/tests/test-javascript/ParserSetHover.test.js b/tests/test-javascript/ParserSetHover.test.js
index 134aad7..25aac2b 100644
--- a/tests/test-javascript/ParserSetHover.test.js
+++ b/tests/test-javascript/ParserSetHover.test.js
@@ -344,6 +344,65 @@ describe("setHoverEffect", () => {
expect(tooltip.innerHTML).toBe("Area 1");
});
+ test("should link legend swatches to grouped area elements", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ const document = dom.window.document;
+ const svg = document.querySelector("svg");
+ const tooltip = document.querySelector("#tooltip");
+ const parser = new PlotSVGParser(svg, tooltip, 0, 0);
+
+ const areas = parser.findAreas(parser.svg, "axes_1");
+ parser.setHoverEffect(
+ areas,
+ "axes_1",
+ ["Series A", "Series B", "Series A", "Series B"],
+ ["Series A", "Series B", "Series A", "Series B"],
+ "block",
+ false,
+ );
+
+ const nodes = areas.nodes();
+ const firstLegendSwatch = nodes[2];
+ firstLegendSwatch.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: firstLegendSwatch,
+ }),
+ );
+
+ expect(nodes[0].classList.contains("hovered")).toBe(true);
+ expect(nodes[2].classList.contains("hovered")).toBe(true);
+ expect(nodes[1].classList.contains("not-hovered")).toBe(true);
+ expect(nodes[3].classList.contains("not-hovered")).toBe(true);
+ expect(tooltip.innerHTML).toBe("Series A");
+ });
+
test("should show correct label for each element", () => {
const dom = new JSDOM(`
From d53f2b72d2817daab30d4512d0585a8726f6b3d6 Mon Sep 17 00:00:00 2001
From: Barbier--Darnal Joseph
Date: Sun, 15 Mar 2026 21:11:03 +0100
Subject: [PATCH 5/5] update readme
---
README.md | 228 +++++---------------------------
docs/img/quick-start-readme.png | Bin 0 -> 137952 bytes
2 files changed, 31 insertions(+), 197 deletions(-)
create mode 100644 docs/img/quick-start-readme.png
diff --git a/README.md b/README.md
index cbe94f0..a413584 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,15 @@
-`plotjs` is a Python package that transform matplotlib plots into interactive charts with minimum user inputs. You can:
+`plotjs` is a Python package that transform matplotlib plots into interactive charts with minimum user inputs. It's very easy to use and highly extensible! It lets you:
- control tooltip labels and grouping
- add CSS
- add JavaScript
- and many more
-> Consider that the project is still **very unstable**.
+> [!IMPORTANT]
+> Consider that the project is still **unstable**.
[Online demo](https://y-sunflower.github.io/plotjs/)
@@ -29,31 +30,12 @@ Latest dev version:
pip install git+https://github.com/y-sunflower/plotjs.git
```
-If you use `uv`:
-
-```bash
-uv add plotjs
-```
-
-## Why `plotjs`?
-
-`plotjs` keeps your existing matplotlib workflow and adds interactivity on top of the SVG that matplotlib already knows how to generate. Instead of rebuilding the chart in another library, you keep the same `Figure`, export it to HTML, and control the browser-side behavior with CSS and JavaScript.
-
-## Features Overview
-
-- Keep your existing matplotlib figure and export it as a standalone interactive HTML file
-- Add hover tooltips from any iterable of labels
-- Highlight related elements together with `groups=...`
-- Support scatter, line, bar, and area charts
-- Restrict interactivity to specific element types with `on=...`
-- Use direct hover or nearest-element hover with `hover_nearest=True`
-- Add custom CSS with strings, dictionaries, or files
-- Add custom JavaScript with strings or files, and optionally load D3.js
-- Work with multiple matplotlib axes in the same figure
-- Export either to disk with `save()` or to an HTML string with `as_html()`
+
## Quickstart
+`plotjs` mainly provides a `PlotJS` class
+
```python
import matplotlib.pyplot as plt
from plotjs import PlotJS, data
@@ -77,188 +59,40 @@ ax.scatter(
)
```
-Open `iris-scatter.html` in your browser to get hover tooltips and default highlight/fade behavior.
-
-## Reprex
-
-### 1. Group hover by category
-
-```python
-import matplotlib.pyplot as plt
-from plotjs import PlotJS, data
-
-df = data.load_iris()
-
-fig, ax = plt.subplots()
-ax.scatter(
- df["petal_length"],
- df["petal_width"],
- c=df["species"].astype("category").cat.codes,
- s=180,
- alpha=0.6,
- ec="black",
-)
-
-(
- PlotJS(fig)
- .add_tooltip(
- labels=df["species"],
- groups=df["species"],
- )
- .save("iris-grouped.html")
-)
-```
-
-All points from the same species will highlight together.
-
-### 2. Customize the tooltip and hover state with CSS
-
-```python
-import matplotlib.pyplot as plt
-from plotjs import PlotJS, data
-
-df = data.load_iris()
-labels = df.apply(
- lambda row: (
- f"{row['species']} "
- f"petal_length = {row['petal_length']} "
- f"petal_width = {row['petal_width']}"
- ),
- axis=1,
-)
-
-fig, ax = plt.subplots()
-ax.scatter(
- df["petal_length"],
- df["petal_width"],
- c=df["species"].astype("category").cat.codes,
- s=180,
- alpha=0.6,
- ec="black",
-)
-
-(
- PlotJS(fig)
- .add_tooltip(
- labels=labels,
- groups=df["species"],
- hover_nearest=True,
- on="point",
- )
- .add_css(
- from_dict={
- ".tooltip": {
- "background": "#111827",
- "color": "white",
- "font-size": "0.95rem",
- "text-align": "center",
- },
- ".point.hovered": {
- "stroke": "#111827",
- "stroke-width": "2px",
- },
- ".point.not-hovered": {
- "opacity": "0.25",
- },
- }
- )
- .save("iris-custom.html")
-)
-```
-
-### 3. Add interactivity to multiple axes
-
-```python
-import matplotlib.pyplot as plt
-from plotjs import PlotJS, data
-
-df = data.load_iris()
-colors = df["species"].astype("category").cat.codes
-
-fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
-
-ax1.scatter(
- df["sepal_length"],
- df["sepal_width"],
- c=colors,
- s=120,
- alpha=0.6,
- ec="black",
-)
-ax2.scatter(
- df["petal_length"],
- df["petal_width"],
- c=colors,
- s=120,
- alpha=0.6,
- ec="black",
-)
-
-(
- PlotJS(fig)
- .add_tooltip(labels=df["species"], groups=df["species"], ax=ax1)
- .add_tooltip(labels=df["species"], groups=df["species"], ax=ax2)
- .save("iris-two-axes.html")
-)
-```
-
-### 4. Embed the chart elsewhere with `as_html()`
+
-```python
-import matplotlib.pyplot as plt
-from plotjs import PlotJS, data
-
-df = data.load_iris()
-
-fig, ax = plt.subplots()
-ax.scatter(df["sepal_length"], df["sepal_width"])
-
-html = (
- PlotJS(fig)
- .add_tooltip(labels=df["species"])
- .as_html()
-)
-```
-
-This is useful for environments like marimo, or for embedding the output in your own app or webpage.
-
-## Supported Plot Elements
-
-`plotjs` currently detects these matplotlib SVG elements:
+Open `iris-scatter.html` in your browser to get hover tooltips and default highlight/fade behavior.
-- Scatter points
-- Lines
-- Bars
-- Filled areas
+
-These are also the main selectors you can target from CSS or JavaScript:
+## Why `plotjs`?
-- `.point`
-- `.line`
-- `.bar`
-- `.area`
-- `.plot-element`
-- `.tooltip`
-- `svg`
+`plotjs` keeps your existing matplotlib workflow and adds interactivity on top of the SVG that matplotlib already knows how to generate. Instead of rebuilding the chart in another library, you keep the same `Figure`, export it to HTML, and control the browser-side behavior with CSS and JavaScript.
-## Important Limitation
+Learn more in the [Q&A](https://y-sunflower.github.io/plotjs/#qa).
-The order in which matplotlib draws elements must match the order of your `labels` and `groups`.
+
-If you plot data in chunks or loops, make sure the tooltip arrays follow the exact same order. When possible, plotting all elements at once is the safest option.
+## Features Overview
-## How It Works
+- Keep your existing matplotlib figure and export it as a standalone interactive HTML file
+- Add hover tooltips from any iterable of labels
+- Highlight related elements together with `groups=...`
+- Restrict interactivity to specific element types with `on=...`
+- Use direct hover or nearest-element hover with `hover_nearest=True`
+- Add custom CSS with strings, dictionaries, or files
+- Add custom JavaScript with strings or files, and optionally load D3.js
+- Work with multiple matplotlib axes in the same figure
+- Export either to disk with `save()` or to an HTML string with `as_html()`
-1. `plotjs` saves your matplotlib `Figure` as SVG.
-2. It injects that SVG, your tooltip metadata, and optional CSS/JavaScript into an HTML template.
-3. In the browser, JavaScript parses the SVG DOM and attaches hover effects, tooltips, and grouping behavior.
+
## Documentation
-- [Getting started](docs/index.md)
-- [PlotJS API reference](docs/reference/plotjs.md)
-- [CSS guide](docs/guides/css/index.md)
-- [JavaScript guide](docs/guides/javascript/index.md)
-- [Embedding in Quarto, marimo, or websites](docs/guides/embed-graphs/index.md)
-- [Troubleshooting](docs/guides/troubleshooting/index.md)
-- [Developer architecture overview](docs/developers/overview.md)
+- [Getting started](https://y-sunflower.github.io/plotjs/)
+- [PlotJS API reference](https://y-sunflower.github.io/plotjs/reference/plotjs)
+- [CSS guide](https://y-sunflower.github.io/plotjs/guides/css/)
+- [JavaScript guide](https://y-sunflower.github.io/plotjs/guides/javascript/)
+- [Embedding in Quarto, marimo, or websites](https://y-sunflower.github.io/plotjs/guides/embed-graphs/)
+- [Troubleshooting](https://y-sunflower.github.io/plotjs/guides/troubleshooting/)
+- [Developer architecture overview](https://y-sunflower.github.io/plotjs/developers/overview)
diff --git a/docs/img/quick-start-readme.png b/docs/img/quick-start-readme.png
new file mode 100644
index 0000000000000000000000000000000000000000..361e6ef368bdaa1bcdd88ad9130e30b3a108258c
GIT binary patch
literal 137952
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 4s6Xs_^kMHHq
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