Skip to content

Commit 13cdc08

Browse files
feat(plotly): implement linked-views-selection (#3372)
## Implementation: `linked-views-selection` - plotly Implements the **plotly** version of `linked-views-selection`. **File:** `plots/linked-views-selection/implementations/plotly.py` **Parent Issue:** #3344 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20832953481)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 570c46a commit 13cdc08

File tree

2 files changed

+498
-0
lines changed

2 files changed

+498
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
""" pyplots.ai
2+
linked-views-selection: Multiple Linked Views with Selection Sync
3+
Library: plotly 6.5.1 | Python 3.13.11
4+
Quality: 90/100 | Created: 2026-01-08
5+
"""
6+
7+
import numpy as np
8+
import plotly.graph_objects as go
9+
from plotly.subplots import make_subplots
10+
11+
12+
# Data - Iris-like multivariate dataset with clear clusters
13+
np.random.seed(42)
14+
15+
# Create 3 distinct groups with different characteristics
16+
n_per_group = 50
17+
categories = ["Setosa", "Versicolor", "Virginica"]
18+
colors = ["#306998", "#FFD43B", "#E53935"]
19+
deselected_colors = ["rgba(48,105,152,0.2)", "rgba(255,212,59,0.2)", "rgba(229,57,53,0.2)"]
20+
21+
# Generate clustered data
22+
sepal_length = np.concatenate(
23+
[
24+
np.random.normal(5.0, 0.35, n_per_group),
25+
np.random.normal(5.9, 0.50, n_per_group),
26+
np.random.normal(6.6, 0.60, n_per_group),
27+
]
28+
)
29+
sepal_width = np.concatenate(
30+
[
31+
np.random.normal(3.4, 0.38, n_per_group),
32+
np.random.normal(2.8, 0.30, n_per_group),
33+
np.random.normal(3.0, 0.32, n_per_group),
34+
]
35+
)
36+
petal_length = np.concatenate(
37+
[
38+
np.random.normal(1.5, 0.17, n_per_group),
39+
np.random.normal(4.3, 0.45, n_per_group),
40+
np.random.normal(5.5, 0.55, n_per_group),
41+
]
42+
)
43+
category = np.repeat(categories, n_per_group)
44+
point_indices = np.arange(len(sepal_length))
45+
46+
# Create subplots: scatter plot, histogram, and bar chart
47+
fig = make_subplots(
48+
rows=2,
49+
cols=2,
50+
specs=[[{"colspan": 2}, None], [{}, {}]],
51+
subplot_titles=("Sepal Dimensions by Species", "Petal Length Distribution", "Species Count"),
52+
vertical_spacing=0.15,
53+
horizontal_spacing=0.12,
54+
)
55+
56+
# Add scatter plot for each category (top row, spans both columns)
57+
for i, cat in enumerate(categories):
58+
mask = category == cat
59+
fig.add_trace(
60+
go.Scatter(
61+
x=sepal_length[mask],
62+
y=sepal_width[mask],
63+
mode="markers",
64+
marker={"size": 14, "color": colors[i], "opacity": 0.8, "line": {"width": 1, "color": "white"}},
65+
name=cat,
66+
legendgroup=cat,
67+
customdata=np.column_stack([point_indices[mask], np.full(mask.sum(), i)]),
68+
hovertemplate=f"<b>{cat}</b><br>Sepal Length: %{{x:.2f}} cm<br>Sepal Width: %{{y:.2f}} cm<extra></extra>",
69+
selected={"marker": {"opacity": 1.0, "size": 16}},
70+
unselected={"marker": {"opacity": 0.2, "size": 10}},
71+
),
72+
row=1,
73+
col=1,
74+
)
75+
76+
# Add histogram for petal length (bottom left) - use bar for better selection control
77+
for i, cat in enumerate(categories):
78+
mask = category == cat
79+
petal_data = petal_length[mask]
80+
hist, bin_edges = np.histogram(petal_data, bins=15, range=(0, 7))
81+
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
82+
fig.add_trace(
83+
go.Bar(
84+
x=bin_centers,
85+
y=hist,
86+
width=bin_edges[1] - bin_edges[0],
87+
name=cat,
88+
marker={"color": colors[i], "opacity": 0.7, "line": {"width": 1, "color": "white"}},
89+
legendgroup=cat,
90+
showlegend=False,
91+
hovertemplate=f"<b>{cat}</b><br>Petal Length: %{{x:.2f}} cm<br>Count: %{{y}}<extra></extra>",
92+
customdata=np.full(len(hist), i),
93+
),
94+
row=2,
95+
col=1,
96+
)
97+
98+
# Add bar chart for species count (bottom right)
99+
counts = [n_per_group] * 3
100+
fig.add_trace(
101+
go.Bar(
102+
x=categories,
103+
y=counts,
104+
marker={"color": colors, "opacity": 0.85, "line": {"width": 2, "color": "white"}},
105+
showlegend=False,
106+
hovertemplate="<b>%{x}</b><br>Count: %{y}<extra></extra>",
107+
customdata=list(range(3)),
108+
),
109+
row=2,
110+
col=2,
111+
)
112+
113+
# Update layout for linked selection
114+
fig.update_layout(
115+
title={
116+
"text": "linked-views-selection · plotly · pyplots.ai",
117+
"font": {"size": 32, "color": "#333333"},
118+
"x": 0.5,
119+
"xanchor": "center",
120+
},
121+
template="plotly_white",
122+
font={"size": 18},
123+
legend={
124+
"title": {"text": "Species", "font": {"size": 20}},
125+
"font": {"size": 18},
126+
"orientation": "h",
127+
"yanchor": "bottom",
128+
"y": 1.02,
129+
"xanchor": "center",
130+
"x": 0.5,
131+
"itemsizing": "constant",
132+
},
133+
barmode="overlay",
134+
hovermode="closest",
135+
dragmode="select",
136+
margin={"l": 80, "r": 80, "t": 140, "b": 100},
137+
annotations=[
138+
{
139+
"text": "Use box/lasso select on scatter plot to highlight species across all views | Double-click to reset",
140+
"xref": "paper",
141+
"yref": "paper",
142+
"x": 0.5,
143+
"y": -0.10,
144+
"showarrow": False,
145+
"font": {"size": 16, "color": "#666666"},
146+
"xanchor": "center",
147+
}
148+
],
149+
)
150+
151+
# Update subplot titles font size
152+
for annotation in fig.layout.annotations:
153+
if annotation.text in ["Sepal Dimensions by Species", "Petal Length Distribution", "Species Count"]:
154+
annotation.font = {"size": 22}
155+
156+
# Update axes
157+
fig.update_xaxes(
158+
title={"text": "Sepal Length (cm)", "font": {"size": 20}},
159+
tickfont={"size": 16},
160+
gridcolor="rgba(0,0,0,0.1)",
161+
row=1,
162+
col=1,
163+
)
164+
fig.update_yaxes(
165+
title={"text": "Sepal Width (cm)", "font": {"size": 20}},
166+
tickfont={"size": 16},
167+
gridcolor="rgba(0,0,0,0.1)",
168+
row=1,
169+
col=1,
170+
)
171+
fig.update_xaxes(
172+
title={"text": "Petal Length (cm)", "font": {"size": 20}},
173+
tickfont={"size": 16},
174+
gridcolor="rgba(0,0,0,0.1)",
175+
row=2,
176+
col=1,
177+
)
178+
fig.update_yaxes(
179+
title={"text": "Count", "font": {"size": 20}}, tickfont={"size": 16}, gridcolor="rgba(0,0,0,0.1)", row=2, col=1
180+
)
181+
fig.update_xaxes(title={"text": "Species", "font": {"size": 20}}, tickfont={"size": 16}, row=2, col=2)
182+
fig.update_yaxes(
183+
title={"text": "Count", "font": {"size": 20}}, tickfont={"size": 16}, gridcolor="rgba(0,0,0,0.1)", row=2, col=2
184+
)
185+
186+
# Configure select/lasso behavior
187+
fig.update_layout(selectdirection="any", newselection={"line": {"color": "#306998", "width": 2}})
188+
189+
# Save PNG
190+
fig.write_image("plot.png", width=1600, height=900, scale=3)
191+
192+
# JavaScript for true cross-view linked selection
193+
linked_selection_js = """
194+
<script>
195+
(function() {
196+
var gd = document.getElementById('plotly-graph');
197+
var originalColors = ['#306998', '#FFD43B', '#E53935'];
198+
var fadedColors = ['rgba(48,105,152,0.2)', 'rgba(255,212,59,0.2)', 'rgba(229,57,53,0.2)'];
199+
var histTraces = [3, 4, 5]; // Histogram bar traces
200+
var countBarTrace = 6; // Species count bar trace
201+
202+
// Handle selection on scatter traces (0, 1, 2)
203+
gd.on('plotly_selected', function(eventData) {
204+
if (!eventData || !eventData.points || eventData.points.length === 0) return;
205+
206+
// Determine which species are selected
207+
var selectedSpecies = new Set();
208+
eventData.points.forEach(function(pt) {
209+
if (pt.curveNumber < 3) { // Scatter traces
210+
selectedSpecies.add(pt.curveNumber);
211+
}
212+
});
213+
214+
// Update histogram bars
215+
var histColors = [];
216+
var histOpacities = [];
217+
for (var i = 0; i < 3; i++) {
218+
if (selectedSpecies.has(i)) {
219+
histColors.push(originalColors[i]);
220+
histOpacities.push(0.85);
221+
} else {
222+
histColors.push(fadedColors[i]);
223+
histOpacities.push(0.3);
224+
}
225+
}
226+
227+
// Update count bar colors
228+
var countColors = [];
229+
var countOpacities = [];
230+
for (var j = 0; j < 3; j++) {
231+
if (selectedSpecies.has(j)) {
232+
countColors.push(originalColors[j]);
233+
countOpacities.push(0.85);
234+
} else {
235+
countColors.push(fadedColors[j]);
236+
countOpacities.push(0.3);
237+
}
238+
}
239+
240+
// Apply updates
241+
var updates = [];
242+
var indices = [];
243+
244+
histTraces.forEach(function(traceIdx, i) {
245+
updates.push({'marker.color': selectedSpecies.has(i) ? originalColors[i] : fadedColors[i],
246+
'marker.opacity': selectedSpecies.has(i) ? 0.85 : 0.3});
247+
indices.push(traceIdx);
248+
});
249+
250+
Plotly.restyle(gd, {'marker.color': [countColors]}, [countBarTrace]);
251+
252+
for (var k = 0; k < histTraces.length; k++) {
253+
Plotly.restyle(gd, {
254+
'marker.color': selectedSpecies.has(k) ? originalColors[k] : fadedColors[k],
255+
'marker.opacity': selectedSpecies.has(k) ? 0.85 : 0.3
256+
}, [histTraces[k]]);
257+
}
258+
});
259+
260+
// Reset on double-click or deselect
261+
gd.on('plotly_deselect', function() {
262+
// Reset histogram colors
263+
for (var i = 0; i < histTraces.length; i++) {
264+
Plotly.restyle(gd, {
265+
'marker.color': originalColors[i],
266+
'marker.opacity': 0.7
267+
}, [histTraces[i]]);
268+
}
269+
// Reset count bar
270+
Plotly.restyle(gd, {'marker.color': [originalColors]}, [countBarTrace]);
271+
});
272+
})();
273+
</script>
274+
"""
275+
276+
# Save HTML with linked selection JavaScript
277+
html_content = fig.to_html(include_plotlyjs=True, full_html=True, div_id="plotly-graph")
278+
html_content = html_content.replace("</body>", linked_selection_js + "</body>")
279+
280+
with open("plot.html", "w", encoding="utf-8") as f:
281+
f.write(html_content)

0 commit comments

Comments
 (0)