Stratigraphic Column with Lithology Patterns — Altair

A vertical column visualization showing geological rock layers with standardized lithology patterns, formation names, ages, and thickness scales. Each layer is represented as a stacked rectangular block filled with a distinctive pattern (e.g., brick pattern for limestone, dots for sandstone, dashes for shale) following FGDC/USGS conventions. This plot is essential for communicating subsurface geology and sedimentary sequences in a compact, standardized format.

Stratigraphic Column with Lithology Patterns rendered with Altair

Python source (Altair)

""" anyplot.ai
column-stratigraphic: Stratigraphic Column with Lithology Patterns
Library: altair 6.2.1 | Python 3.13.13
Quality: 87/100 | Updated: 2026-06-17
"""

import os

import altair as alt
import pandas as pd
from PIL import Image


# Theme-adaptive chrome (see prompts/default-style-guide.md "Theme-adaptive Chrome")
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — theme-independent categorical hues. Lithologies are abstract
# rock-type categories, so positions are assigned 1→N in canonical order, but
# position 5 (matte red #AE3030) is reserved as the semantic anchor for the
# unconformity markers (a gap/loss in the geological record) — so lithologies
# use positions 1,2,3,4,6.
LITHO_GREEN = "#009E73"  # Imprint 1 — brand, first categorical series
LITHO_LAV = "#C475FD"  # Imprint 2
LITHO_BLUE = "#4467A3"  # Imprint 3
LITHO_OCHRE = "#BD8233"  # Imprint 4
LITHO_CYAN = "#2ABCCD"  # Imprint 6
UNCONFORMITY = "#AE3030"  # Imprint 5 — semantic red for missing-time / unconformity

# Data: Grand Canyon sedimentary section, 10 layers spanning Cambrian to Permian
# Dramatic thickness variation (10-35 m) to showcase the stratigraphic format
layers = pd.DataFrame(
    {
        "top": [0, 30, 45, 75, 85, 110, 120, 155, 170, 180],
        "bottom": [30, 45, 75, 85, 110, 120, 155, 170, 180, 200],
        "lithology": [
            "Sandstone",
            "Shale",
            "Limestone",
            "Siltstone",
            "Sandstone",
            "Conglomerate",
            "Shale",
            "Limestone",
            "Siltstone",
            "Sandstone",
        ],
        "formation": [
            "Cedar Mesa Fm",
            "Organ Rock Fm",
            "White Rim Fm",
            "De Chelly Fm",
            "Coconino Fm",
            "Hermit Fm",
            "Supai Group",
            "Redwall Fm",
            "Temple Butte Fm",
            "Muav Fm",
        ],
        "age": [
            "Permian",
            "Permian",
            "Permian",
            "Permian",
            "Permian",
            "Permian",
            "Pennsylvanian",
            "Mississippian",
            "Devonian",
            "Cambrian",
        ],
    }
)

layers["thickness"] = layers["bottom"] - layers["top"]
layers["mid_depth"] = (layers["top"] + layers["bottom"]) / 2

# Lithology → Imprint palette (5 distinct rock types)
lithology_order = ["Sandstone", "Shale", "Limestone", "Siltstone", "Conglomerate"]
lithology_colors = {
    "Sandstone": LITHO_GREEN,
    "Shale": LITHO_LAV,
    "Limestone": LITHO_BLUE,
    "Siltstone": LITHO_OCHRE,
    "Conglomerate": LITHO_CYAN,
}

# Lithology pattern glyphs approximating FGDC/USGS texture conventions. Single
# motif per rock type, tiled across the full column width (rows × columns) so each
# layer reads as a true fill texture rather than a centered band.
pattern_symbols = {"Sandstone": "·", "Shale": "—", "Limestone": "▤", "Siltstone": "╌", "Conglomerate": "◯"}

# Column horizontal span (data units within x_domain) — the rectangles and the
# tiled texture share these bounds.
COL_L, COL_R = 3.2, 10.7

