Skip to content

Commit a8ecc0e

Browse files
fix(pygal): address review feedback for stereonet-equal-area
Attempt 2/3 - fixes based on AI review
1 parent b67b038 commit a8ecc0e

1 file changed

Lines changed: 145 additions & 41 deletions

File tree

  • plots/stereonet-equal-area/implementations

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

Lines changed: 145 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
""" pyplots.ai
1+
"""pyplots.ai
22
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
33
Library: pygal 3.1.0 | Python 3.14.3
44
Quality: 68/100 | Created: 2026-03-15
@@ -29,36 +29,56 @@
2929

3030
# Equal-area (Schmidt net) lower-hemisphere projection
3131
# Pole to plane: trend = strike + 90°, plunge = 90° - dip
32-
# Projection radius: r = √2 · sin(dip_rad / 2)
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)
3334
# Cartesian: x = r · sin(trend_rad), y = r · cos(trend_rad)
3435

35-
# Primitive circle (outer boundary representing the horizontal plane)
36+
# Primitive circle (outer boundary)
3637
theta_circle = np.linspace(0, 2 * np.pi, 361)
3738
circle_points = [(round(float(np.sin(t)), 4), round(float(np.cos(t)), 4)) for t in theta_circle]
3839

3940
# Tick marks every 10° around perimeter
4041
tick_points = []
4142
for deg in range(0, 360, 10):
4243
rad = np.radians(deg)
43-
inner, outer = 0.97, 1.03
44+
inner, outer = 0.96, 1.04
4445
tick_points.append((round(inner * np.sin(rad), 4), round(inner * np.cos(rad), 4)))
4546
tick_points.append((round(outer * np.sin(rad), 4), round(outer * np.cos(rad), 4)))
4647
tick_points.append(None)
4748

48-
# Compass markers (N, E, S, W as small cross marks beyond the circle)
49-
compass_points = []
50-
for _bearing, offset_x, offset_y in [(0, 0, 1.12), (90, 1.12, 0), (180, 0, -1.12), (270, -1.12, 0)]:
51-
compass_points.append((round(offset_x - 0.02, 4), round(offset_y, 4)))
52-
compass_points.append((round(offset_x + 0.02, 4), round(offset_y, 4)))
53-
compass_points.append(None)
54-
compass_points.append((round(offset_x, 4), round(offset_y - 0.02, 4)))
55-
compass_points.append((round(offset_x, 4), round(offset_y + 0.02, 4)))
56-
compass_points.append(None)
49+
# Equal-area net grid lines (subtle, as spec requires)
50+
# Small circles at dip intervals of 30° (at 30°, 60°)
51+
grid_points = []
52+
for dip_grid in [30, 60]:
53+
r_grid = np.sqrt(2) * np.sin(np.radians(dip_grid / 2))
54+
theta_grid = np.linspace(0, 2 * np.pi, 181)
55+
for t in theta_grid:
56+
grid_points.append((round(float(r_grid * np.sin(t)), 4), round(float(r_grid * np.cos(t)), 4)))
57+
grid_points.append(None)
58+
59+
# Diametral lines every 30° (great circle diameters through center)
60+
for az in range(0, 180, 30):
61+
rad = np.radians(az)
62+
grid_points.append((round(-np.sin(rad), 4), round(-np.cos(rad), 4)))
63+
grid_points.append((round(np.sin(rad), 4), round(np.cos(rad), 4)))
64+
grid_points.append(None)
65+
66+
# Compass direction labels as dot patterns
67+
# N label at top - letter shape using connected line segments
68+
n_label = []
69+
n_cx, n_cy = 0.0, 1.15
70+
n_s = 0.03
71+
for t in np.linspace(0, 1, 10):
72+
n_label.append((round(n_cx - n_s, 4), round(n_cy - n_s * 2 + t * n_s * 4, 4)))
73+
n_label.append(None)
74+
for t in np.linspace(0, 1, 10):
75+
n_label.append((round(n_cx - n_s + t * n_s * 2, 4), round(n_cy + n_s * 2 - t * n_s * 4, 4)))
76+
n_label.append(None)
77+
for t in np.linspace(0, 1, 10):
78+
n_label.append((round(n_cx + n_s, 4), round(n_cy - n_s * 2 + t * n_s * 4, 4)))
79+
5780

5881
# Great circle computation for all planes
59-
# For rake angle α (0 to π), a line in the plane has:
60-
# plunge = arcsin(sin(dip) · sin(α))
61-
# trend = strike + atan2(sin(α) · cos(dip), cos(α))
6282
alphas = np.linspace(0, np.pi, 91)
6383

