Skip to content

Commit f57ad96

Browse files
feat(altair): implement column-stratigraphic (#4912)
## Implementation: `column-stratigraphic` - altair Implements the **altair** version of `column-stratigraphic`. **File:** `plots/column-stratigraphic/implementations/altair.py` **Parent Issue:** #4573 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23121198787)* --------- 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 ce64401 commit f57ad96

2 files changed

Lines changed: 506 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
""" pyplots.ai
2+
column-stratigraphic: Stratigraphic Column with Lithology Patterns
3+
Library: altair 6.0.0 | Python 3.14.3
4+
Quality: 87/100 | Created: 2026-03-15
5+
"""
6+
7+
import altair as alt
8+
import pandas as pd
9+
10+
11+
# Data: Grand Canyon sedimentary section with 10 layers spanning Cambrian to Permian
12+
# Dramatic thickness variation (10-35 m) to showcase the format
13+
layers = pd.DataFrame(
14+
{
15+
"top": [0, 30, 45, 75, 85, 110, 120, 155, 170, 180],
16+
"bottom": [30, 45, 75, 85, 110, 120, 155, 170, 180, 200],
17+
"lithology": [
18+
"Sandstone",
19+
"Shale",
20+
"Limestone",
21+
"Siltstone",
22+
"Sandstone",
23+
"Conglomerate",
24+
"Shale",
25+
"Limestone",
26+
"Siltstone",
27+
"Sandstone",
28+
],
29+
"formation": [
30+
"Cedar Mesa Fm",
31+
"Organ Rock Fm",
32+
"White Rim Fm",
33+
"De Chelly Fm",
34+
"Coconino Fm",
35+
"Hermit Fm",
36+
"Supai Group",
37+
"Redwall Fm",
38+
"Temple Butte Fm",
39+
"Muav Fm",
40+
],
41+
"age": [
42+
"Permian",
43+
"Permian",
44+
"Permian",
45+
"Permian",
46+
"Permian",
47+
"Permian",
48+
"Pennsylvanian",
49+
"Mississippian",
50+
"Devonian",
51+
"Cambrian",
52+
],
53+
}
54+
)
55+
56+
layers["thickness"] = layers["bottom"] - layers["top"]
57+
layers["mid_depth"] = (layers["top"] + layers["bottom"]) / 2
58+
59+
# Colorblind-safe geological palette
60+
lithology_colors = {
61+
"Sandstone": "#E8C547",
62+
"Shale": "#7B8894",
63+
"Limestone": "#4A90D9",
64+
"Siltstone": "#9B6DBF",
65+
"Conglomerate": "#D4772C",
66+
}
67+
68+
lithology_order = ["Sandstone", "Shale", "Limestone", "Siltstone", "Conglomerate"]
69+
70+
# Lithology pattern symbols — wider for better visibility
71+
pattern_symbols = {
72+
"Sandstone": "· · · · · · · ·",
73+
"Shale": "— — — — — —",
74+
"Limestone": "▤ ▤ ▤ ▤ ▤ ▤",
75+
"Siltstone": "╌ ╌ ╌ ╌ ╌ ╌",
76+
"Conglomerate": "◯ ◯ ◯ ◯ ◯ ◯",
77+
}
78+
79+
# Create multiple pattern rows per layer for denser texture fill
80+
pattern_rows = []
81+
for _, row in layers.iterrows():
82+
layer_height = row["bottom"] - row["top"]
83+
n_rows = max(2, int(layer_height / 6))
84+
spacing = layer_height / (n_rows + 1)
85+
for i in range(n_rows):
86+
depth = row["top"] + spacing * (i + 1)
87+
pattern_rows.append({"depth": depth, "pattern": pattern_symbols[row["lithology"]]})
88+
pattern_df = pd.DataFrame(pattern_rows)
89+
90+
# Identify unique age groups
91+
age_groups = []
92+
current_age = None
93+
for _, row in layers.iterrows():
94+
if row["age"] != current_age:
95+
current_age = row["age"]
96+
group_rows = layers[layers["age"] == current_age]
97+
age_groups.append(
98+
{
99+
"age": current_age,
100+
"top": group_rows["top"].min(),
101+
"bottom": group_rows["bottom"].max(),
102+
"mid_depth": (group_rows["top"].min() + group_rows["bottom"].max()) / 2,
103+
}
104+
)
105+
age_df = pd.DataFrame(age_groups)
106+
107+
# Unconformity markers at major geological transitions
108+
unconformity_df = pd.DataFrame({"depth": [120, 170], "label": ["Unconformity", "Great Unconformity"]})
109+
110+
# Shared scales — wider x domain for better canvas utilization
111+
x_domain = [0, 18]
112+
x_axis_none = alt.Axis(labels=False, ticks=False, domain=False, grid=False)
113+
114+
# Layer rectangles — wider column (x: 2.5 to 10.5)
115+
rects = (
116+
alt.Chart(layers)
117+
.mark_rect(stroke="#2C3E50", strokeWidth=1.5)
118+
.encode(
119+
y=alt.Y(
120+
"top:Q",
121+
title="Depth (m)",
122+
scale=alt.Scale(domain=[0, 200], reverse=True),
123+
axis=alt.Axis(
124+
labelFontSize=18,
125+
titleFontSize=22,
126+
tickCount=10,
127+
gridColor="#D5D8DC",
128+
gridDash=[2, 4],
129+
domainColor="#2C3E50",
130+
domainWidth=1.5,
131+
),
132+
),
133+
y2="bottom:Q",
134+
x=alt.X("x:Q", scale=alt.Scale(domain=x_domain), axis=None),
135+
x2="x2:Q",
136+
color=alt.Color(
137+
"lithology:N",
138+
title="Lithology",
139+
scale=alt.Scale(domain=lithology_order, range=[lithology_colors[k] for k in lithology_order]),
140+
legend=alt.Legend(
141+
titleFontSize=20,
142+
labelFontSize=18,
143+
symbolSize=600,
144+
orient="bottom",
145+
titlePadding=12,
146+
direction="horizontal",
147+
labelLimit=200,
148+
symbolStrokeWidth=1.5,
149+
symbolStrokeColor="#2C3E50",
150+
padding=20,
151+
columns=5,
152+
),
153+
),
154+
tooltip=[
155+
alt.Tooltip("formation:N", title="Formation"),
156+
alt.Tooltip("lithology:N", title="Lithology"),
157+
alt.Tooltip("age:N", title="Age"),
158+
alt.Tooltip("top:Q", title="Top (m)"),
159+
alt.Tooltip("bottom:Q", title="Bottom (m)"),
160+
alt.Tooltip("thickness:Q", title="Thickness (m)"),
161+
],
162+
)
163+
.transform_calculate(x="2.5", x2="10.5")
164+
)
165+
166+
# Dense pattern texture overlay — bolder and more prominent
167+
pattern_text = (
168+
alt.Chart(pattern_df)
169+
.mark_text(fontSize=17, color="#2C3E50", opacity=0.65, fontWeight="bold")
170+
.encode(y=alt.Y("depth:Q"), x=alt.X("x_mid:Q", scale=alt.Scale(domain=x_domain)), text="pattern:N")
171+
.transform_calculate(x_mid="6.5")
172+
)
173+
174+
# Formation name labels to the right
175+
formation_labels = (
176+
alt.Chart(layers)
177+
.mark_text(fontSize=17, fontWeight="bold", align="left", color="#1B2631")
178+
.encode(y=alt.Y("mid_depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="formation:N")
179+
.transform_calculate(x_pos="11.0")
180+
)
181+
182+
# Thickness annotations — larger font for readability
183+
thickness_labels = (
184+
alt.Chart(layers)
185+
.mark_text(fontSize=16, align="right", color="#7F8C8D", fontStyle="italic")
186+
.encode(y=alt.Y("mid_depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="label:N")
187+
.transform_calculate(x_pos="17.8", label="datum.thickness + ' m'")
188+
)
189+
190+
# Age period labels to the left
191+
age_labels = (
192+
alt.Chart(age_df)
193+
.mark_text(fontSize=18, fontStyle="italic", fontWeight="bold", align="right", color="#2C3E50")
194+
.encode(y=alt.Y("mid_depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="age:N")
195+
.transform_calculate(x_pos="1.8")
196+
)
197+
198+
# Age bracket vertical lines
199+
age_brackets_v = (
200+
alt.Chart(age_df)
201+
.mark_rule(strokeWidth=2.5, color="#2C3E50")
202+
.encode(y=alt.Y("top:Q"), y2="bottom:Q", x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)))
203+
.transform_calculate(x_pos="2.2")
204+
)
205+
206+
# Age bracket horizontal ticks (top and bottom of each age group)
207+
bracket_ticks_data = []
208+
for _, row in age_df.iterrows():
209+
bracket_ticks_data.append({"depth": row["top"]})
210+
bracket_ticks_data.append({"depth": row["bottom"]})
211+
bracket_ticks_df = pd.DataFrame(bracket_ticks_data)
212+
213+
age_bracket_ticks = (
214+
alt.Chart(bracket_ticks_df)
215+
.mark_rule(strokeWidth=2.5, color="#2C3E50")
216+
.encode(y=alt.Y("depth:Q"), x=alt.X("x1:Q", scale=alt.Scale(domain=x_domain)), x2="x2:Q")
217+
.transform_calculate(x1="2.2", x2="2.5")
218+
)
219+
220+
# Unconformity markers — red dashed lines at key geological transitions
221+
unconformity_rules = (
222+
alt.Chart(unconformity_df)
223+
.mark_rule(strokeWidth=4, color="#C0392B", strokeDash=[8, 4])
224+
.encode(y=alt.Y("depth:Q"), x=alt.X("x1:Q", scale=alt.Scale(domain=x_domain)), x2="x2:Q")
225+
.transform_calculate(x1="2.5", x2="10.5")
226+
)
227+
228+
# Unconformity labels — positioned to the right of column to avoid overlapping patterns
229+
unconformity_labels_chart = (
230+
alt.Chart(unconformity_df)
231+
.mark_text(fontSize=13, color="#C0392B", fontWeight="bold", align="left", dy=-10)
232+
.encode(y=alt.Y("depth:Q"), x=alt.X("x_mid:Q", scale=alt.Scale(domain=x_domain)), text="label:N")
233+
.transform_calculate(x_mid="11.0")
234+
)
235+
236+
# Combine all layers
237+
chart = (
238+
(
239+
rects
240+
+ pattern_text
241+
+ formation_labels
242+
+ thickness_labels
243+
+ age_labels
244+
+ age_brackets_v
245+
+ age_bracket_ticks
246+
+ unconformity_rules
247+
+ unconformity_labels_chart
248+
)
249+
.properties(
250+
width=1400,
251+
height=900,
252+
title=alt.Title(
253+
"column-stratigraphic · altair · pyplots.ai",
254+
fontSize=28,
255+
anchor="middle",
256+
offset=20,
257+
color="#1B2631",
258+
subtitle="Grand Canyon Sedimentary Section — Cambrian to Permian",
259+
subtitleFontSize=18,
260+
subtitleColor="#566573",
261+
subtitlePadding=8,
262+
),
263+
)
264+
.configure_view(strokeWidth=0)
265+
.configure(background="#FAFBFC")
266+
)
267+
268+
# Save
269+
chart.save("plot.png", scale_factor=3.0)
270+
chart.save("plot.html")

0 commit comments

Comments
 (0)