# Texture grid: tile each layer with rows (depth) × columns (across the width) of
# the lithology glyph, so the pattern fills the whole rectangle.
n_cols = 11
col_x = [COL_L + 0.35 + i * ((COL_R - COL_L - 0.7) / (n_cols - 1)) for i in range(n_cols)]
pattern_rows = []
for _, row in layers.iterrows():
    layer_height = row["bottom"] - row["top"]
    n_rows = max(2, int(layer_height / 6))
    spacing = layer_height / (n_rows + 1)
    sym = pattern_symbols[row["lithology"]]
    for i in range(n_rows):
        depth = row["top"] + spacing * (i + 1)
        for xp in col_x:
            pattern_rows.append({"depth": depth, "pattern": sym, "x_mid": xp})
pattern_df = pd.DataFrame(pattern_rows)

# Age groups — one bracket per contiguous geological period
age_groups = []
current_age = None
for _, row in layers.iterrows():
    if row["age"] != current_age:
        current_age = row["age"]
        group_rows = layers[layers["age"] == current_age]
        age_groups.append(
            {
                "age": current_age,
                "top": group_rows["top"].min(),
                "bottom": group_rows["bottom"].max(),
                "mid_depth": (group_rows["top"].min() + group_rows["bottom"].max()) / 2,
            }
        )
age_df = pd.DataFrame(age_groups)

# Unconformities at major age boundaries (missing time / erosional gaps)
unconformity_df = pd.DataFrame({"depth": [120, 170], "label": ["Unconformity", "Unconformity"]})

# Shared horizontal layout — generous x domain spreads side labels into clear lanes
x_domain = [0, 20]

# Layer rectangles — the stratigraphic column itself (x: 3.0 to 10.5)
rects = (
    alt.Chart(layers)
    .mark_rect(stroke=INK, strokeWidth=1.2)
    .encode(
        y=alt.Y(
            "top:Q",
            title="Depth (m)",
            scale=alt.Scale(domain=[0, 200], reverse=True),
            axis=alt.Axis(
                labelFontSize=11,
                titleFontSize=13,
                tickCount=10,
                gridColor=INK,
                gridOpacity=0.15,
                domainColor=INK_SOFT,
                domainWidth=1.2,
                tickColor=INK_SOFT,
                labelColor=INK_SOFT,
                titleColor=INK,
            ),
        ),
        y2="bottom:Q",
        x=alt.X("x:Q", scale=alt.Scale(domain=x_domain), axis=None),
        x2="x2:Q",
        color=alt.Color(
            "lithology:N",
            title="Lithology",
            scale=alt.Scale(domain=lithology_order, range=[lithology_colors[k] for k in lithology_order]),
            legend=alt.Legend(
                titleFontSize=12,
                labelFontSize=11,
                symbolSize=320,
                orient="bottom",
                titlePadding=8,
                direction="horizontal",
                labelLimit=200,
                symbolStrokeWidth=1.0,
                symbolStrokeColor=INK_SOFT,
                padding=10,
                columns=5,
            ),
        ),
        tooltip=[
            alt.Tooltip("formation:N", title="Formation"),
            alt.Tooltip("lithology:N", title="Lithology"),
            alt.Tooltip("age:N", title="Age"),
            alt.Tooltip("top:Q", title="Top (m)"),
            alt.Tooltip("bottom:Q", title="Bottom (m)"),
            alt.Tooltip("thickness:Q", title="Thickness (m)"),
        ],
    )
    .transform_calculate(x=f"{COL_L}", x2=f"{COL_R}")
)

# Tiled pattern texture overlay — dark ink reads on every Imprint fill in both themes
pattern_text = (
    alt.Chart(pattern_df)
    .mark_text(fontSize=13, color="#1A1A17", opacity=0.68, fontWeight="bold")
    .encode(y=alt.Y("depth:Q"), x=alt.X("x_mid:Q", scale=alt.Scale(domain=x_domain)), text="pattern:N")
)

