Skip to content

Commit 530f590

Browse files
feat(bokeh): implement contour-3d (#3273)
## Implementation: `contour-3d` - bokeh Implements the **bokeh** version of `contour-3d`. **File:** `plots/contour-3d/implementations/bokeh.py` **Parent Issue:** #3230 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20795211097)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fff8703 commit 530f590

2 files changed

Lines changed: 555 additions & 0 deletions

File tree

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
""" pyplots.ai
2+
contour-3d: 3D Contour Plot
3+
Library: bokeh 3.8.2 | Python 3.13.11
4+
Quality: 88/100 | Created: 2026-01-07
5+
"""
6+
7+
import matplotlib.pyplot as plt
8+
import numpy as np
9+
from bokeh.io import export_png, save
10+
from bokeh.models import ColorBar, HoverTool, Label, LinearColorMapper, Range1d
11+
from bokeh.palettes import Viridis256
12+
from bokeh.plotting import figure
13+
from bokeh.resources import CDN
14+
15+
16+
# Data - create a surface with multiple features to demonstrate contour visualization
17+
np.random.seed(42)
18+
19+
# Grid setup - 40x40 for clear contour detail
20+
n_points = 40
21+
x = np.linspace(-3, 3, n_points)
22+
y = np.linspace(-3, 3, n_points)
23+
X, Y = np.meshgrid(x, y)
24+
25+
# Surface function: Gaussian peaks with different heights
26+
# Primary peak at origin, secondary peak offset, creating interesting contours
27+
Z = 1.0 * np.exp(-(X**2 + Y**2) / 1.5) + 0.6 * np.exp(-((X - 1.5) ** 2 + (Y + 1.2) ** 2) / 0.8)
28+
29+
# Normalize Z for better visualization
30+
z_min, z_max = Z.min(), Z.max()
31+
32+
# 3D to 2D isometric projection parameters
33+
elev_rad = np.radians(25)
34+
azim_rad = np.radians(45)
35+
cos_azim = np.cos(azim_rad)
36+
sin_azim = np.sin(azim_rad)
37+
sin_elev = np.sin(elev_rad)
38+
cos_elev = np.cos(elev_rad)
39+
40+
# Scale Z for visualization (height range 0-2 for proportion)
41+
Z_scaled = (Z - z_min) / (z_max - z_min) * 2
42+
43+
# Project surface grid to 2D using inline isometric projection
44+
X_proj = np.zeros_like(X)
45+
Z_proj = np.zeros_like(X)
46+
Depth = np.zeros_like(X)
47+
48+
for i in range(n_points):
49+
for j in range(n_points):
50+
x_3d, y_3d, z_3d = X[i, j], Y[i, j], Z_scaled[i, j]
51+
x_rot = x_3d * cos_azim - y_3d * sin_azim
52+
y_rot = x_3d * sin_azim + y_3d * cos_azim
53+
X_proj[i, j] = x_rot
54+
Z_proj[i, j] = y_rot * sin_elev + z_3d * cos_elev
55+
Depth[i, j] = y_rot * cos_elev - z_3d * sin_elev
56+
57+
# Generate contour levels for the surface
58+
n_levels = 10
59+
levels = np.linspace(z_min, z_max, n_levels)
60+
61+
# Color mapper for z-values
62+
color_mapper = LinearColorMapper(palette=Viridis256, low=z_min, high=z_max)
63+
64+
# Collect surface quads for rendering (sorted by depth for painter's algorithm)
65+
surface_quads = []
66+
for i in range(n_points - 1):
67+
for j in range(n_points - 1):
68+
# Quad corners in projected 2D
69+
xs = [X_proj[i, j], X_proj[i + 1, j], X_proj[i + 1, j + 1], X_proj[i, j + 1]]
70+
ys = [Z_proj[i, j], Z_proj[i + 1, j], Z_proj[i + 1, j + 1], Z_proj[i, j + 1]]
71+
72+
# Average depth for sorting
73+
avg_depth = (Depth[i, j] + Depth[i + 1, j] + Depth[i + 1, j + 1] + Depth[i, j + 1]) / 4
74+
75+
# Average Z value for coloring
76+
avg_z = (Z[i, j] + Z[i + 1, j] + Z[i + 1, j + 1] + Z[i, j + 1]) / 4
77+
78+
# Map Z value to color index
79+
idx = int((avg_z - z_min) / (z_max - z_min) * 255)
80+
idx = max(0, min(255, idx))
81+
color = Viridis256[idx]
82+
83+
surface_quads.append((avg_depth, xs, ys, color, avg_z))
84+
85+
# Sort by depth (back to front - painter's algorithm)
86+
surface_quads.sort(key=lambda q: q[0], reverse=True)
87+
88+
# Generate contour lines using matplotlib's contour (no external dependency)
89+
fig_temp, ax_temp = plt.subplots()
90+
contour_set = ax_temp.contour(x, y, Z, levels=levels)
91+
plt.close(fig_temp)
92+
93+
# Generate 3D contour lines on the surface
94+
contour_lines_3d = []
95+
for level_idx, level in enumerate(levels):
96+
z_height = (level - z_min) / (z_max - z_min) * 2 # Scale to same range as surface
97+
98+
# Get contour segments from matplotlib
99+
for _path in contour_set.get_paths() if hasattr(contour_set, "get_paths") else []:
100+
pass # Matplotlib 3.8+ uses different API
101+
102+
# Use allsegs attribute for contour data
103+
if hasattr(contour_set, "allsegs") and level_idx < len(contour_set.allsegs):
104+
for segment in contour_set.allsegs[level_idx]:
105+
if len(segment) > 1:
106+
line_xs = []
107+
line_ys = []
108+
line_depths = []
109+
for pt in segment:
110+
x_pt, y_pt = pt
111+
x_rot = x_pt * cos_azim - y_pt * sin_azim
112+
y_rot = x_pt * sin_azim + y_pt * cos_azim
113+
line_xs.append(x_rot)
114+
line_ys.append(y_rot * sin_elev + z_height * cos_elev)
115+
line_depths.append(y_rot * cos_elev - z_height * sin_elev)
116+
117+
avg_depth = np.mean(line_depths)
118+
contour_lines_3d.append((avg_depth, line_xs, line_ys, level_idx, level))
119+
120+
# Generate projected contours on the base plane (z=0)
121+
base_contours = []
122+
for level_idx, level in enumerate(levels):
123+
if hasattr(contour_set, "allsegs") and level_idx < len(contour_set.allsegs):
124+
for segment in contour_set.allsegs[level_idx]:
125+
if len(segment) > 1:
126+
line_xs = []
127+
line_ys = []
128+
line_depths = []
129+
for pt in segment:
130+
x_pt, y_pt = pt
131+
# Inline projection with z=0 for base plane
132+
x_rot = x_pt * cos_azim - y_pt * sin_azim
133+
y_rot = x_pt * sin_azim + y_pt * cos_azim
134+
line_xs.append(x_rot)
135+
line_ys.append(y_rot * sin_elev)
136+
line_depths.append(y_rot * cos_elev)
137+
138+
avg_depth = np.mean(line_depths)
139+
# Color based on level
140+
idx = int(level_idx * 255 / (n_levels - 1))
141+
color = Viridis256[idx]
142+
base_contours.append((avg_depth, line_xs, line_ys, color, level))
143+
144+
# Create Bokeh figure
145+
p = figure(
146+
width=4800,
147+
height=2700,
148+
title="contour-3d · bokeh · pyplots.ai",
149+
toolbar_location="right",
150+
tools="pan,wheel_zoom,box_zoom,reset,save",
151+
)
152+
153+
# Hide default axes for 3D visualization
154+
p.xaxis.visible = False
155+
p.yaxis.visible = False
156+
157+
# Draw base plane contours first (behind surface) - more visible now
158+
for _depth, xs, ys, color, _level_val in sorted(base_contours, key=lambda c: c[0], reverse=True):
159+
p.line(x=xs, y=ys, line_color=color, line_width=3.5, line_alpha=0.65, line_dash="dashed")
160+
161+
# Draw surface quads with hover support
162+
for _depth, xs, ys, color, _avg_z in surface_quads:
163+
p.patch(x=xs, y=ys, fill_color=color, line_color="#444444", line_width=0.5, line_alpha=0.3, alpha=0.9)
164+
165+
# Draw 3D contour lines on surface (on top of surface)
166+
for _depth, xs, ys, _level_idx, _level_val in sorted(contour_lines_3d, key=lambda c: c[0], reverse=False):
167+
p.line(x=xs, y=ys, line_color="#222222", line_width=2.5, line_alpha=0.8)
168+
169+
# Calculate plot range from all elements
170+
all_x_coords = [x for quad in surface_quads for x in quad[1]]
171+
all_y_coords = [y for quad in surface_quads for y in quad[2]]
172+
173+
x_min_plot, x_max_plot = min(all_x_coords), max(all_x_coords)
174+
y_min_plot, y_max_plot = min(all_y_coords), max(all_y_coords)
175+
176+
x_pad = (x_max_plot - x_min_plot) * 0.15
177+
y_pad = (y_max_plot - y_min_plot) * 0.12
178+
179+
p.x_range = Range1d(x_min_plot - x_pad * 1.2, x_max_plot + x_pad * 2.0)
180+
p.y_range = Range1d(y_min_plot - y_pad * 0.8, y_max_plot + y_pad * 1.4)
181+
182+
# Custom 3D axis lines (inline projection)
183+
ox, oy, oz = -3.5, -3.5, 0
184+
origin_x = ox * cos_azim - oy * sin_azim
185+
origin_y = (ox * sin_azim + oy * cos_azim) * sin_elev + oz * cos_elev
186+
187+
axis_color = "#333333"
188+
axis_width = 6
189+
190+
# X-axis end point
191+
ax, ay, az = 3.5, -3.5, 0
192+
x_axis_end_x = ax * cos_azim - ay * sin_azim
193+
x_axis_end_y = (ax * sin_azim + ay * cos_azim) * sin_elev + az * cos_elev
194+
p.line(x=[origin_x, x_axis_end_x], y=[origin_y, x_axis_end_y], line_color=axis_color, line_width=axis_width)
195+
196+
# Y-axis end point
197+
bx, by, bz = -3.5, 3.5, 0
198+
y_axis_end_x = bx * cos_azim - by * sin_azim
199+
y_axis_end_y = (bx * sin_azim + by * cos_azim) * sin_elev + bz * cos_elev
200+
p.line(x=[origin_x, y_axis_end_x], y=[origin_y, y_axis_end_y], line_color=axis_color, line_width=axis_width)
201+
202+
# Z-axis end point
203+
cx, cy, cz = -3.5, -3.5, 2.5
204+
z_axis_end_x = cx * cos_azim - cy * sin_azim
205+
z_axis_end_y = (cx * sin_azim + cy * cos_azim) * sin_elev + cz * cos_elev
206+
p.line(x=[origin_x, z_axis_end_x], y=[origin_y, z_axis_end_y], line_color=axis_color, line_width=axis_width)
207+
208+
# Axis arrows
209+
arrow_size = 0.25
210+
211+
# X-axis arrow
212+
x_dir = np.array([x_axis_end_x - origin_x, x_axis_end_y - origin_y])
213+
x_dir = x_dir / np.linalg.norm(x_dir)
214+
x_perp = np.array([-x_dir[1], x_dir[0]])
215+
p.patch(
216+
x=[
217+
x_axis_end_x,
218+
x_axis_end_x - arrow_size * x_dir[0] + arrow_size * 0.5 * x_perp[0],
219+
x_axis_end_x - arrow_size * x_dir[0] - arrow_size * 0.5 * x_perp[0],
220+
],
221+
y=[
222+
x_axis_end_y,
223+
x_axis_end_y - arrow_size * x_dir[1] + arrow_size * 0.5 * x_perp[1],
224+
x_axis_end_y - arrow_size * x_dir[1] - arrow_size * 0.5 * x_perp[1],
225+
],
226+
fill_color=axis_color,
227+
line_color=axis_color,
228+
)
229+
230+
# Y-axis arrow
231+
y_dir = np.array([y_axis_end_x - origin_x, y_axis_end_y - origin_y])
232+
y_dir = y_dir / np.linalg.norm(y_dir)
233+
y_perp = np.array([-y_dir[1], y_dir[0]])
234+
p.patch(
235+
x=[
236+
y_axis_end_x,
237+
y_axis_end_x - arrow_size * y_dir[0] + arrow_size * 0.5 * y_perp[0],
238+
y_axis_end_x - arrow_size * y_dir[0] - arrow_size * 0.5 * y_perp[0],
239+
],
240+
y=[
241+
y_axis_end_y,
242+
y_axis_end_y - arrow_size * y_dir[1] + arrow_size * 0.5 * y_perp[1],
243+
y_axis_end_y - arrow_size * y_dir[1] - arrow_size * 0.5 * y_perp[1],
244+
],
245+
fill_color=axis_color,
246+
line_color=axis_color,
247+
)
248+
249+
# Z-axis arrow
250+
z_dir = np.array([z_axis_end_x - origin_x, z_axis_end_y - origin_y])
251+
z_dir = z_dir / np.linalg.norm(z_dir)
252+
z_perp = np.array([-z_dir[1], z_dir[0]])
253+
p.patch(
254+
x=[
255+
z_axis_end_x,
256+
z_axis_end_x - arrow_size * z_dir[0] + arrow_size * 0.5 * z_perp[0],
257+
z_axis_end_x - arrow_size * z_dir[0] - arrow_size * 0.5 * z_perp[0],
258+
],
259+
y=[
260+
z_axis_end_y,
261+
z_axis_end_y - arrow_size * z_dir[1] + arrow_size * 0.5 * z_perp[1],
262+
z_axis_end_y - arrow_size * z_dir[1] - arrow_size * 0.5 * z_perp[1],
263+
],
264+
fill_color=axis_color,
265+
line_color=axis_color,
266+
)
267+
268+
# Axis labels - repositioned for better visibility with larger font
269+
x_label = Label(
270+
x=x_axis_end_x + 0.25,
271+
y=x_axis_end_y - 0.55,
272+
text="Position X (units)",
273+
text_font_size="44pt",
274+
text_color="#222222",
275+
text_font_style="bold",
276+
)
277+
p.add_layout(x_label)
278+
279+
y_label = Label(
280+
x=y_axis_end_x - 1.5,
281+
y=y_axis_end_y + 0.35,
282+
text="Position Y (units)",
283+
text_font_size="44pt",
284+
text_color="#222222",
285+
text_font_style="bold",
286+
)
287+
p.add_layout(y_label)
288+
289+
z_label = Label(
290+
x=z_axis_end_x + 0.3,
291+
y=z_axis_end_y + 0.2,
292+
text="Amplitude (a.u.)",
293+
text_font_size="44pt",
294+
text_color="#222222",
295+
text_font_style="bold",
296+
)
297+
p.add_layout(z_label)
298+
299+
# Add colorbar for surface amplitude
300+
color_bar = ColorBar(
301+
color_mapper=color_mapper,
302+
width=80,
303+
location=(0, 0),
304+
title="Amplitude (a.u.)",
305+
title_text_font_size="40pt",
306+
major_label_text_font_size="32pt",
307+
title_standoff=30,
308+
margin=50,
309+
padding=25,
310+
)
311+
p.add_layout(color_bar, "right")
312+
313+
# Title styling - larger for better visibility
314+
p.title.text_font_size = "68pt"
315+
p.title.text_font_style = "bold"
316+
p.title.text_color = "#222222"
317+
318+
# Grid styling - subtle
319+
p.xgrid.grid_line_color = "#dddddd"
320+
p.ygrid.grid_line_color = "#dddddd"
321+
p.xgrid.grid_line_alpha = 0.25
322+
p.ygrid.grid_line_alpha = 0.25
323+
p.xgrid.grid_line_dash = [6, 4]
324+
p.ygrid.grid_line_dash = [6, 4]
325+
326+
# Background styling
327+
p.background_fill_color = "#f8f8f8"
328+
p.border_fill_color = "white"
329+
p.outline_line_color = None
330+
p.min_border_right = 250
331+
332+
# Add hover tool for interactive exploration (Bokeh distinctive feature)
333+
hover = HoverTool(tooltips=[("Position", "($x{0.00}, $y{0.00})")], mode="mouse")
334+
p.add_tools(hover)
335+
336+
# Save PNG
337+
export_png(p, filename="plot.png")
338+
339+
# Save HTML for interactive version
340+
save(p, filename="plot.html", resources=CDN, title="contour-3d · bokeh · pyplots.ai")

0 commit comments

Comments
 (0)