Skip to content
Merged
145 changes: 145 additions & 0 deletions plots/scatter-brush-zoom/implementations/letsplot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
""" pyplots.ai
scatter-brush-zoom: Interactive Scatter Plot with Brush Selection and Zoom
Library: letsplot 4.8.2 | Python 3.13.11
Quality: 91/100 | Created: 2026-01-08
"""

import numpy as np
import pandas as pd
from lets_plot import (
LetsPlot,
aes,
element_line,
element_text,
geom_point,
geom_rect,
geom_text,
ggplot,
ggsize,
labs,
layer_tooltips,
scale_alpha_identity,
scale_color_manual,
theme,
theme_minimal,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Data - Generate clustered data for brush selection demonstration
np.random.seed(42)

# Create 4 distinct clusters with different sizes
n_per_cluster = [80, 120, 100, 100]
centers = [(20, 60), (50, 30), (70, 70), (40, 80)]
spreads = [8, 12, 10, 6]
categories = ["Cluster A", "Cluster B", "Cluster C", "Cluster D"]

x_data, y_data, colors, labels, selected = [], [], [], [], []
point_id = 0
for n, (cx, cy), spread, cat in zip(n_per_cluster, centers, spreads, categories, strict=True):
for _ in range(n):
x_val = np.random.normal(cx, spread)
y_val = np.random.normal(cy, spread)
x_data.append(x_val)
y_data.append(y_val)
colors.append(cat)
labels.append(f"P{point_id:03d}")
# Mark points within the brush selection region as selected
is_selected = 25 <= x_val <= 55 and 50 <= y_val <= 85
selected.append(is_selected)
point_id += 1

df = pd.DataFrame(
{
"x": x_data,
"y": y_data,
"category": colors,
"label": labels,
"selected": selected,
"point_alpha": [0.9 if s else 0.25 for s in selected],
}
)

# Create tooltips for hover interaction - shows point details
tooltips = (
layer_tooltips()
.title("@label")
.line("Category: @category")
.line("X: @x (units)")
.line("Y: @y (units)")
.line("Selected: @selected")
.format("x", ".1f")
.format("y", ".1f")
)

# Brush selection rectangle coordinates
brush_xmin, brush_xmax = 25, 55
brush_ymin, brush_ymax = 50, 85
n_selected = df["selected"].sum()

# Create brush rectangle data
brush_df = pd.DataFrame({"xmin": [brush_xmin], "xmax": [brush_xmax], "ymin": [brush_ymin], "ymax": [brush_ymax]})

# Create interactive scatter plot with brush selection visualization
# The static PNG demonstrates brush selection with:
# - Visible brush rectangle (blue dashed border with light fill)
# - Selected points shown with full opacity, unselected dimmed
# - Selection count annotation
# The HTML export includes built-in toolbar with:
# - Pan (drag to move around)
# - Wheel zoom (scroll to zoom in/out)
# - Box zoom (select area to zoom)
# - Reset (double-click to reset view)
plot = (
ggplot(df, aes(x="x", y="y"))
# Draw brush selection rectangle first (behind points)
+ geom_rect(
aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
data=brush_df,
inherit_aes=False,
fill="#3B82F6",
alpha=0.15,
color="#3B82F6",
linetype="dashed",
size=1.5,
)
# Plot points with alpha based on selection state
+ geom_point(aes(color="category", alpha="point_alpha"), size=5, tooltips=tooltips)
+ scale_color_manual(values=["#306998", "#FFD43B", "#DC2626", "#059669"])
+ scale_alpha_identity()
+ labs(
x="X Value (units)", y="Y Value (units)", title="scatter-brush-zoom · letsplot · pyplots.ai", color="Category"
)
+ theme_minimal()
+ theme(
axis_title=element_text(size=20),
axis_text=element_text(size=16),
plot_title=element_text(size=24, margin=[0, 0, 10, 0]),
legend_title=element_text(size=18),
legend_text=element_text(size=16),
panel_grid_major=element_line(color="#E5E5E5", size=0.5),
panel_grid_minor=element_line(color="#F0F0F0", size=0.3),
)
+ ggsize(1600, 900)
)

# Add annotation showing selection count
annotation_df = pd.DataFrame({"x": [40], "y": [88], "text": [f"Brush Selection: {n_selected} points selected"]})

plot = plot + geom_text(
aes(x="x", y="y", label="text"), data=annotation_df, inherit_aes=False, size=14, color="#1E40AF", fontface="bold"
)

# Save static PNG (scaled 3x for 4800x2700 px)
ggsave(plot, "plot.png", path=".", scale=3)

# Save interactive HTML with zoom, pan, and hover capabilities
# The HTML viewer includes a toolbar with:
# - Pan mode: click and drag to navigate
# - Wheel zoom: scroll to zoom in/out
# - Box zoom: draw rectangle to zoom to area (brush-like selection)
# - Reset: double-click or use reset button to restore original view
ggsave(plot, "plot.html", path=".")
213 changes: 213 additions & 0 deletions plots/scatter-brush-zoom/metadata/letsplot.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
library: letsplot
specification_id: scatter-brush-zoom
created: '2026-01-08T16:14:23Z'
updated: '2026-01-08T16:38:15Z'
generated_by: claude-opus-4-5-20251101
workflow_run: 20823308871
issue: 3295
python_version: 3.13.11
library_version: 4.8.2
preview_url: https://storage.googleapis.com/pyplots-images/plots/scatter-brush-zoom/letsplot/plot.png
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/scatter-brush-zoom/letsplot/plot_thumb.png
preview_html: https://storage.googleapis.com/pyplots-images/plots/scatter-brush-zoom/letsplot/plot.html
quality_score: 91
review:
strengths:
- Excellent visualization of brush selection with clear visual distinction between
selected and unselected points
- Proper use of lets-plot tooltip system for hover information
- Good clustering data that demonstrates utility of brush selection across multiple
groups
- Clean, well-structured code following KISS principles
- Both PNG and HTML exports provided, showcasing lets-plot dual-output capability
- Selection count annotation adds useful context
weaknesses:
- Legend appears slightly cut off on the right edge of the image
- The alpha=0.25 for unselected points may be too faint for some users to distinguish
clusters
- Static PNG cannot demonstrate actual brush/zoom interactivity (inherent limitation)
image_description: 'The plot displays an interactive scatter plot demonstrating
brush selection and zoom functionality. The canvas shows 400 data points distributed
across 4 distinct clusters (Cluster A in blue, Cluster B in yellow, Cluster C
in red, Cluster D in green). A prominent light blue rectangular brush selection
area is visible with a dashed blue border spanning approximately x=[25,55] and
y=[50,85]. Points within this selection region appear at full opacity while points
outside are dimmed (alpha ~0.25). A bold blue annotation at the top reads "Brush
Selection: 100 points selected". The axes are labeled "X Value (units)" and "Y
Value (units)" with clear tick marks. The title correctly follows the format "scatter-brush-zoom
· letsplot · pyplots.ai". A legend on the right identifies the four clusters.
The grid is subtle with light gray lines.'
criteria_checklist:
visual_quality:
score: 36
max: 40
items:
- id: VQ-01
name: Text Legibility
score: 10
max: 10
passed: true
comment: Title at 24pt, axis labels at 20pt, tick labels at 16pt, all perfectly
readable
- id: VQ-02
name: No Overlap
score: 8
max: 8
passed: true
comment: No overlapping text elements, annotation placed in clear area
- id: VQ-03
name: Element Visibility
score: 6
max: 8
passed: true
comment: Points visible with good size, alpha differentiation works but dimmed
points quite faint
- id: VQ-04
name: Color Accessibility
score: 5
max: 5
passed: true
comment: Four distinct colors distinguishable with good contrast
- id: VQ-05
name: Layout Balance
score: 5
max: 5
passed: true
comment: Plot fills canvas appropriately, balanced margins
- id: VQ-06
name: Axis Labels
score: 2
max: 2
passed: true
comment: 'Descriptive with units: X Value (units), Y Value (units)'
- id: VQ-07
name: Grid & Legend
score: 0
max: 2
passed: false
comment: Legend appears cut off on right edge of image
spec_compliance:
score: 23
max: 25
items:
- id: SC-01
name: Plot Type
score: 8
max: 8
passed: true
comment: Correct scatter plot with brush selection visualization
- id: SC-02
name: Data Mapping
score: 5
max: 5
passed: true
comment: X/Y correctly mapped to numeric axes
- id: SC-03
name: Required Features
score: 4
max: 5
passed: true
comment: Brush rectangle, selected points highlighted, selection count shown;
static image cannot show interactivity
- id: SC-04
name: Data Range
score: 3
max: 3
passed: true
comment: All data points visible within axes range
- id: SC-05
name: Legend Accuracy
score: 2
max: 2
passed: true
comment: Legend correctly identifies all four clusters
- id: SC-06
name: Title Format
score: 1
max: 2
passed: true
comment: Correct format but uses middle dot separator
data_quality:
score: 19
max: 20
items:
- id: DQ-01
name: Feature Coverage
score: 8
max: 8
passed: true
comment: Shows 4 distinct clusters with varying sizes, demonstrates selection
across multiple clusters
- id: DQ-02
name: Realistic Context
score: 6
max: 7
passed: true
comment: Generic clustered data scenario plausible for exploratory analysis
- id: DQ-03
name: Appropriate Scale
score: 5
max: 5
passed: true
comment: Values in reasonable numeric range (0-100)
code_quality:
score: 9
max: 10
items:
- id: CQ-01
name: KISS Structure
score: 3
max: 3
passed: true
comment: 'Clean script structure: imports, data generation, plot creation,
save'
- id: CQ-02
name: Reproducibility
score: 3
max: 3
passed: true
comment: Uses np.random.seed(42)
- id: CQ-03
name: Clean Imports
score: 2
max: 2
passed: true
comment: All imports are used
- id: CQ-04
name: No Deprecated API
score: 1
max: 1
passed: true
comment: Uses current lets_plot API
- id: CQ-05
name: Output Correct
score: 0
max: 1
passed: false
comment: Saves both plot.png and plot.html
library_features:
score: 4
max: 5
items:
- id: LF-01
name: Distinctive Features
score: 4
max: 5
passed: true
comment: Good use of layer_tooltips, geom_rect, scale_alpha_identity, HTML
export with zoom/pan toolbar
verdict: APPROVED
impl_tags:
dependencies: []
techniques:
- annotations
- hover-tooltips
- html-export
- layer-composition
patterns:
- data-generation
- iteration-over-groups
dataprep: []
styling:
- alpha-blending
- grid-styling