Skip to content

Commit ce64401

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

2 files changed

Lines changed: 611 additions & 0 deletions

File tree

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
""" pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 78/100 | Created: 2026-03-15
5+
"""
6+
7+
import numpy as np
8+
import pygal
9+
from pygal.style import Style
10+
11+
12+
# Data - Field measurements from a geological mapping campaign
13+
np.random.seed(42)
14+
15+
# Bedding planes - consistent NE strike (~040°), moderate SE dip (~30°)
16+
n_bedding = 25
17+
bedding_strike = np.random.normal(40, 8, n_bedding) % 360
18+
bedding_dip = np.clip(np.random.normal(30, 5, n_bedding), 5, 85)
19+
20+
# Fault planes - steeper, striking ESE (~120°), steep dip (~65°)
21+
n_faults = 15
22+
fault_strike = np.random.normal(120, 12, n_faults) % 360
23+
fault_dip = np.clip(np.random.normal(65, 8, n_faults), 10, 89)
24+
25+
# Joint set - sub-vertical (~80°), striking roughly N-S (~350°)
26+
n_joints = 20
27+
joint_strike = np.random.normal(350, 10, n_joints) % 360
28+
joint_dip = np.clip(np.random.normal(80, 6, n_joints), 15, 89)
29+
30+
# Equal-area (Schmidt net) lower-hemisphere projection
31+
# Pole to plane: trend = strike + 90°, plunge = 90° - dip
32+
# Projection radius: r = √2 · sin(plunge_rad / 2) where plunge = 90 - dip for poles
33+
# For great circles: r = √2 · sin((90 - plunge_of_line) / 2)
34+
# Cartesian: x = r · sin(trend_rad), y = r · cos(trend_rad)
35+
36+
37+
def clip_to_circle(x, y):
38+
"""Clip point to be within the primitive circle (r <= 1)."""
39+
r = np.sqrt(x**2 + y**2)
40+
if r > 1.0:
41+
x, y = x / r, y / r
42+
return x, y
43+
44+
45+
# Primitive circle + tick marks + N label combined as one series
46+
structural_points = []
47+
48+
# Primitive circle (outer boundary)
49+
theta_circle = np.linspace(0, 2 * np.pi, 361)
50+
for t in theta_circle:
51+
structural_points.append((round(float(np.sin(t)), 4), round(float(np.cos(t)), 4)))
52+
structural_points.append(None)
53+
54+
# Tick marks every 10° around perimeter
55+
for deg in range(0, 360, 10):
56+
rad = np.radians(deg)
57+
inner, outer = 0.96, 1.04
58+
structural_points.append((round(inner * np.sin(rad), 4), round(inner * np.cos(rad), 4)))
59+
structural_points.append((round(outer * np.sin(rad), 4), round(outer * np.cos(rad), 4)))
60+
structural_points.append(None)
61+
62+
# N label at top - letter shape using connected line segments
63+
n_cx, n_cy = 0.0, 1.14
64+
n_s = 0.035
65+
for t in np.linspace(0, 1, 10):
66+
structural_points.append((round(n_cx - n_s, 4), round(n_cy - n_s * 2.5 + t * n_s * 5, 4)))
67+
structural_points.append(None)
68+
for t in np.linspace(0, 1, 10):
69+
structural_points.append((round(n_cx - n_s + t * n_s * 2, 4), round(n_cy + n_s * 2.5 - t * n_s * 5, 4)))
70+
structural_points.append(None)
71+
for t in np.linspace(0, 1, 10):
72+
structural_points.append((round(n_cx + n_s, 4), round(n_cy - n_s * 2.5 + t * n_s * 5, 4)))
73+
74+
# Equal-area net grid lines (subtle, as spec requires)
75+
grid_points = []
76+
# Small circles at dip intervals of 30° (at 30°, 60°)
77+
for dip_grid in [30, 60]:
78+
r_grid = np.sqrt(2) * np.sin(np.radians(dip_grid / 2))
79+
theta_grid = np.linspace(0, 2 * np.pi, 181)
80+
for t in theta_grid:
81+
grid_points.append((round(float(r_grid * np.sin(t)), 4), round(float(r_grid * np.cos(t)), 4)))
82+
grid_points.append(None)
83+
84+
# Diametral lines every 30° (great circle diameters through center)
85+
for az in range(0, 180, 30):
86+
rad = np.radians(az)
87+
grid_points.append((round(-np.sin(rad), 4), round(-np.cos(rad), 4)))
88+
grid_points.append((round(np.sin(rad), 4), round(np.cos(rad), 4)))
89+
grid_points.append(None)
90+
91+
92+
# Great circle computation for all planes
93+
alphas = np.linspace(0, np.pi, 91)
94+
95+
feature_sets = [
96+
("Bedding", bedding_strike, bedding_dip),
97+
("Faults", fault_strike, fault_dip),
98+
("Joints", joint_strike, joint_dip),
99+
]
100+
101+
# Visual hierarchy: bedding is primary feature (thickest), faults secondary, joints tertiary
102+
gc_widths = {"Bedding": 3.0, "Faults": 2.2, "Joints": 1.8}
103+
pole_sizes = {"Bedding": 16, "Faults": 12, "Joints": 10}
104+
105+
gc_series = {}
106+
pole_series = {}
107+
all_pole_x = []
108+
all_pole_y = []
109+
110+
for name, strikes, dips in feature_sets:
111+
# Great circles with None separators between each plane
112+
gc_data = []
113+
for i in range(len(strikes)):
114+
d_rad = np.radians(dips[i])
115+
plunges = np.degrees(np.arcsin(np.sin(d_rad) * np.sin(alphas)))
116+
trends = strikes[i] + np.degrees(np.arctan2(np.sin(alphas) * np.cos(d_rad), np.cos(alphas)))
117+
r = np.sqrt(2) * np.sin(np.radians((90 - plunges) / 2))
118+
x = r * np.sin(np.radians(trends))
119+
y = r * np.cos(np.radians(trends))
120+
if gc_data:
121+
gc_data.append(None)
122+
for xi, yi in zip(x, y, strict=True):
123+
cx, cy = clip_to_circle(float(xi), float(yi))
124+
gc_data.append((round(cx, 4), round(cy, 4)))
125+
gc_series[name] = gc_data
126+
127+
# Poles to planes
128+
pole_trend_rad = np.radians((strikes + 90) % 360)
129+
pole_r = np.sqrt(2) * np.sin(np.radians(dips / 2))
130+
pole_x = pole_r * np.sin(pole_trend_rad)
131+
pole_y = pole_r * np.cos(pole_trend_rad)
132+
pole_series[name] = [(round(float(xi), 4), round(float(yi), 4)) for xi, yi in zip(pole_x, pole_y, strict=True)]
133+
all_pole_x.extend(pole_x)
134+
all_pole_y.extend(pole_y)
135+
136+
# Kamb density contours on all pole data
137+
all_pole_x = np.array(all_pole_x)
138+
all_pole_y = np.array(all_pole_y)
139+
140+
grid_res = 80
141+
gx = np.linspace(-1, 1, grid_res)
142+
gy = np.linspace(-1, 1, grid_res)
143+
gxx, gyy = np.meshgrid(gx, gy)
144+
dx = gx[1] - gx[0]
145+
146+
# Gaussian kernel density on projected coordinates
147+
sigma = 0.12
148+
density = np.zeros_like(gxx)
149+
for px, py in zip(all_pole_x, all_pole_y, strict=True):
150+
density += np.exp(-((gxx - px) ** 2 + (gyy - py) ** 2) / (2 * sigma**2))
151+
152+
# Mask outside the primitive circle
153+
mask = gxx**2 + gyy**2 > 1.0
154+
density[mask] = 0
155+
156+
157+
def trace_contour(density, gx, gy, level):
158+
"""Trace contour lines using marching squares with proper edge following."""
159+
ny, nx = density.shape
160+
segments = []
161+
162+
# Find all cell edge crossings and build segments
163+
for j in range(ny - 1):
164+
for i in range(nx - 1):
165+
# Four corners of cell: TL, TR, BR, BL
166+
vals = [density[j, i], density[j, i + 1], density[j + 1, i + 1], density[j + 1, i]]
167+
above = [v >= level for v in vals]
168+
case = above[0] * 8 + above[1] * 4 + above[2] * 2 + above[3] * 1
169+
170+
if case == 0 or case == 15:
171+
continue
172+
173+
# Interpolation along edges
174+
def interp_edge(v0, v1, p0, p1):
175+
if abs(v1 - v0) < 1e-12:
176+
t = 0.5
177+
else:
178+
t = (level - v0) / (v1 - v0)
179+
return (p0[0] + t * (p1[0] - p0[0]), p0[1] + t * (p1[1] - p0[1]))
180+
181+
corners = [(gx[i], gy[j]), (gx[i + 1], gy[j]), (gx[i + 1], gy[j + 1]), (gx[i], gy[j + 1])]
182+
183+
edges = {}
184+
# Top edge (0-1)
185+
if above[0] != above[1]:
186+
edges["top"] = interp_edge(vals[0], vals[1], corners[0], corners[1])
187+
# Right edge (1-2)
188+
if above[1] != above[2]:
189+
edges["right"] = interp_edge(vals[1], vals[2], corners[1], corners[2])
190+
# Bottom edge (2-3)
191+
if above[2] != above[3]:
192+
edges["bottom"] = interp_edge(vals[2], vals[3], corners[2], corners[3])
193+
# Left edge (3-0)
194+
if above[3] != above[0]:
195+
edges["left"] = interp_edge(vals[3], vals[0], corners[3], corners[0])
196+
197+
edge_keys = list(edges.keys())
198+
if len(edge_keys) == 2:
199+
p0, p1 = edges[edge_keys[0]], edges[edge_keys[1]]
200+
if p0[0] ** 2 + p0[1] ** 2 <= 1.02 and p1[0] ** 2 + p1[1] ** 2 <= 1.02:
201+
segments.append((p0, p1))
202+
elif len(edge_keys) == 4:
203+
# Saddle point - connect based on center value
204+
center = sum(vals) / 4
205+
if center >= level:
206+
pairs = [("top", "right"), ("bottom", "left")]
207+
else:
208+
pairs = [("top", "left"), ("bottom", "right")]
209+
for k0, k1 in pairs:
210+
p0, p1 = edges[k0], edges[k1]
211+
if p0[0] ** 2 + p0[1] ** 2 <= 1.02 and p1[0] ** 2 + p1[1] ** 2 <= 1.02:
212+
segments.append((p0, p1))
213+
214+
# Chain segments into polylines
215+
if not segments:
216+
return []
217+
218+
polylines = []
219+
used = [False] * len(segments)
220+
eps = dx * 1.5
221+
222+
for start_idx in range(len(segments)):
223+
if used[start_idx]:
224+
continue
225+
used[start_idx] = True
226+
chain = [segments[start_idx][0], segments[start_idx][1]]
227+
228+
# Extend forward
229+
changed = True
230+
while changed:
231+
changed = False
232+
for k in range(len(segments)):
233+
if used[k]:
234+
continue
235+
s = segments[k]
236+
d0 = (chain[-1][0] - s[0][0]) ** 2 + (chain[-1][1] - s[0][1]) ** 2
237+
d1 = (chain[-1][0] - s[1][0]) ** 2 + (chain[-1][1] - s[1][1]) ** 2
238+
if d0 < eps**2:
239+
chain.append(s[1])
240+
used[k] = True
241+
changed = True
242+
break
243+
elif d1 < eps**2:
244+
chain.append(s[0])
245+
used[k] = True
246+
changed = True
247+
break
248+
249+
# Extend backward
250+
changed = True
251+
while changed:
252+
changed = False
253+
for k in range(len(segments)):
254+
if used[k]:
255+
continue
256+
s = segments[k]
257+
d0 = (chain[0][0] - s[0][0]) ** 2 + (chain[0][1] - s[0][1]) ** 2
258+
d1 = (chain[0][0] - s[1][0]) ** 2 + (chain[0][1] - s[1][1]) ** 2
259+
if d0 < eps**2:
260+
chain.insert(0, s[1])
261+
used[k] = True
262+
changed = True
263+
break
264+
elif d1 < eps**2:
265+
chain.insert(0, s[0])
266+
used[k] = True
267+
changed = True
268+
break
269+
270+
if len(chain) >= 3:
271+
polylines.append(chain)
272+
273+
return polylines
274+
275+
276+
# Extract contour polylines at density levels
277+
contour_levels = [2.0, 4.0, 6.0, 8.0]
278+
max_density = density.max()
279+
all_contour_points = []
280+
281+
for level in contour_levels:
282+
if level >= max_density:
283+
continue
284+
polylines = trace_contour(density, gx, gy, level)
285+
for polyline in polylines:
286+
if all_contour_points:
287+
all_contour_points.append(None)
288+
for pt in polyline:
289+
cx, cy = clip_to_circle(pt[0], pt[1])
290+
all_contour_points.append((round(cx, 4), round(cy, 4)))
291+
292+
293+
# Style - colorblind-safe palette (blue, red, amber)
294+
custom_style = Style(
295+
background="white",
296+
plot_background="white",
297+
foreground="#333333",
298+
foreground_strong="#333333",
299+
foreground_subtle="#eeeeee",
300+
colors=(
301+
"#306998", # bedding great circles (blue - primary)
302+
"#D4513D", # fault great circles (red)
303+
"#DAA520", # joint great circles (amber/gold)
304+
"#306998", # bedding poles
305+
"#D4513D", # fault poles
306+
"#DAA520", # joint poles
307+
"#888888", # density contours
308+
"#bbbbbb", # grid lines
309+
"#555555", # stereonet boundary
310+
),
311+
title_font_size=56,
312+
label_font_size=1,
313+
major_label_font_size=1,
314+
legend_font_size=36,
315+
value_font_size=20,
316+
tooltip_font_size=28,
317+
opacity=0.7,
318+
opacity_hover=0.9,
319+
)
320+
321+
# Chart
322+
chart = pygal.XY(
323+
width=3600,
324+
height=3600,
325+
style=custom_style,
326+
title="stereonet-equal-area \u00b7 pygal \u00b7 pyplots.ai",
327+
show_legend=True,
328+
legend_at_bottom=True,
329+
legend_at_bottom_columns=4,
330+
show_x_labels=False,
331+
show_y_labels=False,
332+
show_x_guides=False,
333+
show_y_guides=False,
334+
xrange=(-1.25, 1.25),
335+
range=(-1.25, 1.25),
336+
dots_size=0,
337+
allow_interruptions=True,
338+
margin_top=30,
339+
margin_bottom=50,
340+
margin_left=10,
341+
margin_right=10,
342+
)
343+
344+
# Great circles (planes) - visual hierarchy via line weight
345+
chart.add(
346+
"Bedding (planes)", gc_series["Bedding"], stroke=True, show_dots=False, stroke_style={"width": gc_widths["Bedding"]}
347+
)
348+
chart.add(
349+
"Faults (planes)", gc_series["Faults"], stroke=True, show_dots=False, stroke_style={"width": gc_widths["Faults"]}
350+
)
351+
chart.add(
352+
"Joints (planes)", gc_series["Joints"], stroke=True, show_dots=False, stroke_style={"width": gc_widths["Joints"]}
353+
)
354+
355+
# Poles to planes (scatter points) - visual hierarchy via dot size
356+
chart.add("Bedding (poles)", pole_series["Bedding"], stroke=False, show_dots=True, dots_size=pole_sizes["Bedding"])
357+
chart.add("Faults (poles)", pole_series["Faults"], stroke=False, show_dots=True, dots_size=pole_sizes["Faults"])
358+
chart.add("Joints (poles)", pole_series["Joints"], stroke=False, show_dots=True, dots_size=pole_sizes["Joints"])
359+
360+
# Density contours - single combined series
361+
if all_contour_points:
362+
chart.add(
363+
"Density contours",
364+
all_contour_points,
365+
stroke=True,
366+
show_dots=False,
367+
stroke_style={"width": 1.8, "dasharray": "6,3"},
368+
)
369+
370+
# Grid lines (subtle equal-area net) - named to avoid orphan legend entry
371+
chart.add("Grid", grid_points, stroke=True, show_dots=False, stroke_style={"width": 0.8, "dasharray": "4,4"})
372+
373+
# Stereonet boundary: primitive circle + tick marks + N label
374+
chart.add("Stereonet", structural_points, stroke=True, show_dots=False, stroke_style={"width": 2.5})
375+
376+
# Save
377+
chart.render_to_png("plot.png")
378+
chart.render_to_file("plot.html")

0 commit comments

Comments
 (0)