6484
feature_sets = [
@@ -69,6 +89,8 @@
6989

7090
gc_series = {}
7191
pole_series = {}
92+
all_pole_x = []
93+
all_pole_y = []
7294

7395
for name, strikes, dips in feature_sets:
7496
# Great circles with None separators between each plane
@@ -91,32 +113,107 @@
91113
pole_x = pole_r * np.sin(pole_trend_rad)
92114
pole_y = pole_r * np.cos(pole_trend_rad)
93115
pole_series[name] = [(round(float(xi), 4), round(float(yi), 4)) for xi, yi in zip(pole_x, pole_y, strict=True)]
116+
all_pole_x.extend(pole_x)
117+
all_pole_y.extend(pole_y)
118+
119+
# Kamb density contours on all pole data
120+
# Grid-based density estimation in projected space
121+
all_pole_x = np.array(all_pole_x)
122+
all_pole_y = np.array(all_pole_y)
123+
124+
grid_res = 60
125+
gx = np.linspace(-1, 1, grid_res)
126+
gy = np.linspace(-1, 1, grid_res)
127+
gxx, gyy = np.meshgrid(gx, gy)
128+
129+
# Gaussian kernel density on projected coordinates
130+
sigma = 0.12
131+
density = np.zeros_like(gxx)
132+
for px, py in zip(all_pole_x, all_pole_y, strict=True):
133+
density += np.exp(-((gxx - px) ** 2 + (gyy - py) ** 2) / (2 * sigma**2))
134+
135+
# Mask outside the primitive circle
136+
mask = gxx**2 + gyy**2 > 1.0
137+
density[mask] = 0
94138

95-
# Style
139+
# Extract contour lines using a simple marching approach
140+
# Generate contour polylines at specific density levels
141+
contour_levels = [2.0, 4.0, 6.0, 8.0]
142+
max_density = density.max()
143+
contour_series = []
144+
145+
for level in contour_levels:
146+
if level >= max_density:
147+
continue
148+
level_points = []
149+
# Scan horizontal edges for contour crossings
150+
for i in range(grid_res - 1):
151+
for j in range(grid_res):
152+
v0, v1 = density[j, i], density[j, i + 1]
153+
if (v0 - level) * (v1 - level) < 0 and v0 != v1:
154+
frac = (level - v0) / (v1 - v0)
155+
cx = gx[i] + frac * (gx[i + 1] - gx[i])
156+
cy = gy[j]
157+
if cx**2 + cy**2 <= 1.0:
158+
level_points.append((cx, cy))
159+
# Scan vertical edges
160+
for i in range(grid_res):
161+
for j in range(grid_res - 1):
162+
v0, v1 = density[j, i], density[j + 1, i]
163+
if (v0 - level) * (v1 - level) < 0 and v0 != v1:
164+
frac = (level - v0) / (v1 - v0)
165+
cx = gx[i]
166+
cy = gy[j] + frac * (gy[j + 1] - gy[j])
167+
if cx**2 + cy**2 <= 1.0:
168+
level_points.append((cx, cy))
169+
170+
if level_points:
171+
# Sort points by angle from each cluster centroid to form connected contours
172+
pts = np.array(level_points)
173+
# Order points by angle for smoother contour rendering
174+
angles = np.arctan2(pts[:, 0], pts[:, 1])
175+
order = np.argsort(angles)
176+
sorted_pts = pts[order]
177+
# Split into segments when consecutive points are far apart
178+
segment = [(round(float(sorted_pts[0, 0]), 4), round(float(sorted_pts[0, 1]), 4))]
179+
for k in range(1, len(sorted_pts)):
180+
dist = np.sqrt(
181+
(sorted_pts[k, 0] - sorted_pts[k - 1, 0]) ** 2 + (sorted_pts[k, 1] - sorted_pts[k - 1, 1]) ** 2
182+
)
183+
if dist > 0.15:
184+
segment.append(None)
185+
segment.append((round(float(sorted_pts[k, 0]), 4), round(float(sorted_pts[k, 1]), 4)))
186+
contour_series.append(segment)
187+
188+
189+
# Style - colorblind-safe palette (blue, orange, amber instead of green)
96190
custom_style = Style(
97191
background="white",
98192
plot_background="white",
99193
foreground="#333333",
100194
foreground_strong="#333333",
101195
foreground_subtle="#eeeeee",
102196
colors=(
103-
"#aaaaaa", # primitive circle
104-
"#aaaaaa", # tick marks
105-
"#aaaaaa", # compass marks
197+
"#d0d0d0", # grid lines
198+
"#777777", # structural elements (circle + ticks + N)
106199
"#306998", # bedding great circles
107200
"#D4513D", # fault great circles
108-
"#2CA02C", # joint great circles
201+
"#DAA520", # joint great circles (amber/gold, colorblind-safe)
109202
"#306998", # bedding poles
110203
"#D4513D", # fault poles
111-
"#2CA02C", # joint poles
204+
"#DAA520", # joint poles (amber/gold)
205+
"#666666", # contour level 1
206+
"#555555", # contour level 2
207+
"#444444", # contour level 3
208+
"#333333", # contour level 4
112209
),
113210
title_font_size=56,
114211
label_font_size=1,
115212
major_label_font_size=1,
116213
legend_font_size=36,
117214
value_font_size=20,
118215
tooltip_font_size=28,
119-
opacity=0.5,
216+
opacity=0.6,
120217
opacity_hover=0.8,
121218
)
122219

@@ -128,35 +225,42 @@
128225
title="stereonet-equal-area · pygal · pyplots.ai",
129226
show_legend=True,
130227
legend_at_bottom=True,
131-
legend_at_bottom_columns=3,
228+
legend_at_bottom_columns=4,
132229
show_x_labels=False,
133230
show_y_labels=False,
134231
show_x_guides=False,
135232
show_y_guides=False,
136-
xrange=(-1.35, 1.35),
137-
range=(-1.35, 1.35),
233+
xrange=(-1.25, 1.25),
234+
range=(-1.25, 1.25),
138235
dots_size=0,
139236
allow_interruptions=True,
140-
margin_top=40,
141-
margin_bottom=60,
142-
margin_left=20,
143-
margin_right=20,
237+
margin_top=30,
238+
margin_bottom=50,
239+
margin_left=10,
240+
margin_right=10,
144241
)
145242

