Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
*.html linguist-detectable=false
test/* linguist-detectable=false
24 changes: 15 additions & 9 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
49 changes: 48 additions & 1 deletion plotjs/plotjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":
"""
Expand All @@ -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:
Expand All @@ -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 = (
Expand All @@ -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)
Expand Down
28 changes: 20 additions & 8 deletions plotjs/static/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,34 @@
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`);
console.log(`PlotJS: - Hover nearest: ${hover_nearest}`);
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();
Expand Down
134 changes: 134 additions & 0 deletions tests/test-browser/test_interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}"
)
Loading