Stratigraphic Column with Lithology Patterns — Pygal

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 Pygal

Python source (Pygal)

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

import os
import re
import sys


# This file is named pygal.py, so `import pygal` would resolve to it; drop the
# script's own directory from sys.path so the installed pygal package wins.
sys.path = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]

import cairosvg
import pygal
from pygal.style import Style


# 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"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Data: synthetic sedimentary section, depth increasing downward (surface = 0 m)
layers = [
    {"top": 0, "bottom": 12, "lithology": "sandstone", "formation": "Red Mesa Fm", "age": "Eocene"},
    {"top": 12, "bottom": 25, "lithology": "shale", "formation": "Grey Basin Fm", "age": "Paleocene"},
    {"top": 25, "bottom": 38, "lithology": "limestone", "formation": "Chalk Bluff Fm", "age": "Paleocene"},
    {"top": 38, "bottom": 50, "lithology": "siltstone", "formation": "Iron Creek Mbr", "age": "L. Cretaceous"},
    {"top": 50, "bottom": 68, "lithology": "sandstone", "formation": "Canyon Wall Fm", "age": "L. Cretaceous"},
    {"top": 68, "bottom": 82, "lithology": "shale", "formation": "Dark Hollow Fm", "age": "E. Cretaceous"},
    {"top": 82, "bottom": 90, "lithology": "conglomerate", "formation": "Boulder Bed Mbr", "age": "E. Cretaceous"},
    {"top": 90, "bottom": 108, "lithology": "limestone", "formation": "Shell Bank Fm", "age": "Jurassic"},
    {"top": 108, "bottom": 118, "lithology": "mudstone", "formation": "Quiet Water Fm", "age": "Jurassic"},
    {"top": 118, "bottom": 135, "lithology": "dolomite", "formation": "Crystal Ridge Fm", "age": "Triassic"},
]
total_depth = layers[-1]["bottom"]

# Lithology -> Imprint palette (first lithology = brand green #009E73). Patterns,
# not hue, carry the geological meaning; distinct hues keep the 7 rock types apart.
LITH_COLOR = {
    "sandstone": "#009E73",
    "shale": "#C475FD",
    "limestone": "#4467A3",
    "siltstone": "#BD8233",
    "conglomerate": "#954477",
    "mudstone": "#2ABCCD",
    "dolomite": "#99B314",
}
# Darkened tone for each pattern's hatch marks (theme-independent, like the fill)
LITH_MARK = {
    lith: "#%02X%02X%02X" % tuple(int(c[i : i + 2], 16) * 42 // 100 for i in (1, 3, 5))
    for lith, c in LITH_COLOR.items()
}

# Title fontsize scales with title length off the 67-char baseline
title = "column-stratigraphic · python · pygal · anyplot.ai"
title_fs = max(44, round(66 * min(1.0, 67 / len(title))))

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=tuple(LITH_COLOR[layer["lithology"]] for layer in reversed(layers)),
    title_font_size=title_fs,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    font_family="'DejaVu Sans Mono', 'Courier New', monospace",
    title_font_family="'DejaVu Sans Mono', 'Courier New', monospace",
    label_font_family="'DejaVu Sans Mono', 'Courier New', monospace",
    stroke_width=2,
)

# Depth axis: bars stack upward from the base, but ticks read as depth-down
depth_ticks = [0, 25, 50, 75, 100, 125, total_depth]

chart = pygal.StackedBar(
    width=2400,
    height=2400,
    style=custom_style,
    title=title,
    show_legend=False,
    show_x_labels=False,
    show_y_labels=True,
    print_values=False,
    show_y_guides=False,
    show_x_guides=False,
    range=(0, total_depth),
    margin_top=150,
    margin_left=360,
    margin_right=560,
    margin_bottom=320,
    explicit_size=True,
)
chart.y_labels = [{"value": total_depth - d, "label": str(d)} for d in depth_ticks]

# Oldest layer first -> sits at the base; youngest ends up on top (surface)
for layer in reversed(layers):
    thickness = layer["bottom"] - layer["top"]
    chart.add(layer["formation"], [{"value": thickness, "label": layer["lithology"].title()}])

svg = chart.render().decode("utf-8")

