Skip to content

Commit d67ea0b

Browse files
fix(bokeh): address review feedback for stereonet-equal-area
Attempt 1/3 - fixes based on AI review
1 parent 3d4eba9 commit d67ea0b

1 file changed

Lines changed: 138 additions & 72 deletions

File tree

  • plots/stereonet-equal-area/implementations

plots/stereonet-equal-area/implementations/bokeh.py

Lines changed: 138 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
1-
""" pyplots.ai
1+
"""pyplots.ai
22
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
33
Library: bokeh 3.9.0 | Python 3.14.3
44
Quality: 75/100 | Created: 2026-03-15
55
"""
66

7-
import matplotlib
8-
9-
10-
matplotlib.use("Agg")
11-
import matplotlib.pyplot as plt
127
import numpy as np
138
from bokeh.io import export_png, output_file, save
14-
from bokeh.models import ColumnDataSource, Label, Legend, LegendItem
9+
from bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem
1510
from bokeh.plotting import figure
11+
from scipy.ndimage import gaussian_filter
1612
from scipy.stats import gaussian_kde
1713

1814

@@ -37,8 +33,6 @@
3733
colors_map = {"Bedding": "#306998", "Joints": "#E8833A", "Faults": "#8B5CF6"}
3834

3935
# Equal-area projection: convert pole (plunge, trend) to x, y
40-
# Pole to a plane: trend = dip_direction + 180, plunge = 90 - dip
41-
# Dip direction = strike + 90 (right-hand rule)
4236
R_net = 1.0
4337

4438
pole_trends = (all_strikes + 90 + 180) % 360
@@ -51,8 +45,6 @@
5145
pole_y = pole_r * np.cos(pole_trends_rad)
5246

5347
# Great circles for each plane
54-
# Parameterize: for rake angle alpha from 0 to pi, compute line in the plane
55-
# then project to equal-area
5648
gc_xs = []
5749
gc_ys = []
5850
gc_types = []
@@ -62,27 +54,22 @@
6254
dip_rad = np.radians(all_dips[i])
6355
dd_rad = strike_rad + np.pi / 2
6456

65-
# Strike vector (horizontal, in plane)
6657
sx = np.sin(strike_rad)
6758
sy = np.cos(strike_rad)
6859

69-
# Down-dip vector (in the plane, dipping)
7060
dx = np.sin(dd_rad) * np.cos(dip_rad)
7161
dy = np.cos(dd_rad) * np.cos(dip_rad)
7262
dz = -np.sin(dip_rad)
7363

74-
# Parameterize great circle: v = cos(alpha)*s_vec + sin(alpha)*d_vec
7564
alpha = np.linspace(0, np.pi, 90)
7665
vx = np.cos(alpha) * sx + np.sin(alpha) * dx
7766
vy = np.cos(alpha) * sy + np.sin(alpha) * dy
7867
vz = np.sin(alpha) * dz
7968

80-
# Convert to plunge and trend
8169
horiz = np.sqrt(vx**2 + vy**2)
8270
plunge = np.arctan2(-vz, horiz)
8371
trend = np.arctan2(vx, vy)
8472

85-
# Equal-area projection
8673
r = R_net * np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2)
8774
gx = r * np.sin(trend)
8875
gy = r * np.cos(trend)
@@ -91,136 +78,215 @@
9178
gc_ys.append(gy.tolist())
9279
gc_types.append(all_types[i])
9380

94-
# Kamb density contours on pole data
95-
grid_n = 200
96-
gx_lin = np.linspace(-1.05, 1.05, grid_n)
97-
gy_lin = np.linspace(-1.05, 1.05, grid_n)
81+
# Density grid for pole data using KDE
82+
grid_n = 300
83+
gx_lin = np.linspace(-R_net, R_net, grid_n)
84+
gy_lin = np.linspace(-R_net, R_net, grid_n)
9885
gx_grid, gy_grid = np.meshgrid(gx_lin, gy_lin)
9986

100-
# Mask to circular region
10187
dist_grid = np.sqrt(gx_grid**2 + gy_grid**2)
10288
mask = dist_grid <= R_net
10389

104-
# KDE on pole positions
10590
pole_xy = np.vstack([pole_x, pole_y])
10691
kde = gaussian_kde(pole_xy, bw_method=0.2)
10792
density = kde(np.vstack([gx_grid.ravel(), gy_grid.ravel()])).reshape(grid_n, grid_n)
108-
density[~mask] = 0
109-
110-
# Extract contour lines using matplotlib (computation only, not for display)
111-
fig_temp, ax_temp = plt.subplots()
112-
levels = np.linspace(density[mask].max() * 0.2, density[mask].max() * 0.9, 5)
113-
cs = ax_temp.contour(gx_lin, gy_lin, density, levels=levels)
114-
contour_xs = []
115-
contour_ys = []
116-
for level_segs in cs.allsegs:
117-
for seg in level_segs:
118-
# Clip to circle
119-
d = np.sqrt(seg[:, 0] ** 2 + seg[:, 1] ** 2)
120-
inside = d <= R_net * 1.01
121-
if np.sum(inside) > 2:
122-
contour_xs.append(seg[inside, 0].tolist())
123-
contour_ys.append(seg[inside, 1].tolist())
124-
plt.close(fig_temp)
93+
density = gaussian_filter(density, sigma=3)
94+
95+
# Create RGBA density overlay (masked to circle)
96+
density_masked = density.copy()
97+
density_masked[~mask] = np.nan
98+
d_min = np.nanmin(density_masked[mask])
99+
d_max = np.nanmax(density_masked[mask])
100+
d_norm = (density_masked - d_min) / (d_max - d_min)
101+
102+
# Build uint32 RGBA array for Bokeh image_rgba
103+
img = np.zeros((grid_n, grid_n), dtype=np.uint32)
104+
view = img.view(dtype=np.uint8).reshape((grid_n, grid_n, 4))
105+
for iy in range(grid_n):
106+
for ix in range(grid_n):
107+
if mask[iy, ix] and d_norm[iy, ix] > 0.12:
108+
v = d_norm[iy, ix]
109+
gray = int(170 - v * 110)
110+
alpha_val = int(v * 150)
111+
view[iy, ix, 0] = gray
112+
view[iy, ix, 1] = gray
113+
view[iy, ix, 2] = min(gray + 15, 255)
114+
view[iy, ix, 3] = alpha_val
125115

126116
# Plot - Square format for circular stereonet
127117
p = figure(
128118
width=3600,
129119
height=3600,
130120
title="stereonet-equal-area · bokeh · pyplots.ai",
131-
x_range=(-1.45, 1.45),
132-
y_range=(-1.40, 1.45),
121+
x_range=(-1.35, 1.35),
122+
y_range=(-1.38, 1.40),
133123
tools="pan,wheel_zoom,reset,save",
134124
toolbar_location=None,
135125
match_aspect=True,
136126
)
137127

128+
# Density heatmap as Bokeh image_rgba (distinctive Bokeh feature)
129+
p.image_rgba(image=[img], x=-R_net, y=-R_net, dw=2 * R_net, dh=2 * R_net, level="image")
130+
131+
# Equal-area net grid lines (small circles at 10° dip intervals)
132+
for dip_angle in range(10, 90, 10):
133+
dip_rad = np.radians(dip_angle)
134+
grid_r = R_net * np.sqrt(2) * np.sin(dip_rad / 2)
135+
theta = np.linspace(0, 2 * np.pi, 180)
136+
gx = grid_r * np.cos(theta)
137+
gy = grid_r * np.sin(theta)
138+
p.line(gx, gy, line_color="#CCCCCC", line_width=1, line_alpha=0.55)
139+
140+
# Great circle grid lines at every 30° azimuth
141+
for az_deg in range(0, 180, 30):
142+
az_rad = np.radians(az_deg)
143+
alpha = np.linspace(0, np.pi, 90)
144+
vx = np.cos(alpha) * np.sin(az_rad)
145+
vy = np.cos(alpha) * np.cos(az_rad)
146+
vz = -np.sin(alpha)
147+
horiz = np.sqrt(vx**2 + vy**2)
148+
plunge = np.arctan2(-vz, horiz)
149+
trend = np.arctan2(vx, vy)
150+
r = R_net * np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2)
151+
grid_gx = r * np.sin(trend)
152+
grid_gy = r * np.cos(trend)
153+
p.line(grid_gx, grid_gy, line_color="#CCCCCC", line_width=1, line_alpha=0.55)
154+
138155
# Primitive circle (outer boundary)
139156
theta_circle = np.linspace(0, 2 * np.pi, 360)
140157
circle_x = R_net * np.cos(theta_circle)
141158
circle_y = R_net * np.sin(theta_circle)
142-
p.line(circle_x, circle_y, line_color="#333333", line_width=3)
159+
p.line(circle_x, circle_y, line_color="#222222", line_width=4)
143160

144161
# Tick marks every 10 degrees around perimeter
145162
for deg in range(0, 360, 10):
146163
rad = np.radians(deg)
147-
x_inner = 0.96 * R_net * np.sin(rad)
148-
y_inner = 0.96 * R_net * np.cos(rad)
149-
x_outer = 1.03 * R_net * np.sin(rad)
150-
y_outer = 1.03 * R_net * np.cos(rad)
151-
lw = 3 if deg % 90 == 0 else 2
152-
p.line([x_inner, x_outer], [y_inner, y_outer], line_color="#333333", line_width=lw)
164+
tick_len = 0.06 if deg % 30 == 0 else 0.04
165+
x_inner = (1.0 - tick_len) * R_net * np.sin(rad)
166+
y_inner = (1.0 - tick_len) * R_net * np.cos(rad)
167+
x_outer = 1.04 * R_net * np.sin(rad)
168+
y_outer = 1.04 * R_net * np.cos(rad)
169+
lw = 4 if deg % 90 == 0 else (3 if deg % 30 == 0 else 2)
170+
p.line([x_inner, x_outer], [y_inner, y_outer], line_color="#222222", line_width=lw)
171+
172+
# Degree labels every 30 degrees
173+
for deg in range(0, 360, 30):
174+
if deg % 90 == 0:
175+
continue
176+
rad = np.radians(deg)
177+
lx = 1.13 * R_net * np.sin(rad)
178+
ly = 1.13 * R_net * np.cos(rad)
179+
p.add_layout(
180+
Label(
181+
x=lx,
182+
y=ly,
183+
text=f"{deg}°",
184+
text_font_size="18pt",
185+
text_align="center",
186+
text_baseline="middle",
187+
text_color="#555555",
188+
)
189+
)
153190

154191
# Cardinal direction labels
155192
for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]:
156193
rad = np.radians(deg)
157-
lx = 1.12 * R_net * np.sin(rad)
158-
ly = 1.12 * R_net * np.cos(rad)
159-
fs = "26pt" if label == "N" else "20pt"
160-
fw = "bold" if label == "N" else "normal"
194+
lx = 1.18 * R_net * np.sin(rad)
195+
ly = 1.18 * R_net * np.cos(rad)
196+
fs = "38pt" if label == "N" else "30pt"
161197
p.add_layout(
162198
Label(
163199
x=lx,
164200
y=ly,
165201
text=label,
166202
text_font_size=fs,
167-
text_font_style=fw,
203+
text_font_style="bold",
168204
text_align="center",
169205
text_baseline="middle",
170-
text_color="#333333",
206+
text_color="#222222",
171207
)
172208
)
173209

174-
# Density contour lines
175-
for cx, cy in zip(contour_xs, contour_ys, strict=True):
176-
p.line(cx, cy, line_color="#888888", line_width=2, line_alpha=0.5, line_dash="dotted")
177-
178-
# Great circles by feature type (draw with low alpha for clarity)
210+
# Great circles by feature type
179211
renderers_gc = {}
180212
for ftype in ["Bedding", "Joints", "Faults"]:
181213
idxs = [j for j, t in enumerate(gc_types) if t == ftype]
182214
fxs = [gc_xs[j] for j in idxs]
183215
fys = [gc_ys[j] for j in idxs]
184-
r = p.multi_line(fxs, fys, line_color=colors_map[ftype], line_width=2, line_alpha=0.35)
216+
r = p.multi_line(fxs, fys, line_color=colors_map[ftype], line_width=2.5, line_alpha=0.4)
185217
renderers_gc[ftype] = r
186218

187-
# Poles by feature type
219+
# Poles by feature type with HoverTool
188220
renderers_pole = {}
189221
for ftype in ["Bedding", "Joints", "Faults"]:
190222
idxs = [j for j, t in enumerate(all_types) if t == ftype]
191223
px = pole_x[idxs]
192224
py = pole_y[idxs]
193-
source = ColumnDataSource(data={"x": px, "y": py})
225+
strikes = all_strikes[idxs]
226+
dips = all_dips[idxs]
227+
source = ColumnDataSource(
228+
data={"x": px, "y": py, "strike": np.round(strikes, 1), "dip": np.round(dips, 1), "type": [ftype] * len(idxs)}
229+
)
194230
r = p.scatter(
195-
"x", "y", source=source, size=18, color=colors_map[ftype], line_color="white", line_width=1.5, alpha=0.85
231+
"x", "y", source=source, size=22, color=colors_map[ftype], line_color="white", line_width=2, alpha=0.9
196232
)
197233
renderers_pole[ftype] = r
198234

199-
# Legend
235+
# HoverTool for pole data (Bokeh distinctive feature)
236+
hover = HoverTool(
237+
renderers=list(renderers_pole.values()),
238+
tooltips=[("Type", "@type"), ("Strike", "@strike°"), ("Dip", "@dip°")],
239+
point_policy="snap_to_data",
240+
)
241+
p.add_tools(hover)
242+
243+
# Interactive legend (Bokeh distinctive feature - click to hide/show)
200244
legend_items = []
201245
for ftype in ["Bedding", "Joints", "Faults"]:
202246
legend_items.append(LegendItem(label=ftype, renderers=[renderers_gc[ftype], renderers_pole[ftype]]))
203247

204248
legend = Legend(items=legend_items, location="top_right")
205-
legend.label_text_font_size = "20pt"
206-
legend.glyph_height = 30
207-
legend.glyph_width = 30
208-
legend.spacing = 10
209-
legend.background_fill_alpha = 0.85
210-
legend.border_line_color = "#cccccc"
249+
legend.label_text_font_size = "26pt"
250+
legend.glyph_height = 40
251+
legend.glyph_width = 40
252+
legend.spacing = 16
253+
legend.background_fill_alpha = 0.92
254+
legend.background_fill_color = "#FAFAFA"
255+
legend.border_line_color = "#AAAAAA"
211256
legend.border_line_width = 2
212-
legend.padding = 15
257+
legend.padding = 24
258+
legend.margin = 20
259+
legend.click_policy = "hide"
213260
p.add_layout(legend)
214261

215262
# Style
216-
p.title.text_font_size = "30pt"
263+
p.title.text_font_size = "38pt"
217264
p.title.align = "center"
265+
p.title.text_color = "#222222"
218266
p.xaxis.visible = False
219267
p.yaxis.visible = False
220268
p.xgrid.visible = False
221269
p.ygrid.visible = False
222270
p.outline_line_color = None
223271
p.background_fill_color = "white"
272+
p.border_fill_color = "white"
273+
p.min_border_left = 40
274+
p.min_border_right = 40
275+
p.min_border_top = 10
276+
p.min_border_bottom = 40
277+
278+
# Subtitle annotation
279+
p.add_layout(
280+
Label(
281+
x=0,
282+
y=-1.30,
283+
text="Lower-hemisphere equal-area (Schmidt) projection · Click legend to toggle",
284+
text_font_size="20pt",
285+
text_align="center",
286+
text_color="#777777",
287+
text_font_style="italic",
288+
)
289+
)
224290

225291
# Save
226292
export_png(p, filename="plot.png")

0 commit comments

Comments
 (0)