146-
# Structural elements (circle, ticks, compass)
147-
chart.add("", circle_points, stroke=True, show_dots=False, stroke_style={"width": 2.5})
148-
chart.add("", tick_points, stroke=True, show_dots=False, stroke_style={"width": 1.5})
149-
chart.add("", compass_points, stroke=True, show_dots=False, stroke_style={"width": 2})
243+
# Grid lines (subtle equal-area net)
244+
chart.add("", grid_points, stroke=True, show_dots=False, stroke_style={"width": 0.8, "dasharray": "4,4"})
245+
246+
# Combine structural elements: circle + ticks + N label
247+
structural = circle_points + [None] + tick_points + [None] + n_label
248+
chart.add("", structural, stroke=True, show_dots=False, stroke_style={"width": 2.5})
150249

151-
# Great circles (planes)
152-
chart.add("Bedding (planes)", gc_series["Bedding"], stroke=True, show_dots=False, stroke_style={"width": 1.5})
153-
chart.add("Faults (planes)", gc_series["Faults"], stroke=True, show_dots=False, stroke_style={"width": 1.5})
154-
chart.add("Joints (planes)", gc_series["Joints"], stroke=True, show_dots=False, stroke_style={"width": 1.5})
250+
# Great circles (planes) - thicker lines for visibility
251+
chart.add("Bedding (planes)", gc_series["Bedding"], stroke=True, show_dots=False, stroke_style={"width": 2.5})
252+
chart.add("Faults (planes)", gc_series["Faults"], stroke=True, show_dots=False, stroke_style={"width": 2.5})
253+
chart.add("Joints (planes)", gc_series["Joints"], stroke=True, show_dots=False, stroke_style={"width": 2.5})
155254

156255
# Poles to planes (scatter points)
157-
chart.add("Bedding (poles)", pole_series["Bedding"], stroke=False, show_dots=True, dots_size=12)
158-
chart.add("Fault (poles)", pole_series["Faults"], stroke=False, show_dots=True, dots_size=12)
159-
chart.add("Joint (poles)", pole_series["Joints"], stroke=False, show_dots=True, dots_size=12)
256+
chart.add("Bedding (poles)", pole_series["Bedding"], stroke=False, show_dots=True, dots_size=14)
257+
chart.add("Faults (poles)", pole_series["Faults"], stroke=False, show_dots=True, dots_size=14)
258+
chart.add("Joints (poles)", pole_series["Joints"], stroke=False, show_dots=True, dots_size=14)
259+
260+
# Density contours - only first gets legend label
261+
for i, contour_data in enumerate(contour_series):
262+
label = "Density contours" if i == 0 else ""
263+
chart.add(label, contour_data, stroke=True, show_dots=False, stroke_style={"width": 1.5, "dasharray": "6,3"})
160264

161265
# Save
162266
chart.render_to_png("plot.png")

0 commit comments

Comments
 (0)