From 800276359a12889bfa46d87b6228382c8ec4972c Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Mon, 2 Feb 2026 11:55:07 +0100 Subject: [PATCH 1/2] add argument to control which plot element to parse #48 #33 --- AGENT.md | 10 +- plotjs/plotjs.py | 47 +++++++++ plotjs/static/template.html | 28 +++-- tests/test-browser/test_interactions.py | 134 ++++++++++++++++++++++++ tests/test-python/test_plotjs.py | 120 +++++++++++++++++++++ 5 files changed, 329 insertions(+), 10 deletions(-) diff --git a/AGENT.md b/AGENT.md index 2fcdaee..7a245ee 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/`) diff --git a/plotjs/plotjs.py b/plotjs/plotjs.py index 0767bf8..b1c2937 100644 --- a/plotjs/plotjs.py +++ b/plotjs/plotjs.py @@ -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-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) From ba051f516b8014939e7ce468a8399846300b240e Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Mon, 2 Feb 2026 13:55:57 +0100 Subject: [PATCH 2/2] add a bunch of tests for the parser --- .gitattributes | 1 - AGENT.md | 14 +- LICENSE | 2 +- plotjs/plotjs.py | 2 +- tests/test-javascript/EdgeCases.test.js | 394 ++++++++++++ tests/test-javascript/ParserSelectors.test.js | 448 ++++++++++++- tests/test-javascript/ParserSetHover.test.js | 596 +++++++++++++++++- tests/test-javascript/Selection.test.js | 219 +++++++ 8 files changed, 1598 insertions(+), 78 deletions(-) create mode 100644 tests/test-javascript/EdgeCases.test.js create mode 100644 tests/test-javascript/Selection.test.js 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 7a245ee..81ec47c 100644 --- a/AGENT.md +++ b/AGENT.md @@ -131,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 b1c2937..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: 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(` - - - +describe("findBars", () => { + test("should select only patches with clip-path starting with url(", () => { + 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(1); + bars.each(function () { + expect(this.getAttribute("class")).toBe("bar plot-element"); + }); + }); + + test("should return empty selection when no bars exist", () => { + 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(0); + expect(bars.empty()).toBe(true); + }); + + test("should ignore patches with clip-path not starting with url(", () => { + 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); + }); +});