|
| 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