{"spec_id":"column-stratigraphic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ncolumn-stratigraphic: Stratigraphic Column with Lithology Patterns\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport pandas as pd\nfrom PIL import Image\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\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\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# Imprint palette — theme-independent categorical hues. Lithologies are abstract\n# rock-type categories, so positions are assigned 1→N in canonical order, but\n# position 5 (matte red #AE3030) is reserved as the semantic anchor for the\n# unconformity markers (a gap/loss in the geological record) — so lithologies\n# use positions 1,2,3,4,6.\nLITHO_GREEN = \"#009E73\"  # Imprint 1 — brand, first categorical series\nLITHO_LAV = \"#C475FD\"  # Imprint 2\nLITHO_BLUE = \"#4467A3\"  # Imprint 3\nLITHO_OCHRE = \"#BD8233\"  # Imprint 4\nLITHO_CYAN = \"#2ABCCD\"  # Imprint 6\nUNCONFORMITY = \"#AE3030\"  # Imprint 5 — semantic red for missing-time / unconformity\n\n# Data: Grand Canyon sedimentary section, 10 layers spanning Cambrian to Permian\n# Dramatic thickness variation (10-35 m) to showcase the stratigraphic format\nlayers = pd.DataFrame(\n    {\n        \"top\": [0, 30, 45, 75, 85, 110, 120, 155, 170, 180],\n        \"bottom\": [30, 45, 75, 85, 110, 120, 155, 170, 180, 200],\n        \"lithology\": [\n            \"Sandstone\",\n            \"Shale\",\n            \"Limestone\",\n            \"Siltstone\",\n            \"Sandstone\",\n            \"Conglomerate\",\n            \"Shale\",\n            \"Limestone\",\n            \"Siltstone\",\n            \"Sandstone\",\n        ],\n        \"formation\": [\n            \"Cedar Mesa Fm\",\n            \"Organ Rock Fm\",\n            \"White Rim Fm\",\n            \"De Chelly Fm\",\n            \"Coconino Fm\",\n            \"Hermit Fm\",\n            \"Supai Group\",\n            \"Redwall Fm\",\n            \"Temple Butte Fm\",\n            \"Muav Fm\",\n        ],\n        \"age\": [\n            \"Permian\",\n            \"Permian\",\n            \"Permian\",\n            \"Permian\",\n            \"Permian\",\n            \"Permian\",\n            \"Pennsylvanian\",\n            \"Mississippian\",\n            \"Devonian\",\n            \"Cambrian\",\n        ],\n    }\n)\n\nlayers[\"thickness\"] = layers[\"bottom\"] - layers[\"top\"]\nlayers[\"mid_depth\"] = (layers[\"top\"] + layers[\"bottom\"]) / 2\n\n# Lithology → Imprint palette (5 distinct rock types)\nlithology_order = [\"Sandstone\", \"Shale\", \"Limestone\", \"Siltstone\", \"Conglomerate\"]\nlithology_colors = {\n    \"Sandstone\": LITHO_GREEN,\n    \"Shale\": LITHO_LAV,\n    \"Limestone\": LITHO_BLUE,\n    \"Siltstone\": LITHO_OCHRE,\n    \"Conglomerate\": LITHO_CYAN,\n}\n\n# Lithology pattern glyphs approximating FGDC/USGS texture conventions. Single\n# motif per rock type, tiled across the full column width (rows × columns) so each\n# layer reads as a true fill texture rather than a centered band.\npattern_symbols = {\"Sandstone\": \"·\", \"Shale\": \"—\", \"Limestone\": \"▤\", \"Siltstone\": \"╌\", \"Conglomerate\": \"◯\"}\n\n# Column horizontal span (data units within x_domain) — the rectangles and the\n# tiled texture share these bounds.\nCOL_L, COL_R = 3.2, 10.7\n\n# Texture grid: tile each layer with rows (depth) × columns (across the width) of\n# the lithology glyph, so the pattern fills the whole rectangle.\nn_cols = 11\ncol_x = [COL_L + 0.35 + i * ((COL_R - COL_L - 0.7) / (n_cols - 1)) for i in range(n_cols)]\npattern_rows = []\nfor _, row in layers.iterrows():\n    layer_height = row[\"bottom\"] - row[\"top\"]\n    n_rows = max(2, int(layer_height / 6))\n    spacing = layer_height / (n_rows + 1)\n    sym = pattern_symbols[row[\"lithology\"]]\n    for i in range(n_rows):\n        depth = row[\"top\"] + spacing * (i + 1)\n        for xp in col_x:\n            pattern_rows.append({\"depth\": depth, \"pattern\": sym, \"x_mid\": xp})\npattern_df = pd.DataFrame(pattern_rows)\n\n# Age groups — one bracket per contiguous geological period\nage_groups = []\ncurrent_age = None\nfor _, row in layers.iterrows():\n    if row[\"age\"] != current_age:\n        current_age = row[\"age\"]\n        group_rows = layers[layers[\"age\"] == current_age]\n        age_groups.append(\n            {\n                \"age\": current_age,\n                \"top\": group_rows[\"top\"].min(),\n                \"bottom\": group_rows[\"bottom\"].max(),\n                \"mid_depth\": (group_rows[\"top\"].min() + group_rows[\"bottom\"].max()) / 2,\n            }\n        )\nage_df = pd.DataFrame(age_groups)\n\n# Unconformities at major age boundaries (missing time / erosional gaps)\nunconformity_df = pd.DataFrame({\"depth\": [120, 170], \"label\": [\"Unconformity\", \"Unconformity\"]})\n\n# Shared horizontal layout — generous x domain spreads side labels into clear lanes\nx_domain = [0, 20]\n\n# Layer rectangles — the stratigraphic column itself (x: 3.0 to 10.5)\nrects = (\n    alt.Chart(layers)\n    .mark_rect(stroke=INK, strokeWidth=1.2)\n    .encode(\n        y=alt.Y(\n            \"top:Q\",\n            title=\"Depth (m)\",\n            scale=alt.Scale(domain=[0, 200], reverse=True),\n            axis=alt.Axis(\n                labelFontSize=11,\n                titleFontSize=13,\n                tickCount=10,\n                gridColor=INK,\n                gridOpacity=0.15,\n                domainColor=INK_SOFT,\n                domainWidth=1.2,\n                tickColor=INK_SOFT,\n                labelColor=INK_SOFT,\n                titleColor=INK,\n            ),\n        ),\n        y2=\"bottom:Q\",\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain), axis=None),\n        x2=\"x2:Q\",\n        color=alt.Color(\n            \"lithology:N\",\n            title=\"Lithology\",\n            scale=alt.Scale(domain=lithology_order, range=[lithology_colors[k] for k in lithology_order]),\n            legend=alt.Legend(\n                titleFontSize=12,\n                labelFontSize=11,\n                symbolSize=320,\n                orient=\"bottom\",\n                titlePadding=8,\n                direction=\"horizontal\",\n                labelLimit=200,\n                symbolStrokeWidth=1.0,\n                symbolStrokeColor=INK_SOFT,\n                padding=10,\n                columns=5,\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"formation:N\", title=\"Formation\"),\n            alt.Tooltip(\"lithology:N\", title=\"Lithology\"),\n            alt.Tooltip(\"age:N\", title=\"Age\"),\n            alt.Tooltip(\"top:Q\", title=\"Top (m)\"),\n            alt.Tooltip(\"bottom:Q\", title=\"Bottom (m)\"),\n            alt.Tooltip(\"thickness:Q\", title=\"Thickness (m)\"),\n        ],\n    )\n    .transform_calculate(x=f\"{COL_L}\", x2=f\"{COL_R}\")\n)\n\n# Tiled pattern texture overlay — dark ink reads on every Imprint fill in both themes\npattern_text = (\n    alt.Chart(pattern_df)\n    .mark_text(fontSize=13, color=\"#1A1A17\", opacity=0.68, fontWeight=\"bold\")\n    .encode(y=alt.Y(\"depth:Q\"), x=alt.X(\"x_mid:Q\", scale=alt.Scale(domain=x_domain)), text=\"pattern:N\")\n)\n\n# Formation name labels — to the right of the column\nformation_labels = (\n    alt.Chart(layers)\n    .mark_text(fontSize=12, fontWeight=\"bold\", align=\"left\", color=INK)\n    .encode(y=alt.Y(\"mid_depth:Q\"), x=alt.X(\"x_pos:Q\", scale=alt.Scale(domain=x_domain)), text=\"formation:N\")\n    .transform_calculate(x_pos=f\"{COL_R + 0.4}\")\n)\n\n# Thickness annotations — far-right lane, tertiary text\nthickness_labels = (\n    alt.Chart(layers)\n    .mark_text(fontSize=11, align=\"right\", color=INK_MUTED, fontStyle=\"italic\")\n    .encode(y=alt.Y(\"mid_depth:Q\"), x=alt.X(\"x_pos:Q\", scale=alt.Scale(domain=x_domain)), text=\"label:N\")\n    .transform_calculate(x_pos=\"19.6\", label=\"datum.thickness + ' m'\")\n)\n\n# Age bracket vertical lines — far-left, just clear of the depth-axis numerals\nage_brackets_v = (\n    alt.Chart(age_df)\n    .mark_rule(strokeWidth=2.0, color=INK_SOFT)\n    .encode(y=alt.Y(\"top:Q\"), y2=\"bottom:Q\", x=alt.X(\"x_pos:Q\", scale=alt.Scale(domain=x_domain)))\n    .transform_calculate(x_pos=\"0.35\")\n)\n\n# Age period labels — left-aligned immediately right of the bracket so the long\n# italic period names sit in their own lane between the bracket and the column,\n# never reaching back into the depth-axis tick numbers (140/160/180).\nage_labels = (\n    alt.Chart(age_df)\n    .mark_text(fontSize=10, fontStyle=\"italic\", fontWeight=\"bold\", align=\"left\", color=INK)\n    .encode(y=alt.Y(\"mid_depth:Q\"), x=alt.X(\"x_pos:Q\", scale=alt.Scale(domain=x_domain)), text=\"age:N\")\n    .transform_calculate(x_pos=\"0.9\")\n)\n\n# Age bracket horizontal ticks (top and bottom of each age group)\nbracket_ticks_data = []\nfor _, row in age_df.iterrows():\n    bracket_ticks_data.append({\"depth\": row[\"top\"]})\n    bracket_ticks_data.append({\"depth\": row[\"bottom\"]})\nbracket_ticks_df = pd.DataFrame(bracket_ticks_data)\n\nage_bracket_ticks = (\n    alt.Chart(bracket_ticks_df)\n    .mark_rule(strokeWidth=2.0, color=INK_SOFT)\n    .encode(y=alt.Y(\"depth:Q\"), x=alt.X(\"x1:Q\", scale=alt.Scale(domain=x_domain)), x2=\"x2:Q\")\n    .transform_calculate(x1=\"0.35\", x2=\"0.7\")\n)\n\n# Unconformity markers — red dashed lines crossing the column at age boundaries\nunconformity_rules = (\n    alt.Chart(unconformity_df)\n    .mark_rule(strokeWidth=3.0, color=UNCONFORMITY, strokeDash=[8, 4])\n    .encode(y=alt.Y(\"depth:Q\"), x=alt.X(\"x1:Q\", scale=alt.Scale(domain=x_domain)), x2=\"x2:Q\")\n    .transform_calculate(x1=f\"{COL_L}\", x2=f\"{COL_R}\")\n)\n\n# Unconformity labels — pinned just inside the column's left edge, lifted clear of\n# the layer texture above the dashed rule\nunconformity_labels_chart = (\n    alt.Chart(unconformity_df)\n    .mark_text(fontSize=12, color=UNCONFORMITY, fontWeight=\"bold\", align=\"left\", dy=-11)\n    .encode(y=alt.Y(\"depth:Q\"), x=alt.X(\"x_pos:Q\", scale=alt.Scale(domain=x_domain)), text=\"label:N\")\n    .transform_calculate(x_pos=f\"{COL_L + 0.15}\")\n)\n\n# Compose all layers\ntitle = \"column-stratigraphic · python · altair · anyplot.ai\"\nchart = (\n    (\n        rects\n        + pattern_text\n        + formation_labels\n        + thickness_labels\n        + age_labels\n        + age_brackets_v\n        + age_bracket_ticks\n        + unconformity_rules\n        + unconformity_labels_chart\n    )\n    .properties(\n        width=700,\n        height=280,\n        title=alt.Title(\n            title,\n            fontSize=16,\n            anchor=\"middle\",\n            offset=12,\n            color=INK,\n            subtitle=\"Grand Canyon Sedimentary Section — Cambrian to Permian\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=6,\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure(background=PAGE_BG)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG, then PAD-only up to the exact landscape target (never crop — see altair.md)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}