diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f48dd9..a9c9cb64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- New `HoverZoom` widget — hover over an image to see a magnified side panel, like product zoom on e-commerce sites. + ## [0.2.40] - 2026-03-24 ### Added diff --git a/README.md b/README.md index fe8c939e..3475c7d8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ uv pip install wigglystuff





diff --git a/mkdocs/llms.txt b/mkdocs/llms.txt
index 22865c27..e872b940 100644
--- a/mkdocs/llms.txt
+++ b/mkdocs/llms.txt
@@ -22,6 +22,7 @@ Install via `pip install wigglystuff` or `uv pip install wigglystuff`.
- [ColorPicker](https://koaning.github.io/wigglystuff/reference/color-picker.md): Color selection widget
- [CopyToClipboard](https://koaning.github.io/wigglystuff/reference/copy-to-clipboard.md): Copy text to clipboard
- [Tangle Widgets](https://koaning.github.io/wigglystuff/reference/tangle.md): Bret Victor-style tangle controls
+- [HoverZoom](https://koaning.github.io/wigglystuff/reference/hover-zoom.md): Image hover zoom with magnified side panel
- [GamepadWidget](https://koaning.github.io/wigglystuff/reference/gamepad.md): Gamepad controller input
- [WebcamCapture](https://koaning.github.io/wigglystuff/reference/webcam-capture.md): Webcam image capture
- [CellTour](https://koaning.github.io/wigglystuff/reference/cell-tour.md): Guided notebook tours
diff --git a/wigglystuff/__init__.py b/wigglystuff/__init__.py
index 73c1eec7..f4cce88a 100644
--- a/wigglystuff/__init__.py
+++ b/wigglystuff/__init__.py
@@ -16,6 +16,7 @@
from .edge_draw import EdgeDraw
from .env_config import EnvConfig
from .gamepad import GamepadWidget
+from .hover_zoom import HoverZoom
from .html import HTMLRefreshWidget, ImageRefreshWidget, ProgressBar
from .keystroke import KeystrokeWidget
from .matrix import Matrix
@@ -74,6 +75,7 @@
"ThreeWidget",
"WandbChart",
"WebcamCapture",
+ "HoverZoom",
"HTMLRefreshWidget",
"ImageRefreshWidget",
"ProgressBar",
diff --git a/wigglystuff/hover_zoom.py b/wigglystuff/hover_zoom.py
new file mode 100644
index 00000000..06d618e8
--- /dev/null
+++ b/wigglystuff/hover_zoom.py
@@ -0,0 +1,88 @@
+"""HoverZoom widget — magnified side panel on image hover."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Optional, Union
+
+import anywidget
+import traitlets
+
+from .chart_puck import fig_to_base64
+from .paint import base64_to_pil, input_to_pil, pil_to_base64
+
+
+class HoverZoom(anywidget.AnyWidget):
+ """Image widget with a magnified side panel that appears on hover.
+
+ Hovering over the image shows a rectangle indicator and a zoom panel
+ to the right displaying the magnified region — the classic e-commerce
+ product zoom pattern.
+
+ Examples:
+ ```python
+ from wigglystuff import HoverZoom
+
+ # From a file path
+ widget = HoverZoom("photo.jpg", zoom_factor=3.0)
+
+ # From a matplotlib figure
+ import matplotlib.pyplot as plt
+ fig, ax = plt.subplots()
+ ax.scatter(x, y, s=5, alpha=0.5)
+ widget = HoverZoom(fig, zoom_factor=4.0)
+ ```
+ """
+
+ _esm = Path(__file__).parent / "static" / "hover-zoom.js"
+ _css = Path(__file__).parent / "static" / "hover-zoom.css"
+
+ image = traitlets.Unicode("").tag(sync=True)
+ zoom_factor = traitlets.Float(3.0).tag(sync=True)
+ width = traitlets.Int(500).tag(sync=True)
+ height = traitlets.Int(0).tag(sync=True)
+ _crop = traitlets.List(traitlets.Float(), [0.0, 0.0, 1.0, 1.0]).tag(sync=True)
+
+ def __init__(
+ self,
+ image: Optional[Union[str, Path, Any]] = None,
+ *,
+ zoom_factor: float = 3.0,
+ width: int = 500,
+ height: int = 0,
+ ):
+ """Create a HoverZoom widget.
+
+ Args:
+ image: Image source — matplotlib figure, file path, URL, PIL Image,
+ bytes, or base64 string.
+ zoom_factor: Magnification level for the zoom panel.
+ width: Display width of the source image in pixels.
+ height: Display height in pixels (0 = auto, preserve aspect ratio).
+ """
+ super().__init__()
+ self.zoom_factor = zoom_factor
+ self.width = width
+ self.height = height
+
+ if image is not None:
+ # Detect matplotlib figures by checking for savefig
+ if hasattr(image, "savefig"):
+ self.image = fig_to_base64(image)
+ else:
+ pil_image = input_to_pil(image)
+ if pil_image is not None:
+ self.image = pil_to_base64(pil_image)
+
+ def get_pil_zoom(self):
+ """Return the currently zoomed region as a PIL Image.
+
+ Returns the cropped portion of the image that is visible in the
+ zoom panel. Returns ``None`` if no image is set.
+ """
+ if not self.image:
+ return None
+ img = base64_to_pil(self.image)
+ x0_r, y0_r, x1_r, y1_r = self._crop
+ w, h = img.size
+ return img.crop((int(x0_r * w), int(y0_r * h), int(x1_r * w), int(y1_r * h)))
diff --git a/wigglystuff/static/hover-zoom.css b/wigglystuff/static/hover-zoom.css
new file mode 100644
index 00000000..31792de7
--- /dev/null
+++ b/wigglystuff/static/hover-zoom.css
@@ -0,0 +1,55 @@
+.hover-zoom-wrapper {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: 8px;
+ position: relative;
+ color-scheme: light dark;
+ --hz-rect-bg: rgba(255, 255, 255, 0.25);
+ --hz-rect-border: rgba(0, 0, 0, 0.4);
+ --hz-panel-border: rgba(0, 0, 0, 0.15);
+ --hz-panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+}
+
+.dark .hover-zoom-wrapper,
+.dark-theme .hover-zoom-wrapper,
+[data-theme="dark"] .hover-zoom-wrapper {
+ --hz-rect-bg: rgba(255, 255, 255, 0.2);
+ --hz-rect-border: rgba(255, 255, 255, 0.6);
+ --hz-panel-border: rgba(255, 255, 255, 0.2);
+ --hz-panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
+}
+
+.hover-zoom-source {
+ position: relative;
+ display: inline-block;
+ cursor: crosshair;
+ flex-shrink: 0;
+}
+
+.hover-zoom-img {
+ display: block;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.hover-zoom-rect {
+ display: none;
+ position: absolute;
+ background: var(--hz-rect-bg);
+ border: 2px solid var(--hz-rect-border);
+ pointer-events: none;
+ box-sizing: border-box;
+}
+
+.hover-zoom-rect--pinned {
+ border-style: dashed;
+}
+
+.hover-zoom-panel {
+ display: none;
+ flex-shrink: 0;
+ border: 1px solid var(--hz-panel-border);
+ box-shadow: var(--hz-panel-shadow);
+ background-repeat: no-repeat;
+ background-color: transparent;
+}
diff --git a/wigglystuff/static/hover-zoom.js b/wigglystuff/static/hover-zoom.js
new file mode 100644
index 00000000..1008bdfd
--- /dev/null
+++ b/wigglystuff/static/hover-zoom.js
@@ -0,0 +1,148 @@
+function render({ model, el }) {
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("hover-zoom-wrapper");
+
+ const sourceContainer = document.createElement("div");
+ sourceContainer.classList.add("hover-zoom-source");
+
+ const img = document.createElement("img");
+ img.classList.add("hover-zoom-img");
+ img.draggable = false;
+
+ const rect = document.createElement("div");
+ rect.classList.add("hover-zoom-rect");
+
+ const zoomPanel = document.createElement("div");
+ zoomPanel.classList.add("hover-zoom-panel");
+
+ sourceContainer.appendChild(img);
+ sourceContainer.appendChild(rect);
+ wrapper.appendChild(sourceContainer);
+ wrapper.appendChild(zoomPanel);
+ el.appendChild(wrapper);
+
+ let pinned = false;
+
+ function updateImage() {
+ const src = model.get("image");
+ img.src = src || "";
+ zoomPanel.style.backgroundImage = src ? `url(${src})` : "none";
+ }
+
+ function updateSize() {
+ const w = model.get("width");
+ const h = model.get("height");
+ img.style.width = w + "px";
+ img.style.height = h > 0 ? h + "px" : "auto";
+ sourceContainer.style.width = w + "px";
+ }
+
+ function updateZoom() {
+ const zf = model.get("zoom_factor");
+ const imgW = img.naturalWidth || img.offsetWidth;
+ const imgH = img.naturalHeight || img.offsetHeight;
+ zoomPanel.style.backgroundSize = (imgW * zf) + "px " + (imgH * zf) + "px";
+ }
+
+ function layoutPanel() {
+ const displayH = img.offsetHeight || img.naturalHeight || 300;
+ zoomPanel.style.width = displayH + "px";
+ zoomPanel.style.height = displayH + "px";
+ updateZoom();
+ }
+
+ function positionZoom(mouseX, mouseY) {
+ const bounds = img.getBoundingClientRect();
+ const imgW = bounds.width;
+ const imgH = bounds.height;
+
+ const zf = model.get("zoom_factor");
+ const panelW = zoomPanel.offsetWidth;
+ const panelH = zoomPanel.offsetHeight;
+
+ const rectW = panelW / zf;
+ const rectH = panelH / zf;
+
+ let rectLeft = mouseX - rectW / 2;
+ let rectTop = mouseY - rectH / 2;
+ rectLeft = Math.max(0, Math.min(rectLeft, imgW - rectW));
+ rectTop = Math.max(0, Math.min(rectTop, imgH - rectH));
+
+ rect.style.width = rectW + "px";
+ rect.style.height = rectH + "px";
+ rect.style.left = rectLeft + "px";
+ rect.style.top = rectTop + "px";
+
+ const ratioX = rectLeft / imgW;
+ const ratioY = rectTop / imgH;
+ const ratioW = rectW / imgW;
+ const ratioH = rectH / imgH;
+ const bgW = imgW * zf;
+ const bgH = imgH * zf;
+
+ zoomPanel.style.backgroundPosition = -(ratioX * bgW) + "px " + -(ratioY * bgH) + "px";
+ zoomPanel.style.backgroundSize = bgW + "px " + bgH + "px";
+
+ // Sync crop region to Python as [x0, y0, x1, y1] ratios
+ model.set("_crop", [ratioX, ratioY, ratioX + ratioW, ratioY + ratioH]);
+ model.save_changes();
+ }
+
+ function showZoom() {
+ rect.style.display = "block";
+ zoomPanel.style.display = "block";
+ layoutPanel();
+ }
+
+ function hideZoom() {
+ rect.style.display = "none";
+ zoomPanel.style.display = "none";
+ }
+
+ img.onload = function () {
+ layoutPanel();
+ };
+
+ sourceContainer.addEventListener("mouseenter", function () {
+ showZoom();
+ });
+
+ sourceContainer.addEventListener("mouseleave", function () {
+ if (!pinned) {
+ hideZoom();
+ }
+ });
+
+ sourceContainer.addEventListener("mousemove", function (e) {
+ if (pinned) return;
+ const bounds = img.getBoundingClientRect();
+ positionZoom(e.clientX - bounds.left, e.clientY - bounds.top);
+ });
+
+ // Double-click to pin the zoom at the current position
+ sourceContainer.addEventListener("dblclick", function (e) {
+ e.preventDefault();
+ pinned = true;
+ rect.classList.add("hover-zoom-rect--pinned");
+ showZoom();
+ const bounds = img.getBoundingClientRect();
+ positionZoom(e.clientX - bounds.left, e.clientY - bounds.top);
+ });
+
+ // Single click to unpin and resume following
+ sourceContainer.addEventListener("click", function (e) {
+ if (!pinned) return;
+ pinned = false;
+ rect.classList.remove("hover-zoom-rect--pinned");
+ });
+
+ updateImage();
+ updateSize();
+
+ model.on("change:image", updateImage);
+ model.on("change:width", function () { updateSize(); layoutPanel(); });
+ model.on("change:height", function () { updateSize(); layoutPanel(); });
+ model.on("change:zoom_factor", updateZoom);
+}
+
+export default { render };