# --- Lithology fill patterns (FGDC-style), injected into the SVG <defs> ---
patterns_svg = ""
for lith, base in LITH_COLOR.items():
    mark = LITH_MARK[lith]
    head = f'<pattern id="pat-{lith}" patternUnits="userSpaceOnUse"'
    if lith == "sandstone":  # stipple dots
        patterns_svg += (
            f'{head} width="34" height="34"><rect width="34" height="34" fill="{base}"/>'
            f'<circle cx="9" cy="9" r="3" fill="{mark}"/><circle cx="26" cy="26" r="3" fill="{mark}"/>'
            f'<circle cx="26" cy="9" r="2" fill="{mark}"/><circle cx="9" cy="26" r="2" fill="{mark}"/></pattern>'
        )
    elif lith == "shale":  # horizontal dashes
        patterns_svg += (
            f'{head} width="48" height="20"><rect width="48" height="20" fill="{base}"/>'
            f'<line x1="3" y1="10" x2="32" y2="10" stroke="{mark}" stroke-width="2.5"/></pattern>'
        )
    elif lith == "limestone":  # brick / blocky carbonate
        patterns_svg += (
            f'{head} width="56" height="34"><rect width="56" height="34" fill="{base}"/>'
            f'<line x1="0" y1="0" x2="56" y2="0" stroke="{mark}" stroke-width="2"/>'
            f'<line x1="0" y1="17" x2="56" y2="17" stroke="{mark}" stroke-width="2"/>'
            f'<line x1="28" y1="0" x2="28" y2="17" stroke="{mark}" stroke-width="2"/>'
            f'<line x1="0" y1="17" x2="0" y2="34" stroke="{mark}" stroke-width="2"/>'
            f'<line x1="56" y1="17" x2="56" y2="34" stroke="{mark}" stroke-width="2"/></pattern>'
        )
    elif lith == "siltstone":  # short broken dashes
        patterns_svg += (
            f'{head} width="36" height="26"><rect width="36" height="26" fill="{base}"/>'
            f'<line x1="4" y1="7" x2="16" y2="7" stroke="{mark}" stroke-width="2.2"/>'
            f'<line x1="20" y1="18" x2="32" y2="18" stroke="{mark}" stroke-width="2.2"/></pattern>'
        )
    elif lith == "conglomerate":  # pebble outlines
        patterns_svg += (
            f'{head} width="46" height="46"><rect width="46" height="46" fill="{base}"/>'
            f'<circle cx="13" cy="13" r="8" fill="none" stroke="{mark}" stroke-width="2.4"/>'
            f'<circle cx="33" cy="31" r="9" fill="none" stroke="{mark}" stroke-width="2.4"/>'
            f'<circle cx="32" cy="9" r="5" fill="none" stroke="{mark}" stroke-width="2.2"/></pattern>'
        )
    elif lith == "mudstone":  # fine horizontal laminations
        patterns_svg += (
            f'{head} width="30" height="14"><rect width="30" height="14" fill="{base}"/>'
            f'<line x1="0" y1="7" x2="30" y2="7" stroke="{mark}" stroke-width="1.4"/></pattern>'
        )
    else:  # dolomite -> rhombs
        patterns_svg += (
            f'{head} width="36" height="36"><rect width="36" height="36" fill="{base}"/>'
            f'<polyline points="18,3 33,18 18,33 3,18 18,3" fill="none" '
            f'stroke="{mark}" stroke-width="2"/></pattern>'
        )

svg = svg.replace("<defs>", "<defs>" + patterns_svg, 1)

# Swap each lithology's flat CSS fill for its pattern; recolor bar edges to ink
for lith, color in LITH_COLOR.items():
    svg = svg.replace(f"fill:{color}", f"fill:url(#pat-{lith})")
svg = svg.replace("</style>", f".rect.reactive{{stroke:{INK};stroke-width:2.5}}</style>", 1)

# --- Locate the plot group offset and parse the stacked bar rectangles ---
tx, ty = 0.0, 0.0
plot_m = re.search(r'<g transform="translate\(([\d.]+),\s*([\d.]+)\)" class="plot">', svg)
if plot_m:
    tx, ty = float(plot_m.group(1)), float(plot_m.group(2))

bars = []
for m in re.finditer(r'<rect ([^>]*?)class="rect reactive[^>]*>', svg):
    a = m.group(1)
    xm = re.search(r'x="([\d.]+)"', a)
    ym = re.search(r'y="([\d.]+)"', a)
    wm = re.search(r'width="([\d.]+)"', a)
    hm = re.search(r'height="([\d.]+)"', a)
    if xm and ym and wm and hm:
        bars.append(
            {
                "x": float(xm.group(1)) + tx,
                "y": float(ym.group(1)) + ty,
                "w": float(wm.group(1)),
                "h": float(hm.group(1)),
            }
        )

# Top (smallest y) = youngest = layers[0]; order matches `layers` directly
bars.sort(key=lambda b: b["y"])
overlay = []

