{"spec_id":"column-stratigraphic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ncolumn-stratigraphic: Stratigraphic Column with Lithology Patterns\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport re\nimport sys\n\n\n# This file is named pygal.py, so `import pygal` would resolve to it; drop the\n# script's own directory from sys.path so the installed pygal package wins.\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\n\nimport cairosvg\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme-adaptive chrome (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Data: synthetic sedimentary section, depth increasing downward (surface = 0 m)\nlayers = [\n    {\"top\": 0, \"bottom\": 12, \"lithology\": \"sandstone\", \"formation\": \"Red Mesa Fm\", \"age\": \"Eocene\"},\n    {\"top\": 12, \"bottom\": 25, \"lithology\": \"shale\", \"formation\": \"Grey Basin Fm\", \"age\": \"Paleocene\"},\n    {\"top\": 25, \"bottom\": 38, \"lithology\": \"limestone\", \"formation\": \"Chalk Bluff Fm\", \"age\": \"Paleocene\"},\n    {\"top\": 38, \"bottom\": 50, \"lithology\": \"siltstone\", \"formation\": \"Iron Creek Mbr\", \"age\": \"L. Cretaceous\"},\n    {\"top\": 50, \"bottom\": 68, \"lithology\": \"sandstone\", \"formation\": \"Canyon Wall Fm\", \"age\": \"L. Cretaceous\"},\n    {\"top\": 68, \"bottom\": 82, \"lithology\": \"shale\", \"formation\": \"Dark Hollow Fm\", \"age\": \"E. Cretaceous\"},\n    {\"top\": 82, \"bottom\": 90, \"lithology\": \"conglomerate\", \"formation\": \"Boulder Bed Mbr\", \"age\": \"E. Cretaceous\"},\n    {\"top\": 90, \"bottom\": 108, \"lithology\": \"limestone\", \"formation\": \"Shell Bank Fm\", \"age\": \"Jurassic\"},\n    {\"top\": 108, \"bottom\": 118, \"lithology\": \"mudstone\", \"formation\": \"Quiet Water Fm\", \"age\": \"Jurassic\"},\n    {\"top\": 118, \"bottom\": 135, \"lithology\": \"dolomite\", \"formation\": \"Crystal Ridge Fm\", \"age\": \"Triassic\"},\n]\ntotal_depth = layers[-1][\"bottom\"]\n\n# Lithology -> Imprint palette (first lithology = brand green #009E73). Patterns,\n# not hue, carry the geological meaning; distinct hues keep the 7 rock types apart.\nLITH_COLOR = {\n    \"sandstone\": \"#009E73\",\n    \"shale\": \"#C475FD\",\n    \"limestone\": \"#4467A3\",\n    \"siltstone\": \"#BD8233\",\n    \"conglomerate\": \"#954477\",\n    \"mudstone\": \"#2ABCCD\",\n    \"dolomite\": \"#99B314\",\n}\n# Darkened tone for each pattern's hatch marks (theme-independent, like the fill)\nLITH_MARK = {\n    lith: \"#%02X%02X%02X\" % tuple(int(c[i : i + 2], 16) * 42 // 100 for i in (1, 3, 5))\n    for lith, c in LITH_COLOR.items()\n}\n\n# Title fontsize scales with title length off the 67-char baseline\ntitle = \"column-stratigraphic · python · pygal · anyplot.ai\"\ntitle_fs = max(44, round(66 * min(1.0, 67 / len(title))))\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=tuple(LITH_COLOR[layer[\"lithology\"]] for layer in reversed(layers)),\n    title_font_size=title_fs,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    font_family=\"'DejaVu Sans Mono', 'Courier New', monospace\",\n    title_font_family=\"'DejaVu Sans Mono', 'Courier New', monospace\",\n    label_font_family=\"'DejaVu Sans Mono', 'Courier New', monospace\",\n    stroke_width=2,\n)\n\n# Depth axis: bars stack upward from the base, but ticks read as depth-down\ndepth_ticks = [0, 25, 50, 75, 100, 125, total_depth]\n\nchart = pygal.StackedBar(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=title,\n    show_legend=False,\n    show_x_labels=False,\n    show_y_labels=True,\n    print_values=False,\n    show_y_guides=False,\n    show_x_guides=False,\n    range=(0, total_depth),\n    margin_top=150,\n    margin_left=360,\n    margin_right=560,\n    margin_bottom=320,\n    explicit_size=True,\n)\nchart.y_labels = [{\"value\": total_depth - d, \"label\": str(d)} for d in depth_ticks]\n\n# Oldest layer first -> sits at the base; youngest ends up on top (surface)\nfor layer in reversed(layers):\n    thickness = layer[\"bottom\"] - layer[\"top\"]\n    chart.add(layer[\"formation\"], [{\"value\": thickness, \"label\": layer[\"lithology\"].title()}])\n\nsvg = chart.render().decode(\"utf-8\")\n\n# --- Lithology fill patterns (FGDC-style), injected into the SVG <defs> ---\npatterns_svg = \"\"\nfor lith, base in LITH_COLOR.items():\n    mark = LITH_MARK[lith]\n    head = f'<pattern id=\"pat-{lith}\" patternUnits=\"userSpaceOnUse\"'\n    if lith == \"sandstone\":  # stipple dots\n        patterns_svg += (\n            f'{head} width=\"34\" height=\"34\"><rect width=\"34\" height=\"34\" fill=\"{base}\"/>'\n            f'<circle cx=\"9\" cy=\"9\" r=\"3\" fill=\"{mark}\"/><circle cx=\"26\" cy=\"26\" r=\"3\" fill=\"{mark}\"/>'\n            f'<circle cx=\"26\" cy=\"9\" r=\"2\" fill=\"{mark}\"/><circle cx=\"9\" cy=\"26\" r=\"2\" fill=\"{mark}\"/></pattern>'\n        )\n    elif lith == \"shale\":  # horizontal dashes\n        patterns_svg += (\n            f'{head} width=\"48\" height=\"20\"><rect width=\"48\" height=\"20\" fill=\"{base}\"/>'\n            f'<line x1=\"3\" y1=\"10\" x2=\"32\" y2=\"10\" stroke=\"{mark}\" stroke-width=\"2.5\"/></pattern>'\n        )\n    elif lith == \"limestone\":  # brick / blocky carbonate\n        patterns_svg += (\n            f'{head} width=\"56\" height=\"34\"><rect width=\"56\" height=\"34\" fill=\"{base}\"/>'\n            f'<line x1=\"0\" y1=\"0\" x2=\"56\" y2=\"0\" stroke=\"{mark}\" stroke-width=\"2\"/>'\n            f'<line x1=\"0\" y1=\"17\" x2=\"56\" y2=\"17\" stroke=\"{mark}\" stroke-width=\"2\"/>'\n            f'<line x1=\"28\" y1=\"0\" x2=\"28\" y2=\"17\" stroke=\"{mark}\" stroke-width=\"2\"/>'\n            f'<line x1=\"0\" y1=\"17\" x2=\"0\" y2=\"34\" stroke=\"{mark}\" stroke-width=\"2\"/>'\n            f'<line x1=\"56\" y1=\"17\" x2=\"56\" y2=\"34\" stroke=\"{mark}\" stroke-width=\"2\"/></pattern>'\n        )\n    elif lith == \"siltstone\":  # short broken dashes\n        patterns_svg += (\n            f'{head} width=\"36\" height=\"26\"><rect width=\"36\" height=\"26\" fill=\"{base}\"/>'\n            f'<line x1=\"4\" y1=\"7\" x2=\"16\" y2=\"7\" stroke=\"{mark}\" stroke-width=\"2.2\"/>'\n            f'<line x1=\"20\" y1=\"18\" x2=\"32\" y2=\"18\" stroke=\"{mark}\" stroke-width=\"2.2\"/></pattern>'\n        )\n    elif lith == \"conglomerate\":  # pebble outlines\n        patterns_svg += (\n            f'{head} width=\"46\" height=\"46\"><rect width=\"46\" height=\"46\" fill=\"{base}\"/>'\n            f'<circle cx=\"13\" cy=\"13\" r=\"8\" fill=\"none\" stroke=\"{mark}\" stroke-width=\"2.4\"/>'\n            f'<circle cx=\"33\" cy=\"31\" r=\"9\" fill=\"none\" stroke=\"{mark}\" stroke-width=\"2.4\"/>'\n            f'<circle cx=\"32\" cy=\"9\" r=\"5\" fill=\"none\" stroke=\"{mark}\" stroke-width=\"2.2\"/></pattern>'\n        )\n    elif lith == \"mudstone\":  # fine horizontal laminations\n        patterns_svg += (\n            f'{head} width=\"30\" height=\"14\"><rect width=\"30\" height=\"14\" fill=\"{base}\"/>'\n            f'<line x1=\"0\" y1=\"7\" x2=\"30\" y2=\"7\" stroke=\"{mark}\" stroke-width=\"1.4\"/></pattern>'\n        )\n    else:  # dolomite -> rhombs\n        patterns_svg += (\n            f'{head} width=\"36\" height=\"36\"><rect width=\"36\" height=\"36\" fill=\"{base}\"/>'\n            f'<polyline points=\"18,3 33,18 18,33 3,18 18,3\" fill=\"none\" '\n            f'stroke=\"{mark}\" stroke-width=\"2\"/></pattern>'\n        )\n\nsvg = svg.replace(\"<defs>\", \"<defs>\" + patterns_svg, 1)\n\n# Swap each lithology's flat CSS fill for its pattern; recolor bar edges to ink\nfor lith, color in LITH_COLOR.items():\n    svg = svg.replace(f\"fill:{color}\", f\"fill:url(#pat-{lith})\")\nsvg = svg.replace(\"</style>\", f\".rect.reactive{{stroke:{INK};stroke-width:2.5}}</style>\", 1)\n\n# --- Locate the plot group offset and parse the stacked bar rectangles ---\ntx, ty = 0.0, 0.0\nplot_m = re.search(r'<g transform=\"translate\\(([\\d.]+),\\s*([\\d.]+)\\)\" class=\"plot\">', svg)\nif plot_m:\n    tx, ty = float(plot_m.group(1)), float(plot_m.group(2))\n\nbars = []\nfor m in re.finditer(r'<rect ([^>]*?)class=\"rect reactive[^>]*>', svg):\n    a = m.group(1)\n    xm = re.search(r'x=\"([\\d.]+)\"', a)\n    ym = re.search(r'y=\"([\\d.]+)\"', a)\n    wm = re.search(r'width=\"([\\d.]+)\"', a)\n    hm = re.search(r'height=\"([\\d.]+)\"', a)\n    if xm and ym and wm and hm:\n        bars.append(\n            {\n                \"x\": float(xm.group(1)) + tx,\n                \"y\": float(ym.group(1)) + ty,\n                \"w\": float(wm.group(1)),\n                \"h\": float(hm.group(1)),\n            }\n        )\n\n# Top (smallest y) = youngest = layers[0]; order matches `layers` directly\nbars.sort(key=lambda b: b[\"y\"])\noverlay = []\n\nif bars:\n    bar_left = bars[0][\"x\"]\n    bar_right = bars[0][\"x\"] + bars[0][\"w\"]\n    form_x = bar_right + 28\n    age_x = 150.0\n\n    # Depth axis caption (units) at the top of the left margin\n    overlay.append(\n        f'<text x=\"{bar_left - 10:.1f}\" y=\"{bars[0][\"y\"] - 36:.1f}\" '\n        f'font-family=\"DejaVu Sans Mono, monospace\" font-size=\"44\" fill=\"{INK}\" '\n        f'text-anchor=\"end\" font-weight=\"bold\">Depth (m)</text>'\n    )\n\n    # Formation labels to the right of each layer, with a short leader line\n    for layer, bar in zip(layers, bars, strict=False):\n        cy = bar[\"y\"] + bar[\"h\"] / 2\n        thickness = layer[\"bottom\"] - layer[\"top\"]\n        overlay.append(\n            f'<line x1=\"{bar_right:.1f}\" y1=\"{cy:.1f}\" x2=\"{form_x - 6:.1f}\" y2=\"{cy:.1f}\" '\n            f'stroke=\"{INK_MUTED}\" stroke-width=\"1.5\"/>'\n        )\n        overlay.append(\n            f'<text x=\"{form_x:.1f}\" y=\"{cy:.1f}\" '\n            f'font-family=\"DejaVu Sans Mono, monospace\" font-size=\"36\" fill=\"{INK}\" '\n            f'text-anchor=\"start\" dominant-baseline=\"central\">{layer[\"formation\"]}</text>'\n        )\n        overlay.append(\n            f'<text x=\"{form_x:.1f}\" y=\"{cy + 42:.1f}\" '\n            f'font-family=\"DejaVu Sans Mono, monospace\" font-size=\"30\" fill=\"{INK_SOFT}\" '\n            f'text-anchor=\"start\" dominant-baseline=\"central\">{thickness} m</text>'\n        )\n\n    # Age-period brackets on the far left, grouping consecutive same-age layers\n    groups = []\n    cur = layers[0][\"age\"]\n    grp = [bars[0]]\n    for layer, bar in list(zip(layers, bars, strict=False))[1:]:\n        if layer[\"age\"] != cur:\n            groups.append({\"age\": cur, \"bars\": grp})\n            cur, grp = layer[\"age\"], [bar]\n        else:\n            grp.append(bar)\n    groups.append({\"age\": cur, \"bars\": grp})\n\n    overlay.append(\n        f'<text x=\"{age_x:.1f}\" y=\"{bars[0][\"y\"] - 36:.1f}\" '\n        f'font-family=\"DejaVu Sans Mono, monospace\" font-size=\"44\" fill=\"{INK}\" '\n        f'text-anchor=\"middle\" font-weight=\"bold\">Period</text>'\n    )\n    for g in groups:\n        ys = [b[\"y\"] for b in g[\"bars\"]] + [b[\"y\"] + b[\"h\"] for b in g[\"bars\"]]\n        y_top, y_bot = min(ys) + 3, max(ys) - 3\n        y_mid = (y_top + y_bot) / 2\n        overlay.append(\n            f'<line x1=\"{age_x:.1f}\" y1=\"{y_top:.1f}\" x2=\"{age_x:.1f}\" y2=\"{y_bot:.1f}\" '\n            f'stroke=\"{INK_SOFT}\" stroke-width=\"3\"/>'\n            f'<line x1=\"{age_x:.1f}\" y1=\"{y_top:.1f}\" x2=\"{age_x + 16:.1f}\" y2=\"{y_top:.1f}\" '\n            f'stroke=\"{INK_SOFT}\" stroke-width=\"3\"/>'\n            f'<line x1=\"{age_x:.1f}\" y1=\"{y_bot:.1f}\" x2=\"{age_x + 16:.1f}\" y2=\"{y_bot:.1f}\" '\n            f'stroke=\"{INK_SOFT}\" stroke-width=\"3\"/>'\n        )\n        overlay.append(\n            f'<text x=\"{age_x - 26:.1f}\" y=\"{y_mid:.1f}\" '\n            f'transform=\"rotate(-90 {age_x - 26:.1f} {y_mid:.1f})\" '\n            f'font-family=\"DejaVu Sans Mono, monospace\" font-size=\"38\" fill=\"{INK_SOFT}\" '\n            f'text-anchor=\"middle\">{g[\"age\"]}</text>'\n        )\n\n    # Bottom legend: one swatch per unique lithology, in first-appearance order\n    seen = []\n    for layer in layers:\n        if layer[\"lithology\"] not in seen:\n            seen.append(layer[\"lithology\"])\n    sw = 52\n    gap = (2400 - 120) / len(seen)\n    leg_y = 2400 - 150\n    for i, lith in enumerate(seen):\n        lx = 120 + i * gap\n        overlay.append(\n            f'<rect x=\"{lx:.1f}\" y=\"{leg_y - sw / 2:.1f}\" width=\"{sw}\" height=\"{sw}\" '\n            f'fill=\"url(#pat-{lith})\" stroke=\"{INK}\" stroke-width=\"2\"/>'\n        )\n        overlay.append(\n            f'<text x=\"{lx + sw + 14:.1f}\" y=\"{leg_y:.1f}\" '\n            f'font-family=\"DejaVu Sans Mono, monospace\" font-size=\"34\" fill=\"{INK_SOFT}\" '\n            f'text-anchor=\"start\" dominant-baseline=\"central\">{lith}</text>'\n        )\n\nsvg = svg.replace(\"</svg>\", \"\\n\".join(overlay) + \"\\n</svg>\")\nsvg = svg.replace(\">No data<\", \"><\")\n\n# Save interactive HTML (pygal's SVG keeps tooltips) and rasterised PNG\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg)\ncairosvg.svg2png(bytestring=svg.encode(), write_to=f\"plot-{THEME}.png\", output_width=2400, output_height=2400)\n"}