diff --git a/.gitattributes b/.gitattributes
index 19f4548..28cee3f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1 @@
*.html linguist-detectable=false
-test/* linguist-detectable=false
diff --git a/AGENT.md b/AGENT.md
index 2fcdaee..81ec47c 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -12,12 +12,18 @@ PlotJS is a Python package that transforms static matplotlib charts into interac
Matplotlib Figure → SVG Export (Python) → HTML Template (Jinja2) → Interactive Browser
```
-### Workflow:
-
1. **Python (PlotJS class):** Captures matplotlib figure as SVG string, collects tooltip/styling metadata
2. **Jinja2 Template:** Injects SVG + CSS + JavaScript parser + configuration into HTML
3. **Browser (PlotSVGParser):** Parses SVG structure to identify plot elements, attaches hover interactivity
+## How to run command
+
+Always use `uv` and/or `just` for running commands:
+
+- `uv run pytest tests/test-python`
+- `uv run pytest tests/test-python`
+- `uv run python -c "import matplotlib"`
+
## Key Components
### Python Module (`/plotjs/`)
@@ -125,17 +131,17 @@ Optional `seed` parameter ensures deterministic UUID generation for consistent o
```
plotjs/
├── __init__.py # Package exports
-├── plotjs.py # Core PlotJS class (330 lines)
-├── css.py # CSS utilities (100 lines)
-├── javascript.py # JavaScript utilities (23 lines)
-├── utils.py # Internal helpers (43 lines)
+├── plotjs.py # Core PlotJS class
+├── css.py # CSS utilities
+├── javascript.py # JavaScript utilities
+├── utils.py # Internal helpers
├── data/
│ ├── datasets.py # Sample datasets with Narwhals
│ └── *.csv # Data files
└── static/
- ├── template.html # Jinja2 HTML template (104 lines)
- ├── plotparser.js # SVG parser class (229 lines)
- └── default.css # Default styles (41 lines)
+ ├── template.html # Jinja2 HTML template
+ ├── plotparser.js # SVG parser class
+ └── default.css # Default styles
tests/
├── test-python/ # Python unit tests
diff --git a/LICENSE b/LICENSE
index 2131159..d147fc3 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 Joseph Barbier
+Copyright (c) 2026 Joseph Barbier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/plotjs/plotjs.py b/plotjs/plotjs.py
index 0767bf8..87df0cf 100644
--- a/plotjs/plotjs.py
+++ b/plotjs/plotjs.py
@@ -22,7 +22,7 @@
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(str(TEMPLATE_DIR)))
+env: Environment = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
class PlotJS:
@@ -89,6 +89,7 @@ def add_tooltip(
tooltip_x_shift: int = 0,
tooltip_y_shift: int = 0,
hover_nearest: bool = False,
+ on: str | list[str] | None = None,
ax: Axes | None = None,
) -> "PlotJS":
"""
@@ -108,6 +109,10 @@ def add_tooltip(
tooltip_y_shift: Number of pixels to shift the tooltip from
the cursor, on the y axis.
hover_nearest: When `True`, hover the nearest plot element.
+ on: Which plot elements to apply interactivity to. Can be a
+ single element type or a list. Valid values are "point",
+ "line", "bar", "area" (plurals like "points" also accepted).
+ If `None` (default), applies to all element types.
ax: A matplotlib Axes. If `None` (default), uses first Axes.
Returns:
@@ -133,10 +138,51 @@ def add_tooltip(
hover_nearest=True,
)
```
+
+ ```python
+ PlotJS(...).add_tooltip(
+ labels=["S&P500", "CAC40", "Sunflower"],
+ on="point", # only apply hover to points
+ )
+ ```
+
+ ```python
+ PlotJS(...).add_tooltip(
+ labels=["S&P500", "CAC40", "Sunflower"],
+ on=["point", "line"], # apply hover to points and lines only
+ )
+ ```
"""
self._tooltip_x_shift = tooltip_x_shift
self._tooltip_y_shift = tooltip_y_shift
+ # Normalize and validate the `on` parameter
+ valid_elements = {"point", "line", "bar", "area"}
+ plural_to_singular = {
+ "points": "point",
+ "lines": "line",
+ "bars": "bar",
+ "areas": "area",
+ }
+
+ if on is None:
+ normalized_on = None
+ else:
+ if isinstance(on, str):
+ on = [on]
+ normalized_on = []
+ for element in on:
+ element = element.lower()
+ element = plural_to_singular.get(element, element)
+ if element not in valid_elements:
+ raise ValueError(
+ f"Invalid element type '{element}' in `on` parameter. "
+ f"Valid values are: {', '.join(sorted(valid_elements))} "
+ f"(plurals also accepted)."
+ )
+ if element not in normalized_on:
+ normalized_on.append(element)
+
if ax is None:
ax: Axes = self._axes[0]
self._legend_handles, self._legend_handles_labels = (
@@ -162,6 +208,7 @@ def add_tooltip(
"tooltip_labels": self._tooltip_labels,
"tooltip_groups": self._tooltip_groups,
"hover_nearest": "true" if hover_nearest else "false", # js boolean
+ "on": normalized_on, # None means all elements, otherwise list of element types
}
}
self._axes_tooltip.update(axe_tooltip)
diff --git a/plotjs/static/template.html b/plotjs/static/template.html
index 29e4908..8be77e7 100644
--- a/plotjs/static/template.html
+++ b/plotjs/static/template.html
@@ -60,6 +60,7 @@
const tooltip_groups = axe_data["tooltip_groups"];
const hover_nearest = axe_data["hover_nearest"] === "true";
const show_tooltip = tooltip_labels.length === 0 ? "none" : "block";
+ const on = axe_data["on"] ?? null; // null/undefined means all elements, otherwise array of element types
console.log(`PlotJS: - ${tooltip_labels.length} tooltip labels`);
console.log(`PlotJS: - ${tooltip_groups.length} tooltip groups`);
@@ -67,15 +68,26 @@
console.log(
`PlotJS: - Show tooltips: ${show_tooltip === "block"}`,
);
-
- const lines = plotParser.findLines(plotParser.svg, axes_class);
- const bars = plotParser.findBars(plotParser.svg, axes_class);
- const points = plotParser.findPoints(
- plotParser.svg,
- axes_class,
- tooltip_groups,
+ console.log(
+ `PlotJS: - Element filter (on): ${on === null ? "all" : on.join(", ")}`,
);
- const areas = plotParser.findAreas(plotParser.svg, axes_class);
+
+ // Helper to check if an element type should be processed
+ const shouldProcess = (elementType) =>
+ on === null || on.includes(elementType);
+
+ const lines = shouldProcess("line")
+ ? plotParser.findLines(plotParser.svg, axes_class)
+ : new Selection([]);
+ const bars = shouldProcess("bar")
+ ? plotParser.findBars(plotParser.svg, axes_class)
+ : new Selection([]);
+ const points = shouldProcess("point")
+ ? plotParser.findPoints(plotParser.svg, axes_class, tooltip_groups)
+ : new Selection([]);
+ const areas = shouldProcess("area")
+ ? plotParser.findAreas(plotParser.svg, axes_class)
+ : new Selection([]);
const totalElements =
lines.size() + bars.size() + points.size() + areas.size();
diff --git a/tests/test-browser/test_interactions.py b/tests/test-browser/test_interactions.py
index 667e907..cc9fad6 100644
--- a/tests/test-browser/test_interactions.py
+++ b/tests/test-browser/test_interactions.py
@@ -293,3 +293,137 @@ def test_multiple_axes_independent_hover(page, tmp_output_dir, load_html):
tooltip_text = tooltip.inner_text()
# Just verify that tooltip updated (should show "Right" label from second axes)
assert "Right" in tooltip_text, f"Expected 'Right' in tooltip, got: {tooltip_text}"
+
+
+def test_on_parameter_point_only(page, tmp_output_dir, load_html):
+ """Test that on='point' only enables hover on points, not lines."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+ ax.plot([1, 2, 3], [3, 2, 1])
+
+ html_path = tmp_output_dir / "on_point_only.html"
+ PlotJS(fig).add_tooltip(labels=["P1", "P2", "P3", "Line"], on="point").save(
+ str(html_path)
+ )
+ plt.close(fig)
+
+ load_html(page, html_path)
+
+ # Points should have the plot-element class
+ points = page.locator('svg g[id^="PathCollection"] use.plot-element')
+ assert points.count() == 3, (
+ f"Expected 3 points with plot-element class, got {points.count()}"
+ )
+
+ # Lines should NOT have the plot-element class (since on="point")
+ lines = page.locator('svg g[id^="line2d"] path.plot-element')
+ assert lines.count() == 0, (
+ f"Expected 0 lines with plot-element class, got {lines.count()}"
+ )
+
+ # Hover over point - should show tooltip
+ first_point = page.locator('svg g[id^="PathCollection"] use').first
+ first_point.hover()
+ page.wait_for_selector(".tooltip[style*='display: block']", timeout=2000)
+ tooltip = page.locator(".tooltip")
+ assert tooltip.is_visible()
+
+
+def test_on_parameter_line_only(page, tmp_output_dir, load_html):
+ """Test that on='line' only enables hover on lines, not points."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+ ax.plot([1, 2, 3], [3, 2, 1])
+
+ html_path = tmp_output_dir / "on_line_only.html"
+ PlotJS(fig).add_tooltip(labels=["Line"], on="line").save(str(html_path))
+ plt.close(fig)
+
+ load_html(page, html_path)
+
+ # Points should NOT have the plot-element class (since on="line")
+ points = page.locator('svg g[id^="PathCollection"] use.plot-element')
+ assert points.count() == 0, (
+ f"Expected 0 points with plot-element class, got {points.count()}"
+ )
+
+ # Lines should have the plot-element class
+ lines = page.locator('svg g[id^="line2d"] path.plot-element')
+ assert lines.count() >= 1, (
+ f"Expected at least 1 line with plot-element class, got {lines.count()}"
+ )
+
+
+def test_on_parameter_multiple_types(page, tmp_output_dir, load_html):
+ """Test that on=['point', 'line'] enables hover on both."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+ ax.plot([1, 2, 3], [3, 2, 1])
+
+ html_path = tmp_output_dir / "on_multiple.html"
+ PlotJS(fig).add_tooltip(
+ labels=["P1", "P2", "P3", "Line"], on=["point", "line"]
+ ).save(str(html_path))
+ plt.close(fig)
+
+ load_html(page, html_path)
+
+ # Both points and lines should have the plot-element class
+ points = page.locator('svg g[id^="PathCollection"] use.plot-element')
+ assert points.count() == 3, (
+ f"Expected 3 points with plot-element class, got {points.count()}"
+ )
+
+ lines = page.locator('svg g[id^="line2d"] path.plot-element')
+ assert lines.count() >= 1, (
+ f"Expected at least 1 line with plot-element class, got {lines.count()}"
+ )
+
+
+def test_on_parameter_none_enables_all(page, tmp_output_dir, load_html):
+ """Test that on=None (default) enables hover on all element types."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+ ax.plot([1, 2, 3], [3, 2, 1])
+
+ html_path = tmp_output_dir / "on_none.html"
+ PlotJS(fig).add_tooltip(labels=["P1", "P2", "P3", "Line"]).save(str(html_path))
+ plt.close(fig)
+
+ load_html(page, html_path)
+
+ # Both points and lines should have the plot-element class
+ points = page.locator('svg g[id^="PathCollection"] use.plot-element')
+ assert points.count() == 3, (
+ f"Expected 3 points with plot-element class, got {points.count()}"
+ )
+
+ lines = page.locator('svg g[id^="line2d"] path.plot-element')
+ assert lines.count() >= 1, (
+ f"Expected at least 1 line with plot-element class, got {lines.count()}"
+ )
+
+
+def test_on_parameter_bar_only(page, tmp_output_dir, load_html):
+ """Test that on='bar' only enables hover on bars."""
+ fig, ax = plt.subplots()
+ ax.bar([1, 2, 3], [1, 2, 3])
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ html_path = tmp_output_dir / "on_bar_only.html"
+ PlotJS(fig).add_tooltip(labels=["B1", "B2", "B3"], on="bar").save(str(html_path))
+ plt.close(fig)
+
+ load_html(page, html_path)
+
+ # Bars should have the plot-element class
+ bars = page.locator("svg .bar.plot-element")
+ assert bars.count() == 3, (
+ f"Expected 3 bars with plot-element class, got {bars.count()}"
+ )
+
+ # Points should NOT have the plot-element class
+ points = page.locator('svg g[id^="PathCollection"] use.plot-element')
+ assert points.count() == 0, (
+ f"Expected 0 points with plot-element class, got {points.count()}"
+ )
diff --git a/tests/test-javascript/EdgeCases.test.js b/tests/test-javascript/EdgeCases.test.js
new file mode 100644
index 0000000..415d3bc
--- /dev/null
+++ b/tests/test-javascript/EdgeCases.test.js
@@ -0,0 +1,394 @@
+import { expect, test, describe } from "bun:test";
+import { JSDOM } from "jsdom";
+import PlotSVGParser from "../../plotjs/static/plotparser.js";
+
+describe("Edge cases", () => {
+ describe("Empty and null handling", () => {
+ test("findBars with nonexistent axes returns empty selection", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const bars = parser.findBars(parser.svg, "nonexistent_axes");
+ expect(bars.size()).toBe(0);
+ expect(bars.empty()).toBe(true);
+ });
+
+ test("findPoints with nonexistent axes returns empty selection", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const points = parser.findPoints(parser.svg, "nonexistent_axes", []);
+ expect(points.size()).toBe(0);
+ });
+
+ test("findLines with nonexistent axes returns empty selection", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const lines = parser.findLines(parser.svg, "nonexistent_axes");
+ expect(lines.size()).toBe(0);
+ });
+
+ test("findAreas with nonexistent axes returns empty selection", () => {
+ 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, "nonexistent_axes");
+ expect(areas.size()).toBe(0);
+ });
+ });
+
+ describe("Complex SVG structures", () => {
+ test("deeply nested PathCollection elements are found", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+
+ expect(points.size()).toBe(1);
+ });
+
+ test("multiple PathCollections with different depths", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", ["A", "B", "C"]);
+
+ expect(points.size()).toBe(3);
+ });
+
+ test("mixed element types in same axes", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ expect(parser.findBars(parser.svg, "axes_1").size()).toBe(1);
+ expect(parser.findPoints(parser.svg, "axes_1", ["G1"]).size()).toBe(1);
+ expect(parser.findLines(parser.svg, "axes_1").size()).toBe(1);
+ expect(parser.findAreas(parser.svg, "axes_1").size()).toBe(1);
+ });
+ });
+
+ describe("Selection edge cases", () => {
+ test("chaining multiple operations", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const result = parser.svg
+ .select("#axes_1")
+ .select("rect")
+ .attr("data-value", "123")
+ .classed("highlighted", true)
+ .style("opacity", "0.5");
+
+ expect(result.attr("data-value")).toBe("123");
+ expect(result.classed("highlighted")).toBe(true);
+ expect(result.classed("test")).toBe(true);
+ });
+
+ test("selectAll from empty selection returns empty", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const empty = parser.svg.selectAll(".nonexistent");
+ const nested = empty.selectAll("path");
+
+ expect(nested.size()).toBe(0);
+ });
+
+ test("filter with false predicate returns empty selection", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const filtered = parser.svg.selectAll("rect").filter(() => false);
+ expect(filtered.size()).toBe(0);
+ });
+
+ test("each with no elements does nothing", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ let called = false;
+ parser.svg.selectAll(".nonexistent").each(() => {
+ called = true;
+ });
+
+ expect(called).toBe(false);
+ });
+ });
+
+ describe("Special characters in selectors", () => {
+ test("axes with underscore in id", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1_subplot", ["G1"]);
+
+ expect(points.size()).toBe(1);
+ });
+ });
+
+ describe("Hover with multiple groups", () => {
+ test("all elements in same group get hovered class", () => {
+ 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);
+
+ // 4 points: 2 in GroupA, 2 in GroupB
+ const groups = ["GroupA", "GroupA", "GroupB", "GroupB"];
+ const labels = ["A1", "A2", "B1", "B2"];
+ const points = parser.findPoints(parser.svg, "axes_1", groups);
+
+ parser.setHoverEffect(points, "axes_1", labels, groups, "block", false);
+
+ // Hover third element (first of GroupB)
+ const thirdPoint = points.nodes()[2];
+ thirdPoint.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ currentTarget: thirdPoint,
+ }),
+ );
+
+ const nodes = points.nodes();
+ // GroupA elements should be not-hovered
+ expect(nodes[0].classList.contains("not-hovered")).toBe(true);
+ expect(nodes[1].classList.contains("not-hovered")).toBe(true);
+ // GroupB elements should be hovered
+ expect(nodes[2].classList.contains("hovered")).toBe(true);
+ expect(nodes[3].classList.contains("hovered")).toBe(true);
+ });
+
+ test("single element group", () => {
+ 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);
+
+ // Each element in its own group
+ const groups = ["G1", "G2", "G3"];
+ const points = parser.findPoints(parser.svg, "axes_1", groups);
+
+ parser.setHoverEffect(
+ points,
+ "axes_1",
+ ["L1", "L2", "L3"],
+ groups,
+ "block",
+ false,
+ );
+
+ const secondPoint = points.nodes()[1];
+ secondPoint.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ currentTarget: secondPoint,
+ }),
+ );
+
+ const nodes = points.nodes();
+ expect(nodes[0].classList.contains("not-hovered")).toBe(true);
+ expect(nodes[1].classList.contains("hovered")).toBe(true);
+ expect(nodes[2].classList.contains("not-hovered")).toBe(true);
+ });
+ });
+
+ describe("Multiple axes independence", () => {
+ test("hover on one axes does not affect another", () => {
+ 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 points1 = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+ const points2 = parser.findPoints(parser.svg, "axes_2", ["G2"]);
+
+ parser.setHoverEffect(
+ points1,
+ "axes_1",
+ ["Axes1 Point"],
+ ["G1"],
+ "block",
+ false,
+ );
+ parser.setHoverEffect(
+ points2,
+ "axes_2",
+ ["Axes2 Point"],
+ ["G2"],
+ "block",
+ false,
+ );
+
+ // Hover point in axes_1
+ const point1 = points1.nodes()[0];
+ point1.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ currentTarget: point1,
+ }),
+ );
+
+ expect(point1.classList.contains("hovered")).toBe(true);
+ // axes_2 point should not be affected
+ expect(points2.nodes()[0].classList.contains("hovered")).toBe(false);
+ expect(tooltip.innerHTML).toBe("Axes1 Point");
+ });
+ });
+
+ describe("nearestElementFromMouse edge cases", () => {
+ test("with elements at same distance returns first", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const rects = parser.svg.selectAll("rect");
+
+ rects.nodes().forEach((rect) => {
+ rect.getBBox = () => ({
+ x: parseFloat(rect.getAttribute("x")),
+ y: parseFloat(rect.getAttribute("y")),
+ width: parseFloat(rect.getAttribute("width")),
+ height: parseFloat(rect.getAttribute("height")),
+ });
+ });
+
+ // Mouse at x=10, y=5 - equidistant from both centers (5,5) and (15,5)
+ const nearest = parser.nearestElementFromMouse(10, 5, rects);
+ // First element should be returned when distances are equal
+ expect(nearest.id).toBe("r1");
+ });
+
+ test("with elements at different y positions", () => {
+ const dom = new JSDOM(``);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const rects = parser.svg.selectAll("rect");
+
+ rects.nodes().forEach((rect) => {
+ rect.getBBox = () => ({
+ x: parseFloat(rect.getAttribute("x")),
+ y: parseFloat(rect.getAttribute("y")),
+ width: parseFloat(rect.getAttribute("width")),
+ height: parseFloat(rect.getAttribute("height")),
+ });
+ });
+
+ // Mouse at (5, 90) - closer to r2 (center at 5, 105)
+ const nearest = parser.nearestElementFromMouse(5, 90, rects);
+ expect(nearest.id).toBe("r2");
+ });
+ });
+});
diff --git a/tests/test-javascript/ParserSelectors.test.js b/tests/test-javascript/ParserSelectors.test.js
index c7283b7..91b8399 100644
--- a/tests/test-javascript/ParserSelectors.test.js
+++ b/tests/test-javascript/ParserSelectors.test.js
@@ -1,50 +1,430 @@
-import { expect, test } from "bun:test";
+import { expect, test, describe } from "bun:test";
import { JSDOM } from "jsdom";
import PlotSVGParser from "../../plotjs/static/plotparser.js";
-test("findBars should select only patches with clip-path", () => {
- const dom = new JSDOM(``);
+ `);
- const svg = dom.window.document.querySelector("svg");
- const parser = new PlotSVGParser(svg, null, 0, 0);
- const bars = parser.findBars(parser.svg, "axes_1");
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const bars = parser.findBars(parser.svg, "axes_1");
- expect(bars.size()).toBe(1);
- bars.each(function () {
- expect(this.getAttribute("class")).toBe("bar plot-element");
+ expect(bars.size()).toBe(0);
+ });
+
+ test("should find multiple bars", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const bars = parser.findBars(parser.svg, "axes_1");
+
+ expect(bars.size()).toBe(3);
+ });
+
+ test("should only find bars within specified axes", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ const bars1 = parser.findBars(parser.svg, "axes_1");
+ expect(bars1.size()).toBe(1);
+
+ const bars2 = parser.findBars(parser.svg, "axes_2");
+ expect(bars2.size()).toBe(2);
});
});
-test("findPoints should set data-group and class", () => {
- const dom = new JSDOM(`
-
-
-
-
-
+describe("findPoints", () => {
+ test("should set data-group and class with use elements", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", ["A", "B"]);
+
+ expect(points.size()).toBe(2);
+ const nodes = points.nodes();
+ expect(nodes[0].getAttribute("data-group")).toBe("A");
+ expect(nodes[1].getAttribute("data-group")).toBe("B");
+ points.each(function () {
+ expect(this.getAttribute("class")).toBe("point plot-element");
+ });
+ });
+
+ test("should fallback to path elements when no use elements", () => {
+ const dom = new JSDOM(`
+
+
+
+
-
- `);
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", ["X", "Y"]);
+
+ expect(points.size()).toBe(2);
+ const nodes = points.nodes();
+ expect(nodes[0].getAttribute("data-group")).toBe("X");
+ expect(nodes[1].getAttribute("data-group")).toBe("Y");
+ expect(nodes[0].getAttribute("class")).toBe("point plot-element");
+ });
+
+ test("should return empty selection when no points", () => {
+ const dom = new JSDOM(`
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", []);
+
+ expect(points.size()).toBe(0);
+ });
+
+ test("should find points from multiple PathCollections", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", ["G1", "G2"]);
+
+ expect(points.size()).toBe(2);
+ });
+
+ test("should handle mixed group values", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const points = parser.findPoints(parser.svg, "axes_1", [
+ "GroupA",
+ "GroupB",
+ "GroupA",
+ ]);
+
+ const nodes = points.nodes();
+ expect(nodes[0].getAttribute("data-group")).toBe("GroupA");
+ expect(nodes[1].getAttribute("data-group")).toBe("GroupB");
+ expect(nodes[2].getAttribute("data-group")).toBe("GroupA");
+ });
+});
+
+describe("findLines", () => {
+ test("should find line2d path elements", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const lines = parser.findLines(parser.svg, "axes_1");
+
+ expect(lines.size()).toBe(2);
+ lines.each(function () {
+ expect(this.getAttribute("class")).toBe("line plot-element");
+ });
+ });
+
+ test("should exclude axis grid lines", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const lines = parser.findLines(parser.svg, "axes_1");
+
+ expect(lines.size()).toBe(1);
+ });
+
+ test("should return empty selection when no lines", () => {
+ const dom = new JSDOM(`
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const lines = parser.findLines(parser.svg, "axes_1");
+
+ expect(lines.size()).toBe(0);
+ });
+
+ test("should only find lines in specified axes", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ expect(parser.findLines(parser.svg, "axes_1").size()).toBe(1);
+ expect(parser.findLines(parser.svg, "axes_2").size()).toBe(2);
+ });
+});
+
+describe("findAreas", () => {
+ test("should find FillBetweenPolyCollection path elements", () => {
+ 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(1);
+ areas.each(function () {
+ expect(this.getAttribute("class")).toBe("area plot-element");
+ });
+ });
+
+ test("should find multiple areas", () => {
+ 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(2);
+ });
+
+ test("should return empty selection when no areas", () => {
+ 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(0);
+ });
+
+ test("should only find areas in specified axes", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ expect(parser.findAreas(parser.svg, "axes_1").size()).toBe(1);
+ expect(parser.findAreas(parser.svg, "axes_2").size()).toBe(1);
+ });
+});
+
+describe("nearestElementFromMouse", () => {
+ test("should return nearest element by bounding box center", () => {
+ const dom = new JSDOM(`
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const rects = parser.svg.selectAll("rect");
+
+ // Mock getBBox for jsdom
+ rects.nodes().forEach((rect) => {
+ rect.getBBox = () => ({
+ x: parseFloat(rect.getAttribute("x")),
+ y: parseFloat(rect.getAttribute("y")),
+ width: parseFloat(rect.getAttribute("width")),
+ height: parseFloat(rect.getAttribute("height")),
+ });
+ });
+
+ // Mouse at (2, 2) should be nearest to r1 (center at 5, 5)
+ const nearest = parser.nearestElementFromMouse(2, 2, rects);
+ expect(nearest.id).toBe("r1");
+ });
+
+ test("should return nearest element when mouse closer to second", () => {
+ const dom = new JSDOM(`
+
+
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const rects = parser.svg.selectAll("rect");
+
+ rects.nodes().forEach((rect) => {
+ rect.getBBox = () => ({
+ x: parseFloat(rect.getAttribute("x")),
+ y: parseFloat(rect.getAttribute("y")),
+ width: parseFloat(rect.getAttribute("width")),
+ height: parseFloat(rect.getAttribute("height")),
+ });
+ });
+
+ // Mouse at (102, 102) should be nearest to r2 (center at 105, 105)
+ const nearest = parser.nearestElementFromMouse(102, 102, rects);
+ expect(nearest.id).toBe("r2");
+ });
+
+ test("should return null for empty selection", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const empty = parser.svg.selectAll(".nonexistent");
+
+ const nearest = parser.nearestElementFromMouse(0, 0, empty);
+ expect(nearest).toBeNull();
+ });
+
+ test("should handle single element", () => {
+ const dom = new JSDOM(`
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+ const rect = parser.svg.selectAll("rect");
- const svg = dom.window.document.querySelector("svg");
- const parser = new PlotSVGParser(svg, null, 0, 0);
- const points = parser.findPoints(parser.svg, "axes_1", ["A", "B"]);
+ rect.nodes()[0].getBBox = () => ({ x: 50, y: 50, width: 10, height: 10 });
- expect(points.size()).toBe(2);
- const nodes = points.nodes();
- expect(nodes[0].getAttribute("data-group")).toBe("A");
- expect(nodes[1].getAttribute("data-group")).toBe("B");
- points.each(function () {
- expect(this.getAttribute("class")).toBe("point plot-element");
+ const nearest = parser.nearestElementFromMouse(0, 0, rect);
+ expect(nearest.id).toBe("single");
});
});
diff --git a/tests/test-javascript/ParserSetHover.test.js b/tests/test-javascript/ParserSetHover.test.js
index 54f182f..134aad7 100644
--- a/tests/test-javascript/ParserSetHover.test.js
+++ b/tests/test-javascript/ParserSetHover.test.js
@@ -1,48 +1,576 @@
-import { expect, test } from "bun:test";
+import { expect, test, describe, beforeEach } from "bun:test";
import { JSDOM } from "jsdom";
import PlotSVGParser from "../../plotjs/static/plotparser.js";
-test("setHoverEffect should toggle hovered class and tooltip", () => {
- const dom = new JSDOM(`
-
-
-
-
-
-
+describe("setHoverEffect", () => {
+ test("should toggle hovered class and tooltip on direct hover", () => {
+ 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, 10, 20);
+ const document = dom.window.document;
+ const svg = document.querySelector("svg");
+ const tooltip = document.querySelector("#tooltip");
+ const parser = new PlotSVGParser(svg, tooltip, 10, 20);
- const points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+ const points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
- parser.setHoverEffect(points, "axes_1", ["Label1"], ["G1"], "block", false);
+ parser.setHoverEffect(points, "axes_1", ["Label1"], ["G1"], "block", false);
- const pointElement = points.nodes()[0];
- const event = new dom.window.MouseEvent("mouseover", {
- bubbles: true,
- clientX: 100,
- clientY: 200,
- pageX: 100,
- pageY: 200,
- currentTarget: pointElement,
+ const pointElement = points.nodes()[0];
+ const event = new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ clientX: 100,
+ clientY: 200,
+ pageX: 100,
+ pageY: 200,
+ currentTarget: pointElement,
+ });
+ pointElement.dispatchEvent(event);
+
+ expect(points.classed("hovered")).toBe(true);
+ expect(tooltip.style.display).toBe("block");
+ expect(tooltip.innerHTML).toBe("Label1");
+
+ const outEvent = new dom.window.MouseEvent("mouseout", { bubbles: true });
+ pointElement.dispatchEvent(outEvent);
+
+ expect(points.classed("hovered")).toBe(false);
+ expect(tooltip.style.display).toBe("none");
+ });
+
+ test("should position tooltip with x and y shift", () => {
+ const dom = new JSDOM(`
+
+
+
+
+
+
+
+
+ `);
+
+ const document = dom.window.document;
+ const svg = document.querySelector("svg");
+ const tooltip = document.querySelector("#tooltip");
+ const xShift = 15;
+ const yShift = -25;
+ const parser = new PlotSVGParser(svg, tooltip, xShift, yShift);
+
+ const points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+ parser.setHoverEffect(points, "axes_1", ["Label"], ["G1"], "block", false);
+
+ const pointElement = points.nodes()[0];
+ // Create event with custom pageX/pageY (jsdom doesn't set these from constructor)
+ const event = new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ currentTarget: pointElement,
+ });
+ Object.defineProperty(event, "pageX", { value: 50 });
+ Object.defineProperty(event, "pageY", { value: 100 });
+ pointElement.dispatchEvent(event);
+
+ expect(tooltip.style.left).toBe("65px"); // 50 + 15
+ expect(tooltip.style.top).toBe("75px"); // 100 + (-25)
+ });
+
+ test("should highlight elements with same group", () => {
+ 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);
+
+ // Two elements in GroupA, one in GroupB
+ const points = parser.findPoints(parser.svg, "axes_1", [
+ "GroupA",
+ "GroupB",
+ "GroupA",
+ ]);
+ parser.setHoverEffect(
+ points,
+ "axes_1",
+ ["A1", "B1", "A2"],
+ ["GroupA", "GroupB", "GroupA"],
+ "block",
+ false,
+ );
+
+ // Hover first element (GroupA)
+ const firstPoint = points.nodes()[0];
+ const event = new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: firstPoint,
+ });
+ firstPoint.dispatchEvent(event);
+
+ const nodes = points.nodes();
+ // First and third should be hovered (same group)
+ expect(nodes[0].classList.contains("hovered")).toBe(true);
+ expect(nodes[2].classList.contains("hovered")).toBe(true);
+ // Second should be not-hovered (different group)
+ expect(nodes[1].classList.contains("not-hovered")).toBe(true);
+ });
+
+ test("should not show tooltip when show_tooltip is none", () => {
+ 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 points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+ parser.setHoverEffect(points, "axes_1", ["Label"], ["G1"], "none", false);
+
+ const pointElement = points.nodes()[0];
+ const event = new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: pointElement,
+ });
+ pointElement.dispatchEvent(event);
+
+ expect(tooltip.style.display).toBe("none");
+ });
+
+ test("should clear hover states on mouseout", () => {
+ 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 points = parser.findPoints(parser.svg, "axes_1", ["G1", "G2"]);
+ parser.setHoverEffect(
+ points,
+ "axes_1",
+ ["L1", "L2"],
+ ["G1", "G2"],
+ "block",
+ false,
+ );
+
+ const firstPoint = points.nodes()[0];
+
+ // Hover
+ firstPoint.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: firstPoint,
+ }),
+ );
+
+ expect(points.nodes()[0].classList.contains("hovered")).toBe(true);
+ expect(points.nodes()[1].classList.contains("not-hovered")).toBe(true);
+
+ // Mouseout
+ firstPoint.dispatchEvent(
+ new dom.window.MouseEvent("mouseout", { bubbles: true }),
+ );
+
+ expect(points.nodes()[0].classList.contains("hovered")).toBe(false);
+ expect(points.nodes()[0].classList.contains("not-hovered")).toBe(false);
+ expect(points.nodes()[1].classList.contains("hovered")).toBe(false);
+ expect(points.nodes()[1].classList.contains("not-hovered")).toBe(false);
+ expect(tooltip.style.display).toBe("none");
+ });
+
+ test("should work with bar 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 bars = parser.findBars(parser.svg, "axes_1");
+ parser.setHoverEffect(
+ bars,
+ "axes_1",
+ ["Bar 1", "Bar 2"],
+ ["G1", "G2"],
+ "block",
+ false,
+ );
+
+ const firstBar = bars.nodes()[0];
+ firstBar.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 10,
+ pageY: 20,
+ currentTarget: firstBar,
+ }),
+ );
+
+ expect(firstBar.classList.contains("hovered")).toBe(true);
+ expect(tooltip.innerHTML).toBe("Bar 1");
+ });
+
+ test("should work with line 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 lines = parser.findLines(parser.svg, "axes_1");
+ parser.setHoverEffect(
+ lines,
+ "axes_1",
+ ["Line 1"],
+ ["Series1"],
+ "block",
+ false,
+ );
+
+ const line = lines.nodes()[0];
+ line.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: line,
+ }),
+ );
+
+ expect(line.classList.contains("hovered")).toBe(true);
+ expect(tooltip.innerHTML).toBe("Line 1");
+ });
+
+ test("should work with 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",
+ ["Area 1"],
+ ["Fill1"],
+ "block",
+ false,
+ );
+
+ const area = areas.nodes()[0];
+ area.dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: area,
+ }),
+ );
+
+ expect(area.classList.contains("hovered")).toBe(true);
+ expect(tooltip.innerHTML).toBe("Area 1");
+ });
+
+ test("should show correct label for each element", () => {
+ 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 points = parser.findPoints(parser.svg, "axes_1", ["G1", "G2", "G3"]);
+ parser.setHoverEffect(
+ points,
+ "axes_1",
+ ["First", "Second", "Third"],
+ ["G1", "G2", "G3"],
+ "block",
+ false,
+ );
+
+ const nodes = points.nodes();
+
+ // Hover second element
+ nodes[1].dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: nodes[1],
+ }),
+ );
+ expect(tooltip.innerHTML).toBe("Second");
+
+ nodes[1].dispatchEvent(
+ new dom.window.MouseEvent("mouseout", { bubbles: true }),
+ );
+
+ // Hover third element
+ nodes[2].dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: nodes[2],
+ }),
+ );
+ expect(tooltip.innerHTML).toBe("Third");
+ });
+
+ test("should handle HTML content in labels", () => {
+ 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 points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+ parser.setHoverEffect(
+ points,
+ "axes_1",
+ ["Bold and italic"],
+ ["G1"],
+ "block",
+ false,
+ );
+
+ points.nodes()[0].dispatchEvent(
+ new dom.window.MouseEvent("mouseover", {
+ bubbles: true,
+ pageX: 0,
+ pageY: 0,
+ currentTarget: points.nodes()[0],
+ }),
+ );
+
+ expect(tooltip.innerHTML).toBe("Bold and italic");
+ });
+});
+
+describe("setHoverEffect with hover_nearest", () => {
+ test("should attach mousemove to axes group", () => {
+ 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 points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+
+ // Mock SVG coordinate transformation
+ svg.createSVGPoint = () => ({
+ x: 0,
+ y: 0,
+ matrixTransform: () => ({ x: 5, y: 5 }),
+ });
+ svg.getScreenCTM = () => ({ inverse: () => ({}) });
+
+ // Mock getBBox for nearest element calculation
+ points.nodes()[0].getBBox = () => ({ x: 0, y: 0, width: 10, height: 10 });
+
+ parser.setHoverEffect(
+ points,
+ "axes_1",
+ ["Nearest Label"],
+ ["G1"],
+ "block",
+ true,
+ );
+
+ const axesGroup = document.querySelector("#axes_1");
+ const event = new dom.window.MouseEvent("mousemove", {
+ bubbles: true,
+ clientX: 5,
+ clientY: 5,
+ pageX: 5,
+ pageY: 5,
+ });
+ axesGroup.dispatchEvent(event);
+
+ expect(points.classed("hovered")).toBe(true);
+ expect(tooltip.style.display).toBe("block");
+ expect(tooltip.innerHTML).toBe("Nearest Label");
});
- pointElement.dispatchEvent(event);
- expect(points.classed("hovered")).toBe(true);
- expect(tooltip.style.display).toBe("block");
- expect(tooltip.innerHTML).toBe("Label1");
+ test("should clear hover on mouseout from axes group", () => {
+ 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 points = parser.findPoints(parser.svg, "axes_1", ["G1"]);
+
+ svg.createSVGPoint = () => ({
+ x: 0,
+ y: 0,
+ matrixTransform: () => ({ x: 5, y: 5 }),
+ });
+ svg.getScreenCTM = () => ({ inverse: () => ({}) });
+ points.nodes()[0].getBBox = () => ({ x: 0, y: 0, width: 10, height: 10 });
- const outEvent = new dom.window.MouseEvent("mouseout", { bubbles: true });
- pointElement.dispatchEvent(outEvent);
+ parser.setHoverEffect(points, "axes_1", ["Label"], ["G1"], "block", true);
- expect(points.classed("hovered")).toBe(false);
- expect(tooltip.style.display).toBe("none");
+ const axesGroup = document.querySelector("#axes_1");
+
+ // Hover
+ axesGroup.dispatchEvent(
+ new dom.window.MouseEvent("mousemove", {
+ bubbles: true,
+ clientX: 5,
+ clientY: 5,
+ pageX: 5,
+ pageY: 5,
+ }),
+ );
+ expect(points.classed("hovered")).toBe(true);
+
+ // Mouseout
+ axesGroup.dispatchEvent(
+ new dom.window.MouseEvent("mouseout", { bubbles: true }),
+ );
+ expect(points.classed("hovered")).toBe(false);
+ expect(tooltip.style.display).toBe("none");
+ });
+});
+
+describe("PlotSVGParser constructor", () => {
+ test("should accept DOM elements directly", () => {
+ const dom = new JSDOM(`
+
+
+ `);
+
+ const svg = dom.window.document.querySelector("svg");
+ const tooltip = dom.window.document.querySelector("#tooltip");
+
+ const parser = new PlotSVGParser(svg, tooltip, 5, 10);
+
+ expect(parser.svg.nodes()[0]).toBe(svg);
+ expect(parser.tooltip.nodes()[0]).toBe(tooltip);
+ expect(parser.tooltip_x_shift).toBe(5);
+ expect(parser.tooltip_y_shift).toBe(10);
+ });
+
+ test("should handle null tooltip", () => {
+ const dom = new JSDOM(``);
+ const svg = dom.window.document.querySelector("svg");
+
+ const parser = new PlotSVGParser(svg, null, 0, 0);
+
+ expect(parser.svg.nodes()[0]).toBe(svg);
+ });
});
diff --git a/tests/test-javascript/Selection.test.js b/tests/test-javascript/Selection.test.js
new file mode 100644
index 0000000..ee493c8
--- /dev/null
+++ b/tests/test-javascript/Selection.test.js
@@ -0,0 +1,219 @@
+import { expect, test, describe, beforeEach } from "bun:test";
+import { JSDOM } from "jsdom";
+
+// We need to test the Selection class and select function
+// Import the module and extract the classes
+const dom = new JSDOM(``);
+global.document = dom.window.document;
+
+// Re-import to get Selection and select
+import PlotSVGParser from "../../plotjs/static/plotparser.js";
+
+describe("Selection class", () => {
+ let testDom;
+ let svg;
+ let parser;
+
+ beforeEach(() => {
+ testDom = new JSDOM(`
+
+
+
+
+
+
+
+ `);
+ svg = testDom.window.document.querySelector("svg");
+ parser = new PlotSVGParser(svg, null, 0, 0);
+ });
+
+ test("constructor wraps single element in array", () => {
+ expect(parser.svg.elements).toBeInstanceOf(Array);
+ expect(parser.svg.elements.length).toBe(1);
+ });
+
+ test("select returns first matching element wrapped in Selection", () => {
+ const rect = parser.svg.select("#rect1");
+ expect(rect.elements.length).toBe(1);
+ expect(rect.elements[0].id).toBe("rect1");
+ });
+
+ test("select returns empty Selection when no match", () => {
+ const notFound = parser.svg.select("#nonexistent");
+ expect(notFound.elements.length).toBe(1);
+ expect(notFound.elements[0]).toBeNull();
+ });
+
+ test("selectAll returns all matching elements", () => {
+ const rects = parser.svg.selectAll("rect");
+ expect(rects.size()).toBe(2);
+ });
+
+ test("selectAll returns empty Selection when no matches", () => {
+ const notFound = parser.svg.selectAll(".nonexistent");
+ expect(notFound.size()).toBe(0);
+ expect(notFound.empty()).toBe(true);
+ });
+
+ test("attr getter returns attribute value", () => {
+ const rect = parser.svg.select("#rect1");
+ expect(rect.attr("width")).toBe("10");
+ });
+
+ test("attr getter returns null for missing attribute", () => {
+ const rect = parser.svg.select("#rect1");
+ expect(rect.attr("nonexistent")).toBeNull();
+ });
+
+ test("attr setter sets attribute and returns this", () => {
+ const rect = parser.svg.select("#rect1");
+ const result = rect.attr("data-test", "value");
+ expect(result).toBe(rect);
+ expect(rect.attr("data-test")).toBe("value");
+ });
+
+ test("attr setter works on multiple elements", () => {
+ const rects = parser.svg.selectAll("rect");
+ rects.attr("data-common", "shared");
+ expect(rects.nodes()[0].getAttribute("data-common")).toBe("shared");
+ expect(rects.nodes()[1].getAttribute("data-common")).toBe("shared");
+ });
+
+ test("classed getter returns true when class exists", () => {
+ const group = parser.svg.select("#group1");
+ expect(group.classed("myclass")).toBe(true);
+ });
+
+ test("classed getter returns false when class missing", () => {
+ const group = parser.svg.select("#group1");
+ expect(group.classed("otherclass")).toBe(false);
+ });
+
+ test("classed setter adds class", () => {
+ const group = parser.svg.select("#group1");
+ group.classed("newclass", true);
+ expect(group.classed("newclass")).toBe(true);
+ });
+
+ test("classed setter removes class", () => {
+ const group = parser.svg.select("#group1");
+ group.classed("myclass", false);
+ expect(group.classed("myclass")).toBe(false);
+ });
+
+ test("classed returns this for chaining", () => {
+ const group = parser.svg.select("#group1");
+ const result = group.classed("test", true);
+ expect(result).toBe(group);
+ });
+
+ test("style setter sets inline style", () => {
+ const rect = parser.svg.select("#rect1");
+ rect.style("display", "none");
+ expect(rect.elements[0].style.display).toBe("none");
+ });
+
+ test("style returns this for chaining", () => {
+ const rect = parser.svg.select("#rect1");
+ const result = rect.style("opacity", "0.5");
+ expect(result).toBe(rect);
+ });
+
+ test("html getter returns innerHTML", () => {
+ const group = parser.svg.select("#group1");
+ expect(group.html()).toContain("rect");
+ });
+
+ test("html setter sets innerHTML", () => {
+ const group = parser.svg.select("#group2");
+ group.html("Hello");
+ expect(group.html()).toBe("Hello");
+ });
+
+ test("html returns this for chaining", () => {
+ const group = parser.svg.select("#group2");
+ const result = group.html("Test");
+ expect(result).toBe(group);
+ });
+
+ test("on attaches event listener", () => {
+ const rect = parser.svg.select("#rect1");
+ let clicked = false;
+ rect.on("click", () => {
+ clicked = true;
+ });
+ rect.elements[0].dispatchEvent(new testDom.window.Event("click"));
+ expect(clicked).toBe(true);
+ });
+
+ test("on returns this for chaining", () => {
+ const rect = parser.svg.select("#rect1");
+ const result = rect.on("click", () => {});
+ expect(result).toBe(rect);
+ });
+
+ test("filter returns filtered Selection", () => {
+ const rects = parser.svg.selectAll("rect");
+ const filtered = rects.filter(function () {
+ return this.id === "rect1";
+ });
+ expect(filtered.size()).toBe(1);
+ expect(filtered.nodes()[0].id).toBe("rect1");
+ });
+
+ test("filter with index parameter", () => {
+ const rects = parser.svg.selectAll("rect");
+ const filtered = rects.filter(function (_, i) {
+ return i === 0;
+ });
+ expect(filtered.size()).toBe(1);
+ });
+
+ test("each iterates over all elements", () => {
+ const rects = parser.svg.selectAll("rect");
+ const ids = [];
+ rects.each(function () {
+ ids.push(this.id);
+ });
+ expect(ids).toContain("rect1");
+ expect(ids).toContain("rect2");
+ });
+
+ test("each provides index", () => {
+ const rects = parser.svg.selectAll("rect");
+ const indices = [];
+ rects.each(function (_, i) {
+ indices.push(i);
+ });
+ expect(indices).toEqual([0, 1]);
+ });
+
+ test("each returns this for chaining", () => {
+ const rects = parser.svg.selectAll("rect");
+ const result = rects.each(() => {});
+ expect(result).toBe(rects);
+ });
+
+ test("nodes returns array of elements", () => {
+ const rects = parser.svg.selectAll("rect");
+ const nodes = rects.nodes();
+ expect(nodes).toBeInstanceOf(Array);
+ expect(nodes.length).toBe(2);
+ });
+
+ test("size returns element count", () => {
+ const rects = parser.svg.selectAll("rect");
+ expect(rects.size()).toBe(2);
+ });
+
+ test("empty returns true for empty selection", () => {
+ const notFound = parser.svg.selectAll(".nonexistent");
+ expect(notFound.empty()).toBe(true);
+ });
+
+ test("empty returns false for non-empty selection", () => {
+ const rects = parser.svg.selectAll("rect");
+ expect(rects.empty()).toBe(false);
+ });
+});
diff --git a/tests/test-python/test_plotjs.py b/tests/test-python/test_plotjs.py
index 0e7d9b5..a860e02 100644
--- a/tests/test-python/test_plotjs.py
+++ b/tests/test-python/test_plotjs.py
@@ -3,6 +3,7 @@
import os
import tempfile
from unittest.mock import patch
+import pytest
def test_add_css_method_chaining():
@@ -236,3 +237,122 @@ def test_open_method_chaining(mock_webbrowser):
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
+
+
+def test_add_tooltip_on_parameter_single_string():
+ """Test that on parameter accepts a single string."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on="point")
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point"]
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_list():
+ """Test that on parameter accepts a list of strings."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on=["point", "line"])
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point", "line"]
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_plurals():
+ """Test that on parameter accepts plural forms."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on="points")
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point"]
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on=["lines", "bars"])
+ assert mp._axes_tooltip["axes_1"]["on"] == ["line", "bar"]
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_none():
+ """Test that on=None means all elements (default)."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on=None)
+ assert mp._axes_tooltip["axes_1"]["on"] is None
+
+ # Also test default (no on parameter)
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"])
+ assert mp._axes_tooltip["axes_1"]["on"] is None
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_case_insensitive():
+ """Test that on parameter is case insensitive."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on="POINT")
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point"]
+
+ mp = PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on=["LINE", "Bar"])
+ assert mp._axes_tooltip["axes_1"]["on"] == ["line", "bar"]
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_deduplication():
+ """Test that duplicate values in on parameter are removed."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(
+ labels=["A", "B", "C"], on=["point", "point", "line"]
+ )
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point", "line"]
+
+ # Also test mixed plural/singular
+ mp = PlotJS(fig=fig).add_tooltip(
+ labels=["A", "B", "C"], on=["point", "points", "line"]
+ )
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point", "line"]
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_all_valid_values():
+ """Test that all valid element types are accepted."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ mp = PlotJS(fig=fig).add_tooltip(
+ labels=["A", "B", "C"], on=["point", "line", "bar", "area"]
+ )
+ assert mp._axes_tooltip["axes_1"]["on"] == ["point", "line", "bar", "area"]
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_invalid_value():
+ """Test that invalid values raise ValueError."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ with pytest.raises(ValueError, match=r"Invalid element type 'invalid'"):
+ PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on="invalid")
+
+ plt.close(fig)
+
+
+def test_add_tooltip_on_parameter_invalid_in_list():
+ """Test that invalid values in a list raise ValueError."""
+ fig, ax = plt.subplots()
+ ax.scatter([1, 2, 3], [1, 2, 3])
+
+ with pytest.raises(ValueError, match=r"Invalid element type 'circle'"):
+ PlotJS(fig=fig).add_tooltip(labels=["A", "B", "C"], on=["point", "circle"])
+
+ plt.close(fig)