diff --git a/.github/workflows/doc.yaml b/.github/workflows/doc.yaml
index abfcaf5..5a22095 100644
--- a/.github/workflows/doc.yaml
+++ b/.github/workflows/doc.yaml
@@ -1,41 +1,30 @@
-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: 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
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/AGENT.md b/AGENTS.md
similarity index 100%
rename from AGENT.md
rename to AGENTS.md
diff --git a/README.md b/README.md
index c3362ec..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/)
@@ -28,3 +29,70 @@ Latest dev version:
```
pip install git+https://github.com/y-sunflower/plotjs.git
```
+
+
+
+## Quickstart
+
+`plotjs` mainly provides a `PlotJS` class
+
+```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.
+
+
+
+## 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.
+
+Learn more in the [Q&A](https://y-sunflower.github.io/plotjs/#qa).
+
+
+
+## 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=...`
+- 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()`
+
+
+
+## Documentation
+
+- [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/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/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.
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/docs/img/quick-start-readme.png b/docs/img/quick-start-readme.png
new file mode 100644
index 0000000..361e6ef
Binary files /dev/null and b/docs/img/quick-start-readme.png differ
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/plotjs/plotjs.py b/plotjs/plotjs.py
index 21f653d..0d105dc 100644
--- a/plotjs/plotjs.py
+++ b/plotjs/plotjs.py
@@ -21,6 +21,8 @@
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")
+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))
@@ -33,6 +35,7 @@ class PlotJS:
def __init__(
self,
fig: Figure | None = None,
+ _debug: bool = False,
**savefig_kws: dict,
):
"""
@@ -51,11 +54,16 @@ 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)
+
+ if _debug:
+ fig.savefig("debug-plotjs.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 +76,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 +191,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 +354,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 +387,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 +443,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/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/pyproject.toml b/pyproject.toml
index 0079498..5ebdca4 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,9 @@ dev = [
"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]
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(`
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 "