Skip to content

Commit 271140c

Browse files
feat(letsplot): implement stereonet-equal-area (#4918)
## Implementation: `stereonet-equal-area` - letsplot Implements the **letsplot** version of `stereonet-equal-area`. **File:** `plots/stereonet-equal-area/implementations/letsplot.py` **Parent Issue:** #4576 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23121189167)* --------- 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 fccb15d commit 271140c

2 files changed

Lines changed: 519 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
""" pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 88/100 | Created: 2026-03-15
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
coord_fixed,
13+
element_blank,
14+
element_rect,
15+
element_text,
16+
geom_density2d,
17+
geom_path,
18+
geom_point,
19+
geom_polygon,
20+
geom_segment,
21+
geom_text,
22+
ggplot,
23+
ggsize,
24+
labs,
25+
layer_tooltips,
26+
scale_color_manual,
27+
scale_fill_manual,
28+
scale_x_continuous,
29+
scale_y_continuous,
30+
theme,
31+
)
32+
from lets_plot.export import ggsave
33+
34+
35+
LetsPlot.setup_html()
36+
37+
# Data - Field measurements from a structural geology mapping campaign
38+
np.random.seed(42)
39+
40+
# Bedding planes (NE strike ~045, moderate dip ~35° SE)
41+
n_bedding = 25
42+
bedding_strike = np.random.normal(45, 12, n_bedding) % 360
43+
bedding_dip = np.clip(np.random.normal(35, 8, n_bedding), 5, 85)
44+
45+
# Joint set (N-S striking ~000, steep dip ~78° W)
46+
n_joints = 20
47+
joints_strike = np.random.normal(355, 10, n_joints) % 360
48+
joints_dip = np.clip(np.random.normal(78, 7, n_joints), 10, 89)
49+
50+
# Faults (NW-SE striking ~150, moderate-steep dip ~60°)
51+
n_faults = 13
52+
faults_strike = np.random.uniform(130, 170, n_faults)
53+
faults_dip = np.clip(np.random.normal(60, 12, n_faults), 10, 89)
54+
55+
# Foliation (E-W strike ~270, gentle dip ~25° N)
56+
n_foliation = 12
57+
foliation_strike = np.random.normal(270, 8, n_foliation) % 360
58+
foliation_dip = np.clip(np.random.normal(25, 6, n_foliation), 5, 60)
59+
60+
strikes = np.concatenate([bedding_strike, joints_strike, faults_strike, foliation_strike])
61+
dips = np.concatenate([bedding_dip, joints_dip, faults_dip, foliation_dip])
62+
feature_types = ["Bedding"] * n_bedding + ["Joint"] * n_joints + ["Fault"] * n_faults + ["Foliation"] * n_foliation
63+
64+
# Equal-area (Schmidt net) lower-hemisphere projection
65+
strike_rad = np.radians(strikes)
66+
dip_rad = np.radians(dips)
67+
dip_dir_rad = strike_rad + np.pi / 2
68+
69+
# Pole 3D coordinates (lower hemisphere normal to each plane)
70+
pole_x_3d = np.sin(dip_rad) * np.sin(dip_dir_rad)
71+
pole_y_3d = np.sin(dip_rad) * np.cos(dip_dir_rad)
72+
pole_z_3d = -np.cos(dip_rad)
73+
74+
# Lambert azimuthal equal-area projection
75+
pole_scale = 1.0 / np.sqrt(1.0 - pole_z_3d)
76+
pole_x = pole_x_3d * pole_scale
77+
pole_y = pole_y_3d * pole_scale
78+
79+
poles_df = pd.DataFrame(
80+
{
81+
"x": pole_x,
82+
"y": pole_y,
83+
"feature_type": feature_types,
84+
"strike": [f"{s:.0f}°" for s in strikes],
85+
"dip": [f"{d:.0f}°" for d in dips],
86+
}
87+
)
88+
89+
# Great circle paths for each plane
90+
gc_rows = []
91+
alphas = np.linspace(0, np.pi, 60)
92+
cos_a = np.cos(alphas)
93+
sin_a = np.sin(alphas)
94+
95+
for i in range(len(strikes)):
96+
s = strike_rad[i]
97+
d = dip_rad[i]
98+
dd = dip_dir_rad[i]
99+
sv_x, sv_y = np.sin(s), np.cos(s)
100+
dv_x = np.cos(d) * np.sin(dd)
101+
dv_y = np.cos(d) * np.cos(dd)
102+
dv_z = -np.sin(d)
103+
px = cos_a * sv_x + sin_a * dv_x
104+
py = cos_a * sv_y + sin_a * dv_y
105+
pz = sin_a * dv_z
106+
sc = 1.0 / np.sqrt(1.0 - pz)
107+
gx = px * sc
108+
gy = py * sc
109+
# Clip to unit circle boundary
110+
r2 = gx**2 + gy**2
111+
mask_inside = r2 <= 1.0
112+
gx_clip = gx[mask_inside]
113+
gy_clip = gy[mask_inside]
114+
for j in range(len(gx_clip)):
115+
gc_rows.append({"x": gx_clip[j], "y": gy_clip[j], "group": i, "feature_type": feature_types[i]})
116+
117+
gc_df = pd.DataFrame(gc_rows)
118+
119+
# Primitive circle (outer boundary)
120+
theta = np.linspace(0, 2 * np.pi, 200)
121+
circle_df = pd.DataFrame({"x": np.cos(theta), "y": np.sin(theta)})
122+
123+
# Tick marks every 10° (longer every 30°)
124+
tick_rows = []
125+
for deg in range(0, 360, 10):
126+
rad = np.radians(deg)
127+
inner_r = 0.95 if deg % 30 != 0 else 0.92
128+
tick_rows.append({"x": np.sin(rad) * inner_r, "y": np.cos(rad) * inner_r, "xend": np.sin(rad), "yend": np.cos(rad)})
129+
tick_df = pd.DataFrame(tick_rows)
130+
131+
# Reference cross lines (N-S, E-W)
132+
ref_df = pd.DataFrame(
133+
[{"x": 0, "y": -1, "xend": 0, "yend": 1, "group": 0}, {"x": -1, "y": 0, "xend": 1, "yend": 0, "group": 1}]
134+
)
135+
136+
# Cardinal direction labels
137+
cardinal_positions = [(0, 1.12, "N"), (1.12, 0, "E"), (0, -1.12, "S"), (-1.12, 0, "W")]
138+
label_df = pd.DataFrame(
139+
{
140+
"x": [c[0] for c in cardinal_positions],
141+
"y": [c[1] for c in cardinal_positions],
142+
"label": [c[2] for c in cardinal_positions],
143+
}
144+
)
145+
146+
# Mean pole annotations and confidence ellipses per feature type
147+
mean_annotations = []
148+
ellipse_rows = []
149+
for idx, ft in enumerate(["Bedding", "Joint", "Fault", "Foliation"]):
150+
mask = np.array(feature_types) == ft
151+
mx = pole_x[mask].mean()
152+
my = pole_y[mask].mean()
153+
# Circular mean for strike (handles wraparound at 0/360°)
154+
s_rad = np.radians(strikes[mask])
155+
ms = np.degrees(np.arctan2(np.sin(s_rad).mean(), np.cos(s_rad).mean())) % 360
156+
md = dips[mask].mean()
157+
# Smart label nudge: avoid overlap with cardinal labels (N/E/S/W)
158+
nudge_x, nudge_y = 0.0, -0.13
159+
for cx, cy, _ in cardinal_positions:
160+
dist = np.sqrt((mx - cx) ** 2 + (my - cy) ** 2)
161+
if dist < 0.35:
162+
# Push label away from cardinal direction
163+
dx, dy = mx - cx, my - cy
164+
norm = max(np.sqrt(dx**2 + dy**2), 0.01)
165+
nudge_x = dx / norm * 0.15
166+
nudge_y = dy / norm * 0.15 - 0.08
167+
mean_annotations.append(
168+
{"x": mx, "y": my, "label": f"{ft}\n{ms:.0f}/{md:.0f}", "nudge_x": nudge_x, "nudge_y": nudge_y}
169+
)
170+
# Confidence ellipse (1-sigma) around each cluster
171+
px_ft = pole_x[mask]
172+
py_ft = pole_y[mask]
173+
cov = np.cov(px_ft, py_ft)
174+
eigvals, eigvecs = np.linalg.eigh(cov)
175+
angle = np.arctan2(eigvecs[1, 1], eigvecs[0, 1])
176+
t = np.linspace(0, 2 * np.pi, 40)
177+
ex = np.sqrt(eigvals[1]) * np.cos(t)
178+
ey = np.sqrt(eigvals[0]) * np.sin(t)
179+
rx = mx + ex * np.cos(angle) - ey * np.sin(angle)
180+
ry = my + ex * np.sin(angle) + ey * np.cos(angle)
181+
for j in range(len(t)):
182+
ellipse_rows.append({"x": rx[j], "y": ry[j], "group": idx, "feature_type": ft})
183+
184+
ellipse_df = pd.DataFrame(ellipse_rows)
185+
186+
# Split mean annotations by nudge direction for separate geom_text layers
187+
mean_df_list = []
188+
for ann in mean_annotations:
189+
mean_df_list.append(ann)
190+
mean_df = pd.DataFrame(mean_df_list)
191+
192+
# Colorblind-safe palette (4 feature types)
193+
color_values = ["#306998", "#CC79A7", "#E69F00", "#009E73"]
194+
195+
# Tooltips for poles showing strike/dip
196+
pole_tooltips = layer_tooltips().line("@feature_type").line("Strike: @strike").line("Dip: @dip")
197+
198+
# Build individual mean label layers to handle per-label nudge offsets
199+
mean_label_layers = []
200+
for _, row in mean_df.iterrows():
201+
single_df = pd.DataFrame([{"x": row["x"], "y": row["y"], "label": row["label"]}])
202+
mean_label_layers.append(
203+
geom_text(
204+
aes(x="x", y="y", label="label"),
205+
data=single_df,
206+
size=14,
207+
color="#222222",
208+
nudge_x=row["nudge_x"],
209+
nudge_y=row["nudge_y"],
210+
fontface="italic",
211+
show_legend=False,
212+
)
213+
)
214+
215+
# Plot
216+
plot = (
217+
ggplot()
218+
# Reference cross lines
219+
+ geom_segment(
220+
aes(x="x", y="y", xend="xend", yend="yend"), data=ref_df, color="#E0E0E0", size=0.4, linetype="dashed"
221+
)
222+
# Density contours using lets-plot built-in geom_density2d (Kamb-style)
223+
+ geom_density2d(
224+
aes(x="x", y="y"),
225+
data=poles_df,
226+
color="#999999",
227+
alpha=0.5,
228+
size=0.5,
229+
bins=6,
230+
kernel="gaussian",
231+
adjust=0.8,
232+
show_legend=False,
233+
)
234+
# Great circles
235+
+ geom_path(aes(x="x", y="y", group="group", color="feature_type"), data=gc_df, size=0.35, alpha=0.25)
236+
# Confidence ellipses around each cluster
237+
+ geom_polygon(
238+
aes(x="x", y="y", group="group", fill="feature_type"), data=ellipse_df, alpha=0.10, size=0, show_legend=False
239+
)
240+
# Poles to planes with tooltips
241+
+ geom_point(aes(x="x", y="y", color="feature_type"), data=poles_df, size=5, alpha=0.85, tooltips=pole_tooltips)
242+
# Mean pole markers (diamond shape, dark)
243+
+ geom_point(aes(x="x", y="y"), data=mean_df, size=10, shape=18, color="#222222", alpha=0.95, show_legend=False)
244+
)
245+
246+
# Add per-label mean annotation layers
247+
for layer in mean_label_layers:
248+
plot = plot + layer
249+
250+
plot = (
251+
plot
252+
# Primitive circle
253+
+ geom_path(aes(x="x", y="y"), data=circle_df, color="#2A2A2A", size=1.3)
254+
# Tick marks
255+
+ geom_segment(aes(x="x", y="y", xend="xend", yend="yend"), data=tick_df, color="#2A2A2A", size=0.8)
256+
# Cardinal labels
257+
+ geom_text(aes(x="x", y="y", label="label"), data=label_df, size=20, color="#1A1A1A", fontface="bold")
258+
# Color scale
259+
+ scale_color_manual(values=color_values, name="Feature Type")
260+
+ scale_fill_manual(values=color_values)
261+
+ coord_fixed()
262+
+ scale_x_continuous(limits=[-1.3, 1.3])
263+
+ scale_y_continuous(limits=[-1.35, 1.3])
264+
+ labs(title="stereonet-equal-area · letsplot · pyplots.ai")
265+
+ theme(
266+
plot_title=element_text(size=26, hjust=0.5, face="bold", margin=[0, 0, 16, 0]),
267+
legend_title=element_text(size=18, face="bold"),
268+
legend_text=element_text(size=16),
269+
legend_position=[0.85, 0.15],
270+
legend_justification=[0.5, 0.5],
271+
legend_background=element_rect(fill="white", color="#CCCCCC", size=0.5),
272+
axis_title=element_blank(),
273+
axis_text=element_blank(),
274+
axis_ticks=element_blank(),
275+
axis_line=element_blank(),
276+
panel_grid=element_blank(),
277+
plot_background=element_rect(fill="white"),
278+
panel_background=element_rect(fill="#FAFAFA"),
279+
)
280+
+ ggsize(1200, 1200)
281+
)
282+
283+
ggsave(plot, "plot.png", path=".", scale=3)
284+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)