# Formation name labels — to the right of the column
formation_labels = (
    alt.Chart(layers)
    .mark_text(fontSize=12, fontWeight="bold", align="left", color=INK)
    .encode(y=alt.Y("mid_depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="formation:N")
    .transform_calculate(x_pos=f"{COL_R + 0.4}")
)

# Thickness annotations — far-right lane, tertiary text
thickness_labels = (
    alt.Chart(layers)
    .mark_text(fontSize=11, align="right", color=INK_MUTED, fontStyle="italic")
    .encode(y=alt.Y("mid_depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="label:N")
    .transform_calculate(x_pos="19.6", label="datum.thickness + ' m'")
)

# Age bracket vertical lines — far-left, just clear of the depth-axis numerals
age_brackets_v = (
    alt.Chart(age_df)
    .mark_rule(strokeWidth=2.0, color=INK_SOFT)
    .encode(y=alt.Y("top:Q"), y2="bottom:Q", x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)))
    .transform_calculate(x_pos="0.35")
)

# Age period labels — left-aligned immediately right of the bracket so the long
# italic period names sit in their own lane between the bracket and the column,
# never reaching back into the depth-axis tick numbers (140/160/180).
age_labels = (
    alt.Chart(age_df)
    .mark_text(fontSize=10, fontStyle="italic", fontWeight="bold", align="left", color=INK)
    .encode(y=alt.Y("mid_depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="age:N")
    .transform_calculate(x_pos="0.9")
)

# Age bracket horizontal ticks (top and bottom of each age group)
bracket_ticks_data = []
for _, row in age_df.iterrows():
    bracket_ticks_data.append({"depth": row["top"]})
    bracket_ticks_data.append({"depth": row["bottom"]})
bracket_ticks_df = pd.DataFrame(bracket_ticks_data)

age_bracket_ticks = (
    alt.Chart(bracket_ticks_df)
    .mark_rule(strokeWidth=2.0, color=INK_SOFT)
    .encode(y=alt.Y("depth:Q"), x=alt.X("x1:Q", scale=alt.Scale(domain=x_domain)), x2="x2:Q")
    .transform_calculate(x1="0.35", x2="0.7")
)

# Unconformity markers — red dashed lines crossing the column at age boundaries
unconformity_rules = (
    alt.Chart(unconformity_df)
    .mark_rule(strokeWidth=3.0, color=UNCONFORMITY, strokeDash=[8, 4])
    .encode(y=alt.Y("depth:Q"), x=alt.X("x1:Q", scale=alt.Scale(domain=x_domain)), x2="x2:Q")
    .transform_calculate(x1=f"{COL_L}", x2=f"{COL_R}")
)

# Unconformity labels — pinned just inside the column's left edge, lifted clear of
# the layer texture above the dashed rule
unconformity_labels_chart = (
    alt.Chart(unconformity_df)
    .mark_text(fontSize=12, color=UNCONFORMITY, fontWeight="bold", align="left", dy=-11)
    .encode(y=alt.Y("depth:Q"), x=alt.X("x_pos:Q", scale=alt.Scale(domain=x_domain)), text="label:N")
    .transform_calculate(x_pos=f"{COL_L + 0.15}")
)

# Compose all layers
title = "column-stratigraphic · python · altair · anyplot.ai"
chart = (
    (
        rects
        + pattern_text
        + formation_labels
        + thickness_labels
        + age_labels
        + age_brackets_v
        + age_bracket_ticks
        + unconformity_rules
        + unconformity_labels_chart
    )
    .properties(
        width=700,
        height=280,
        title=alt.Title(
            title,
            fontSize=16,
            anchor="middle",
            offset=12,
            color=INK,
            subtitle="Grand Canyon Sedimentary Section — Cambrian to Permian",
            subtitleFontSize=12,
            subtitleColor=INK_SOFT,
            subtitlePadding=6,
        ),
    )
    .configure_view(strokeWidth=0, fill=PAGE_BG)
    .configure(background=PAGE_BG)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
)

# Save PNG, then PAD-only up to the exact landscape target (never crop — see altair.md)
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Part of Stratigraphic Column with Lithology Patterns on anyplot.ai.

Other implementations