if bars:
    bar_left = bars[0]["x"]
    bar_right = bars[0]["x"] + bars[0]["w"]
    form_x = bar_right + 28
    age_x = 150.0

    # Depth axis caption (units) at the top of the left margin
    overlay.append(
        f'<text x="{bar_left - 10:.1f}" y="{bars[0]["y"] - 36:.1f}" '
        f'font-family="DejaVu Sans Mono, monospace" font-size="44" fill="{INK}" '
        f'text-anchor="end" font-weight="bold">Depth (m)</text>'
    )

    # Formation labels to the right of each layer, with a short leader line
    for layer, bar in zip(layers, bars, strict=False):
        cy = bar["y"] + bar["h"] / 2
        thickness = layer["bottom"] - layer["top"]
        overlay.append(
            f'<line x1="{bar_right:.1f}" y1="{cy:.1f}" x2="{form_x - 6:.1f}" y2="{cy:.1f}" '
            f'stroke="{INK_MUTED}" stroke-width="1.5"/>'
        )
        overlay.append(
            f'<text x="{form_x:.1f}" y="{cy:.1f}" '
            f'font-family="DejaVu Sans Mono, monospace" font-size="36" fill="{INK}" '
            f'text-anchor="start" dominant-baseline="central">{layer["formation"]}</text>'
        )
        overlay.append(
            f'<text x="{form_x:.1f}" y="{cy + 42:.1f}" '
            f'font-family="DejaVu Sans Mono, monospace" font-size="30" fill="{INK_SOFT}" '
            f'text-anchor="start" dominant-baseline="central">{thickness} m</text>'
        )

    # Age-period brackets on the far left, grouping consecutive same-age layers
    groups = []
    cur = layers[0]["age"]
    grp = [bars[0]]
    for layer, bar in list(zip(layers, bars, strict=False))[1:]:
        if layer["age"] != cur:
            groups.append({"age": cur, "bars": grp})
            cur, grp = layer["age"], [bar]
        else:
            grp.append(bar)
    groups.append({"age": cur, "bars": grp})

    overlay.append(
        f'<text x="{age_x:.1f}" y="{bars[0]["y"] - 36:.1f}" '
        f'font-family="DejaVu Sans Mono, monospace" font-size="44" fill="{INK}" '
        f'text-anchor="middle" font-weight="bold">Period</text>'
    )
    for g in groups:
        ys = [b["y"] for b in g["bars"]] + [b["y"] + b["h"] for b in g["bars"]]
        y_top, y_bot = min(ys) + 3, max(ys) - 3
        y_mid = (y_top + y_bot) / 2
        overlay.append(
            f'<line x1="{age_x:.1f}" y1="{y_top:.1f}" x2="{age_x:.1f}" y2="{y_bot:.1f}" '
            f'stroke="{INK_SOFT}" stroke-width="3"/>'
            f'<line x1="{age_x:.1f}" y1="{y_top:.1f}" x2="{age_x + 16:.1f}" y2="{y_top:.1f}" '
            f'stroke="{INK_SOFT}" stroke-width="3"/>'
            f'<line x1="{age_x:.1f}" y1="{y_bot:.1f}" x2="{age_x + 16:.1f}" y2="{y_bot:.1f}" '
            f'stroke="{INK_SOFT}" stroke-width="3"/>'
        )
        overlay.append(
            f'<text x="{age_x - 26:.1f}" y="{y_mid:.1f}" '
            f'transform="rotate(-90 {age_x - 26:.1f} {y_mid:.1f})" '
            f'font-family="DejaVu Sans Mono, monospace" font-size="38" fill="{INK_SOFT}" '
            f'text-anchor="middle">{g["age"]}</text>'
        )

    # Bottom legend: one swatch per unique lithology, in first-appearance order
    seen = []
    for layer in layers:
        if layer["lithology"] not in seen:
            seen.append(layer["lithology"])
    sw = 52
    gap = (2400 - 120) / len(seen)
    leg_y = 2400 - 150
    for i, lith in enumerate(seen):
        lx = 120 + i * gap
        overlay.append(
            f'<rect x="{lx:.1f}" y="{leg_y - sw / 2:.1f}" width="{sw}" height="{sw}" '
            f'fill="url(#pat-{lith})" stroke="{INK}" stroke-width="2"/>'
        )
        overlay.append(
            f'<text x="{lx + sw + 14:.1f}" y="{leg_y:.1f}" '
            f'font-family="DejaVu Sans Mono, monospace" font-size="34" fill="{INK_SOFT}" '
            f'text-anchor="start" dominant-baseline="central">{lith}</text>'
        )

svg = svg.replace("</svg>", "\n".join(overlay) + "\n</svg>")
svg = svg.replace(">No data<", "><")

# Save interactive HTML (pygal's SVG keeps tooltips) and rasterised PNG
with open(f"plot-{THEME}.html", "w") as f:
    f.write(svg)
cairosvg.svg2png(bytestring=svg.encode(), write_to=f"plot-{THEME}.png", output_width=2400, output_height=2400)

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

Other implementations