|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
3 | 3 | Library: bokeh 3.9.0 | Python 3.14.3 |
4 | 4 | Quality: 75/100 | Created: 2026-03-15 |
5 | 5 | """ |
6 | 6 |
|
7 | | -import matplotlib |
8 | | - |
9 | | - |
10 | | -matplotlib.use("Agg") |
11 | | -import matplotlib.pyplot as plt |
12 | 7 | import numpy as np |
13 | 8 | 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 |
15 | 10 | from bokeh.plotting import figure |
| 11 | +from scipy.ndimage import gaussian_filter |
16 | 12 | from scipy.stats import gaussian_kde |
17 | 13 |
|
18 | 14 |
|
|
37 | 33 | colors_map = {"Bedding": "#306998", "Joints": "#E8833A", "Faults": "#8B5CF6"} |
38 | 34 |
|
39 | 35 | # 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) |
42 | 36 | R_net = 1.0 |
43 | 37 |
|
44 | 38 | pole_trends = (all_strikes + 90 + 180) % 360 |
|
51 | 45 | pole_y = pole_r * np.cos(pole_trends_rad) |
52 | 46 |
|
53 | 47 | # 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 |
56 | 48 | gc_xs = [] |
57 | 49 | gc_ys = [] |
58 | 50 | gc_types = [] |
|
62 | 54 | dip_rad = np.radians(all_dips[i]) |
63 | 55 | dd_rad = strike_rad + np.pi / 2 |
64 | 56 |
|
65 | | - # Strike vector (horizontal, in plane) |
66 | 57 | sx = np.sin(strike_rad) |
67 | 58 | sy = np.cos(strike_rad) |
68 | 59 |
|
69 | | - # Down-dip vector (in the plane, dipping) |
70 | 60 | dx = np.sin(dd_rad) * np.cos(dip_rad) |
71 | 61 | dy = np.cos(dd_rad) * np.cos(dip_rad) |
72 | 62 | dz = -np.sin(dip_rad) |
73 | 63 |
|
74 | | - # Parameterize great circle: v = cos(alpha)*s_vec + sin(alpha)*d_vec |
75 | 64 | alpha = np.linspace(0, np.pi, 90) |
76 | 65 | vx = np.cos(alpha) * sx + np.sin(alpha) * dx |
77 | 66 | vy = np.cos(alpha) * sy + np.sin(alpha) * dy |
78 | 67 | vz = np.sin(alpha) * dz |
79 | 68 |
|
80 | | - # Convert to plunge and trend |
81 | 69 | horiz = np.sqrt(vx**2 + vy**2) |
82 | 70 | plunge = np.arctan2(-vz, horiz) |
83 | 71 | trend = np.arctan2(vx, vy) |
84 | 72 |
|
85 | | - # Equal-area projection |
86 | 73 | r = R_net * np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2) |
87 | 74 | gx = r * np.sin(trend) |
88 | 75 | gy = r * np.cos(trend) |
|
91 | 78 | gc_ys.append(gy.tolist()) |
92 | 79 | gc_types.append(all_types[i]) |
93 | 80 |
|
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) |
98 | 85 | gx_grid, gy_grid = np.meshgrid(gx_lin, gy_lin) |
99 | 86 |
|
100 | | -# Mask to circular region |
101 | 87 | dist_grid = np.sqrt(gx_grid**2 + gy_grid**2) |
102 | 88 | mask = dist_grid <= R_net |
103 | 89 |
|
104 | | -# KDE on pole positions |
105 | 90 | pole_xy = np.vstack([pole_x, pole_y]) |
106 | 91 | kde = gaussian_kde(pole_xy, bw_method=0.2) |
107 | 92 | 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 |
125 | 115 |
|
126 | 116 | # Plot - Square format for circular stereonet |
127 | 117 | p = figure( |
128 | 118 | width=3600, |
129 | 119 | height=3600, |
130 | 120 | 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), |
133 | 123 | tools="pan,wheel_zoom,reset,save", |
134 | 124 | toolbar_location=None, |
135 | 125 | match_aspect=True, |
136 | 126 | ) |
137 | 127 |
|
| 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 | + |
138 | 155 | # Primitive circle (outer boundary) |
139 | 156 | theta_circle = np.linspace(0, 2 * np.pi, 360) |
140 | 157 | circle_x = R_net * np.cos(theta_circle) |
141 | 158 | 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) |
143 | 160 |
|
144 | 161 | # Tick marks every 10 degrees around perimeter |
145 | 162 | for deg in range(0, 360, 10): |
146 | 163 | 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 | + ) |
153 | 190 |
|
154 | 191 | # Cardinal direction labels |
155 | 192 | for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]: |
156 | 193 | 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" |
161 | 197 | p.add_layout( |
162 | 198 | Label( |
163 | 199 | x=lx, |
164 | 200 | y=ly, |
165 | 201 | text=label, |
166 | 202 | text_font_size=fs, |
167 | | - text_font_style=fw, |
| 203 | + text_font_style="bold", |
168 | 204 | text_align="center", |
169 | 205 | text_baseline="middle", |
170 | | - text_color="#333333", |
| 206 | + text_color="#222222", |
171 | 207 | ) |
172 | 208 | ) |
173 | 209 |
|
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 |
179 | 211 | renderers_gc = {} |
180 | 212 | for ftype in ["Bedding", "Joints", "Faults"]: |
181 | 213 | idxs = [j for j, t in enumerate(gc_types) if t == ftype] |
182 | 214 | fxs = [gc_xs[j] for j in idxs] |
183 | 215 | 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) |
185 | 217 | renderers_gc[ftype] = r |
186 | 218 |
|
187 | | -# Poles by feature type |
| 219 | +# Poles by feature type with HoverTool |
188 | 220 | renderers_pole = {} |
189 | 221 | for ftype in ["Bedding", "Joints", "Faults"]: |
190 | 222 | idxs = [j for j, t in enumerate(all_types) if t == ftype] |
191 | 223 | px = pole_x[idxs] |
192 | 224 | 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 | + ) |
194 | 230 | 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 |
196 | 232 | ) |
197 | 233 | renderers_pole[ftype] = r |
198 | 234 |
|
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) |
200 | 244 | legend_items = [] |
201 | 245 | for ftype in ["Bedding", "Joints", "Faults"]: |
202 | 246 | legend_items.append(LegendItem(label=ftype, renderers=[renderers_gc[ftype], renderers_pole[ftype]])) |
203 | 247 |
|
204 | 248 | 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" |
211 | 256 | legend.border_line_width = 2 |
212 | | -legend.padding = 15 |
| 257 | +legend.padding = 24 |
| 258 | +legend.margin = 20 |
| 259 | +legend.click_policy = "hide" |
213 | 260 | p.add_layout(legend) |
214 | 261 |
|
215 | 262 | # Style |
216 | | -p.title.text_font_size = "30pt" |
| 263 | +p.title.text_font_size = "38pt" |
217 | 264 | p.title.align = "center" |
| 265 | +p.title.text_color = "#222222" |
218 | 266 | p.xaxis.visible = False |
219 | 267 | p.yaxis.visible = False |
220 | 268 | p.xgrid.visible = False |
221 | 269 | p.ygrid.visible = False |
222 | 270 | p.outline_line_color = None |
223 | 271 | 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 | +) |
224 | 290 |
|
225 | 291 | # Save |
226 | 292 | export_png(p, filename="plot.png") |
|
0 commit comments