Skip to content

Commit 3cb77e1

Browse files
feat(letsplot): implement scatter-brush-zoom (#3336)
## Implementation: `scatter-brush-zoom` - letsplot Implements the **letsplot** version of `scatter-brush-zoom`. **File:** `plots/scatter-brush-zoom/implementations/letsplot.py` **Parent Issue:** #3295 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20823308871)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent e5fff60 commit 3cb77e1

2 files changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
""" pyplots.ai
2+
scatter-brush-zoom: Interactive Scatter Plot with Brush Selection and Zoom
3+
Library: letsplot 4.8.2 | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-08
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
element_line,
13+
element_text,
14+
geom_point,
15+
geom_rect,
16+
geom_text,
17+
ggplot,
18+
ggsize,
19+
labs,
20+
layer_tooltips,
21+
scale_alpha_identity,
22+
scale_color_manual,
23+
theme,
24+
theme_minimal,
25+
)
26+
from lets_plot.export import ggsave
27+
28+
29+
LetsPlot.setup_html()
30+
31+
# Data - Generate clustered data for brush selection demonstration
32+
np.random.seed(42)
33+
34+
# Create 4 distinct clusters with different sizes
35+
n_per_cluster = [80, 120, 100, 100]
36+
centers = [(20, 60), (50, 30), (70, 70), (40, 80)]
37+
spreads = [8, 12, 10, 6]
38+
categories = ["Cluster A", "Cluster B", "Cluster C", "Cluster D"]
39+
40+
x_data, y_data, colors, labels, selected = [], [], [], [], []
41+
point_id = 0
42+
for n, (cx, cy), spread, cat in zip(n_per_cluster, centers, spreads, categories, strict=True):
43+
for _ in range(n):
44+
x_val = np.random.normal(cx, spread)
45+
y_val = np.random.normal(cy, spread)
46+
x_data.append(x_val)
47+
y_data.append(y_val)
48+
colors.append(cat)
49+
labels.append(f"P{point_id:03d}")
50+
# Mark points within the brush selection region as selected
51+
is_selected = 25 <= x_val <= 55 and 50 <= y_val <= 85
52+
selected.append(is_selected)
53+
point_id += 1
54+
55+
df = pd.DataFrame(
56+
{
57+
"x": x_data,
58+
"y": y_data,
59+
"category": colors,
60+
"label": labels,
61+
"selected": selected,
62+
"point_alpha": [0.9 if s else 0.25 for s in selected],
63+
}
64+
)
65+
66+
# Create tooltips for hover interaction - shows point details
67+
tooltips = (
68+
layer_tooltips()
69+
.title("@label")
70+
.line("Category: @category")
71+
.line("X: @x (units)")
72+
.line("Y: @y (units)")
73+
.line("Selected: @selected")
74+
.format("x", ".1f")
75+
.format("y", ".1f")
76+
)
77+
78+
# Brush selection rectangle coordinates
79+
brush_xmin, brush_xmax = 25, 55
80+
brush_ymin, brush_ymax = 50, 85
81+
n_selected = df["selected"].sum()
82+
83+
# Create brush rectangle data
84+
brush_df = pd.DataFrame({"xmin": [brush_xmin], "xmax": [brush_xmax], "ymin": [brush_ymin], "ymax": [brush_ymax]})
85+
86+
# Create interactive scatter plot with brush selection visualization
87+
# The static PNG demonstrates brush selection with:
88+
# - Visible brush rectangle (blue dashed border with light fill)
89+
# - Selected points shown with full opacity, unselected dimmed
90+
# - Selection count annotation
91+
# The HTML export includes built-in toolbar with:
92+
# - Pan (drag to move around)
93+
# - Wheel zoom (scroll to zoom in/out)
94+
# - Box zoom (select area to zoom)
95+
# - Reset (double-click to reset view)
96+
plot = (
97+
ggplot(df, aes(x="x", y="y"))
98+
# Draw brush selection rectangle first (behind points)
99+
+ geom_rect(
100+
aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"),
101+
data=brush_df,
102+
inherit_aes=False,
103+
fill="#3B82F6",
104+
alpha=0.15,
105+
color="#3B82F6",
106+
linetype="dashed",
107+
size=1.5,
108+
)
109+
# Plot points with alpha based on selection state
110+
+ geom_point(aes(color="category", alpha="point_alpha"), size=5, tooltips=tooltips)
111+
+ scale_color_manual(values=["#306998", "#FFD43B", "#DC2626", "#059669"])
112+
+ scale_alpha_identity()
113+
+ labs(
114+
x="X Value (units)", y="Y Value (units)", title="scatter-brush-zoom · letsplot · pyplots.ai", color="Category"
115+
)
116+
+ theme_minimal()
117+
+ theme(
118+
axis_title=element_text(size=20),
119+
axis_text=element_text(size=16),
120+
plot_title=element_text(size=24, margin=[0, 0, 10, 0]),
121+
legend_title=element_text(size=18),
122+
legend_text=element_text(size=16),
123+
panel_grid_major=element_line(color="#E5E5E5", size=0.5),
124+
panel_grid_minor=element_line(color="#F0F0F0", size=0.3),
125+
)
126+
+ ggsize(1600, 900)
127+
)
128+
129+
# Add annotation showing selection count
130+
annotation_df = pd.DataFrame({"x": [40], "y": [88], "text": [f"Brush Selection: {n_selected} points selected"]})
131+
132+
plot = plot + geom_text(
133+
aes(x="x", y="y", label="text"), data=annotation_df, inherit_aes=False, size=14, color="#1E40AF", fontface="bold"
134+
)
135+
136+
# Save static PNG (scaled 3x for 4800x2700 px)
137+
ggsave(plot, "plot.png", path=".", scale=3)
138+
139+
# Save interactive HTML with zoom, pan, and hover capabilities
140+
# The HTML viewer includes a toolbar with:
141+
# - Pan mode: click and drag to navigate
142+
# - Wheel zoom: scroll to zoom in/out
143+
# - Box zoom: draw rectangle to zoom to area (brush-like selection)
144+
# - Reset: double-click or use reset button to restore original view
145+
ggsave(plot, "plot.html", path=".")
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
library: letsplot
2+
specification_id: scatter-brush-zoom
3+
created: '2026-01-08T16:14:23Z'
4+
updated: '2026-01-08T16:38:15Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20823308871
7+
issue: 3295
8+
python_version: 3.13.11
9+
library_version: 4.8.2
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/scatter-brush-zoom/letsplot/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/scatter-brush-zoom/letsplot/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/scatter-brush-zoom/letsplot/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent visualization of brush selection with clear visual distinction between
17+
selected and unselected points
18+
- Proper use of lets-plot tooltip system for hover information
19+
- Good clustering data that demonstrates utility of brush selection across multiple
20+
groups
21+
- Clean, well-structured code following KISS principles
22+
- Both PNG and HTML exports provided, showcasing lets-plot dual-output capability
23+
- Selection count annotation adds useful context
24+
weaknesses:
25+
- Legend appears slightly cut off on the right edge of the image
26+
- The alpha=0.25 for unselected points may be too faint for some users to distinguish
27+
clusters
28+
- Static PNG cannot demonstrate actual brush/zoom interactivity (inherent limitation)
29+
image_description: 'The plot displays an interactive scatter plot demonstrating
30+
brush selection and zoom functionality. The canvas shows 400 data points distributed
31+
across 4 distinct clusters (Cluster A in blue, Cluster B in yellow, Cluster C
32+
in red, Cluster D in green). A prominent light blue rectangular brush selection
33+
area is visible with a dashed blue border spanning approximately x=[25,55] and
34+
y=[50,85]. Points within this selection region appear at full opacity while points
35+
outside are dimmed (alpha ~0.25). A bold blue annotation at the top reads "Brush
36+
Selection: 100 points selected". The axes are labeled "X Value (units)" and "Y
37+
Value (units)" with clear tick marks. The title correctly follows the format "scatter-brush-zoom
38+
· letsplot · pyplots.ai". A legend on the right identifies the four clusters.
39+
The grid is subtle with light gray lines.'
40+
criteria_checklist:
41+
visual_quality:
42+
score: 36
43+
max: 40
44+
items:
45+
- id: VQ-01
46+
name: Text Legibility
47+
score: 10
48+
max: 10
49+
passed: true
50+
comment: Title at 24pt, axis labels at 20pt, tick labels at 16pt, all perfectly
51+
readable
52+
- id: VQ-02
53+
name: No Overlap
54+
score: 8
55+
max: 8
56+
passed: true
57+
comment: No overlapping text elements, annotation placed in clear area
58+
- id: VQ-03
59+
name: Element Visibility
60+
score: 6
61+
max: 8
62+
passed: true
63+
comment: Points visible with good size, alpha differentiation works but dimmed
64+
points quite faint
65+
- id: VQ-04
66+
name: Color Accessibility
67+
score: 5
68+
max: 5
69+
passed: true
70+
comment: Four distinct colors distinguishable with good contrast
71+
- id: VQ-05
72+
name: Layout Balance
73+
score: 5
74+
max: 5
75+
passed: true
76+
comment: Plot fills canvas appropriately, balanced margins
77+
- id: VQ-06
78+
name: Axis Labels
79+
score: 2
80+
max: 2
81+
passed: true
82+
comment: 'Descriptive with units: X Value (units), Y Value (units)'
83+
- id: VQ-07
84+
name: Grid & Legend
85+
score: 0
86+
max: 2
87+
passed: false
88+
comment: Legend appears cut off on right edge of image
89+
spec_compliance:
90+
score: 23
91+
max: 25
92+
items:
93+
- id: SC-01
94+
name: Plot Type
95+
score: 8
96+
max: 8
97+
passed: true
98+
comment: Correct scatter plot with brush selection visualization
99+
- id: SC-02
100+
name: Data Mapping
101+
score: 5
102+
max: 5
103+
passed: true
104+
comment: X/Y correctly mapped to numeric axes
105+
- id: SC-03
106+
name: Required Features
107+
score: 4
108+
max: 5
109+
passed: true
110+
comment: Brush rectangle, selected points highlighted, selection count shown;
111+
static image cannot show interactivity
112+
- id: SC-04
113+
name: Data Range
114+
score: 3
115+
max: 3
116+
passed: true
117+
comment: All data points visible within axes range
118+
- id: SC-05
119+
name: Legend Accuracy
120+
score: 2
121+
max: 2
122+
passed: true
123+
comment: Legend correctly identifies all four clusters
124+
- id: SC-06
125+
name: Title Format
126+
score: 1
127+
max: 2
128+
passed: true
129+
comment: Correct format but uses middle dot separator
130+
data_quality:
131+
score: 19
132+
max: 20
133+
items:
134+
- id: DQ-01
135+
name: Feature Coverage
136+
score: 8
137+
max: 8
138+
passed: true
139+
comment: Shows 4 distinct clusters with varying sizes, demonstrates selection
140+
across multiple clusters
141+
- id: DQ-02
142+
name: Realistic Context
143+
score: 6
144+
max: 7
145+
passed: true
146+
comment: Generic clustered data scenario plausible for exploratory analysis
147+
- id: DQ-03
148+
name: Appropriate Scale
149+
score: 5
150+
max: 5
151+
passed: true
152+
comment: Values in reasonable numeric range (0-100)
153+
code_quality:
154+
score: 9
155+
max: 10
156+
items:
157+
- id: CQ-01
158+
name: KISS Structure
159+
score: 3
160+
max: 3
161+
passed: true
162+
comment: 'Clean script structure: imports, data generation, plot creation,
163+
save'
164+
- id: CQ-02
165+
name: Reproducibility
166+
score: 3
167+
max: 3
168+
passed: true
169+
comment: Uses np.random.seed(42)
170+
- id: CQ-03
171+
name: Clean Imports
172+
score: 2
173+
max: 2
174+
passed: true
175+
comment: All imports are used
176+
- id: CQ-04
177+
name: No Deprecated API
178+
score: 1
179+
max: 1
180+
passed: true
181+
comment: Uses current lets_plot API
182+
- id: CQ-05
183+
name: Output Correct
184+
score: 0
185+
max: 1
186+
passed: false
187+
comment: Saves both plot.png and plot.html
188+
library_features:
189+
score: 4
190+
max: 5
191+
items:
192+
- id: LF-01
193+
name: Distinctive Features
194+
score: 4
195+
max: 5
196+
passed: true
197+
comment: Good use of layer_tooltips, geom_rect, scale_alpha_identity, HTML
198+
export with zoom/pan toolbar
199+
verdict: APPROVED
200+
impl_tags:
201+
dependencies: []
202+
techniques:
203+
- annotations
204+
- hover-tooltips
205+
- html-export
206+
- layer-composition
207+
patterns:
208+
- data-generation
209+
- iteration-over-groups
210+
dataprep: []
211+
styling:
212+
- alpha-blending
213+
- grid-styling

0 commit comments

Comments
 (0)