{"spec_id":"column-stratigraphic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ncolumn-stratigraphic: Stratigraphic Column with Lithology Patterns\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 94/100 | Updated: 2026-06-17\n\"\"\"\n# ruff: noqa: F405\n\nimport os\nimport shutil\n\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\n# Theme tokens (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 — matte red (#AE3030) reserved as the unconformity anchor\nACCENT = \"#AE3030\"  # Imprint semantic anchor — erosional discontinuity\n\n# Pattern ink overlays the saturated fills (constant in both themes)\nPATTERN_INK = \"#1A1A17\"\n\n# Data - synthetic sedimentary section with 10 layers (depth increasing downward)\nlayers = pd.DataFrame(\n    {\n        \"top\": [0, 15, 35, 55, 80, 110, 135, 165, 195, 230],\n        \"bottom\": [15, 35, 55, 80, 110, 135, 165, 195, 230, 260],\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            \"Basal Sand Fm\",\n            \"Dark Shale Mbr\",\n            \"Reef Limestone Fm\",\n            \"Gray Silt Mbr\",\n            \"Channel Sand Fm\",\n            \"Gravel Bed Fm\",\n            \"Marine Shale Fm\",\n            \"Platform Carb Fm\",\n            \"Tidal Flat Mbr\",\n            \"Upper Sand Fm\",\n        ],\n        \"age\": [\n            \"Triassic\",\n            \"Triassic\",\n            \"Jurassic\",\n            \"Jurassic\",\n            \"Jurassic\",\n            \"Cretaceous\",\n            \"Cretaceous\",\n            \"Cretaceous\",\n            \"Paleogene\",\n            \"Paleogene\",\n        ],\n    }\n)\n\nlayers[\"thickness\"] = layers[\"bottom\"] - layers[\"top\"]\nlayers[\"xmin\"] = 0.0\nlayers[\"xmax\"] = 1.0\n\n# Lithology fills — Imprint palette (CVD-safe), red withheld for the unconformity.\n# Sandstone is the first categorical series → brand green #009E73.\nlithology_colors = {\n    \"Sandstone\": \"#009E73\",  # Imprint 1 — brand green (first series)\n    \"Shale\": \"#C475FD\",  # Imprint 2 — lavender\n    \"Limestone\": \"#4467A3\",  # Imprint 3 — blue\n    \"Siltstone\": \"#BD8233\",  # Imprint 4 — ochre (earth)\n    \"Conglomerate\": \"#2ABCCD\",  # Imprint 6 — cyan\n}\nlithology_order = [\"Sandstone\", \"Shale\", \"Limestone\", \"Siltstone\", \"Conglomerate\"]\n\n# Generate lithology pattern overlay data (FGDC/USGS-style textures)\npattern_segments = []  # For line-based patterns (shale dashes, limestone bricks)\npattern_points = []  # For dot-based patterns (sandstone stipple, conglomerate circles)\n\nfor _, row in layers.iterrows():\n    top, bottom, lith = row[\"top\"], row[\"bottom\"], row[\"lithology\"]\n    thickness = bottom - top\n    margin = 0.03\n\n    if lith == \"Shale\":\n        # Horizontal dashes - denser pattern\n        spacing = 2.5\n        n_lines = max(1, int(thickness / spacing))\n        for i in range(n_lines):\n            y = top + (i + 0.5) * thickness / n_lines\n            for x_start in [0.04, 0.20, 0.36, 0.52, 0.68, 0.84]:\n                pattern_segments.append({\"x\": x_start, \"y\": y, \"xend\": x_start + 0.12, \"yend\": y})\n\n    elif lith == \"Limestone\":\n        # Brick pattern: horizontal lines + offset vertical lines - denser\n        spacing = 3.5\n        n_rows = max(1, int(thickness / spacing))\n        for i in range(n_rows + 1):\n            y = top + margin + i * (thickness - 2 * margin) / max(n_rows, 1)\n            if top + margin <= y <= bottom - margin:\n                pattern_segments.append({\"x\": 0.02, \"y\": y, \"xend\": 0.98, \"yend\": y})\n        for i in range(n_rows):\n            y_top = top + margin + i * (thickness - 2 * margin) / max(n_rows, 1)\n            y_bot = top + margin + (i + 1) * (thickness - 2 * margin) / max(n_rows, 1)\n            offset = 0.2 if i % 2 == 0 else 0.0\n            for vx in [0.2 + offset, 0.4 + offset, 0.6 + offset, 0.8 + offset]:\n                if 0.02 < vx < 0.98:\n                    pattern_segments.append({\"x\": vx, \"y\": y_top, \"xend\": vx, \"yend\": y_bot})\n\n    elif lith == \"Sandstone\":\n        # Stipple dots - denser grid\n        spacing_y = 2.8\n        spacing_x = 0.08\n        n_rows = max(1, int(thickness / spacing_y))\n        for i in range(n_rows):\n            y = top + (i + 0.5) * thickness / n_rows\n            offset = 0.04 if i % 2 == 0 else 0.0\n            x = 0.06 + offset\n            while x < 0.96:\n                pattern_points.append({\"x\": x, \"y\": y, \"shape\": \"dot\"})\n                x += spacing_x\n\n    elif lith == \"Siltstone\":\n        # Short tilted dashes - denser\n        spacing_y = 3.5\n        n_rows = max(1, int(thickness / spacing_y))\n        for i in range(n_rows):\n            y = top + (i + 0.5) * thickness / n_rows\n            offset = 0.06 if i % 2 == 0 else 0.0\n            for x in [0.08 + offset, 0.22 + offset, 0.36 + offset, 0.50 + offset, 0.64 + offset, 0.78 + offset]:\n                if x < 0.95:\n                    pattern_segments.append({\"x\": x, \"y\": y - 0.8, \"xend\": x + 0.06, \"yend\": y + 0.8})\n\n    elif lith == \"Conglomerate\":\n        # Circles (larger dots) - denser\n        spacing_y = 4.5\n        n_rows = max(1, int(thickness / spacing_y))\n        for i in range(n_rows):\n            y = top + (i + 0.5) * thickness / n_rows\n            offset = 0.08 if i % 2 == 0 else 0.0\n            for x in [0.12 + offset, 0.32 + offset, 0.52 + offset, 0.72 + offset]:\n                if x < 0.95:\n                    pattern_points.append({\"x\": x, \"y\": y, \"shape\": \"circle\"})\n\npattern_seg_df = pd.DataFrame(pattern_segments) if pattern_segments else None\npattern_dot_df = pd.DataFrame([p for p in pattern_points if p[\"shape\"] == \"dot\"]) if pattern_points else None\npattern_circle_df = pd.DataFrame([p for p in pattern_points if p[\"shape\"] == \"circle\"]) if pattern_points else None\n\n# Formation labels (right side)\nform_labels = []\nfor _, row in layers.iterrows():\n    mid_depth = (row[\"top\"] + row[\"bottom\"]) / 2\n    form_labels.append({\"x\": 1.06, \"y\": mid_depth, \"label\": row[\"formation\"]})\nform_df = pd.DataFrame(form_labels)\n\n# Age labels (left side) with bracket indicators\nage_spans = {\"Triassic\": (0, 35), \"Jurassic\": (35, 110), \"Cretaceous\": (110, 195), \"Paleogene\": (195, 260)}\nage_labels = []\nage_brackets = []\nfor age_name, (age_top, age_bottom) in age_spans.items():\n    age_labels.append({\"x\": -0.18, \"y\": (age_top + age_bottom) / 2, \"label\": age_name})\n    # Bracket lines on left\n    age_brackets.append({\"x\": -0.06, \"y\": age_top + 1, \"xend\": -0.06, \"yend\": age_bottom - 1})\n    age_brackets.append({\"x\": -0.06, \"y\": age_top + 1, \"xend\": -0.03, \"yend\": age_top + 1})\n    age_brackets.append({\"x\": -0.06, \"y\": age_bottom - 1, \"xend\": -0.03, \"yend\": age_bottom - 1})\nage_df = pd.DataFrame(age_labels)\nbracket_df = pd.DataFrame(age_brackets)\n\n# Unconformity wavy line at Jurassic/Cretaceous boundary (110m)\nwavy_x = []\nwavy_y = []\nn_waves = 20\nfor i in range(n_waves + 1):\n    xi = i / n_waves\n    yi = 110 + 1.5 * (1 if (i % 2 == 0) else -1)\n    wavy_x.append(xi)\n    wavy_y.append(yi)\nwavy_df = pd.DataFrame({\"x\": wavy_x, \"y\": wavy_y})\n\n# Plot assembly\nplot = (\n    ggplot()\n    # Layer rectangles with interactive tooltips\n    + geom_rect(\n        aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"top\", ymax=\"bottom\", fill=\"lithology\"),\n        data=layers,\n        color=INK,\n        size=1.0,\n        tooltips=layer_tooltips()\n        .format(\"@top\", \".0f\")\n        .format(\"@bottom\", \".0f\")\n        .format(\"@thickness\", \".0f\")\n        .title(\"@formation\")\n        .line(\"@lithology | @age\")\n        .line(\"Depth: @top–@bottom m\")\n        .line(\"Thickness: @thickness m\"),\n    )\n)\n\n# Add pattern overlays\nif pattern_seg_df is not None and len(pattern_seg_df) > 0:\n    plot = plot + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=pattern_seg_df,\n        color=PATTERN_INK,\n        size=0.6,\n        alpha=0.55,\n        show_legend=False,\n    )\n\nif pattern_dot_df is not None and len(pattern_dot_df) > 0:\n    plot = plot + geom_point(\n        aes(x=\"x\", y=\"y\"), data=pattern_dot_df, color=PATTERN_INK, size=1.8, alpha=0.55, shape=16, show_legend=False\n    )\n\nif pattern_circle_df is not None and len(pattern_circle_df) > 0:\n    plot = plot + geom_point(\n        aes(x=\"x\", y=\"y\"), data=pattern_circle_df, color=PATTERN_INK, size=5.0, alpha=0.6, shape=1, show_legend=False\n    )\n\n# Unconformity wavy line at 110m with label (Imprint matte-red anchor)\nplot = plot + geom_line(aes(x=\"x\", y=\"y\"), data=wavy_df, color=ACCENT, size=2.0, show_legend=False)\nunconformity_label = pd.DataFrame({\"x\": [1.06], \"y\": [110], \"label\": [\"Unconformity\"]})\nplot = plot + geom_text(\n    aes(x=\"x\", y=\"y\", label=\"label\"),\n    data=unconformity_label,\n    color=ACCENT,\n    size=6.5,\n    fontface=\"bold\",\n    hjust=0,\n    show_legend=False,\n)\n\n# Age boundary dashed lines (non-unconformity)\nnon_unconformity_boundaries = pd.DataFrame(\n    {\"x\": [-0.02, -0.02], \"y\": [35, 195], \"xend\": [1.02, 1.02], \"yend\": [35, 195]}\n)\nplot = plot + geom_segment(\n    aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n    data=non_unconformity_boundaries,\n    linetype=\"dashed\",\n    color=INK_MUTED,\n    size=0.6,\n    show_legend=False,\n)\n\n# Age brackets (left side)\nplot = plot + geom_segment(\n    aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=bracket_df, color=INK_SOFT, size=0.6, show_legend=False\n)\n\n# Formation labels (right side)\nplot = plot + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=form_df, size=6, color=INK, hjust=0)\n\n# Age labels (left side)\nplot = plot + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=age_df, size=6, color=INK_SOFT, fontface=\"italic\")\n\n# Theme flavor follows the active theme\nflavor = flavor_high_contrast_light() if THEME == \"light\" else flavor_high_contrast_dark()\n\n# Scales and theme\nplot = (\n    plot\n    + scale_fill_manual(values=lithology_colors, name=\"Lithology\", limits=lithology_order)\n    + scale_y_reverse()\n    + labs(\n        title=\"column-stratigraphic · python · letsplot · anyplot.ai\",\n        subtitle=\"Synthetic Mesozoic–Cenozoic sedimentary section · Triassic to Paleogene\",\n        y=\"Depth (m)\",\n        x=\"\",\n    )\n    + scale_x_continuous(limits=[-0.30, 1.46])\n    + flavor\n    + theme(\n        plot_title=element_text(size=16, face=\"bold\", color=INK, margin=[0, 0, 10, 0]),\n        plot_subtitle=element_text(size=11, color=INK_SOFT, face=\"italic\"),\n        axis_title_y=element_text(size=12, color=INK, margin=[0, 8, 0, 0]),\n        axis_title_x=element_blank(),\n        axis_text_y=element_text(size=10, color=INK_SOFT),\n        axis_text_x=element_blank(),\n        axis_ticks_x=element_blank(),\n        axis_line_y=element_line(size=0.8, color=INK_SOFT),\n        legend_title=element_text(size=11, face=\"bold\", color=INK),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_position=\"bottom\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor_x=element_blank(),\n        panel_grid_major_y=element_line(size=0.3, color=INK_SOFT),\n        panel_grid_minor_y=element_blank(),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_background=element_rect(color=PAGE_BG, fill=PAGE_BG),\n        plot_margin=[16, 16, 16, 16],\n    )\n    + ggsize(800, 450)\n)\n\n# Save (PNG + interactive HTML), theme-suffixed\nggsave(plot, f\"plot-{THEME}.png\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\")\n\n# Move files from lets-plot-images to current directory\nfor ext in (\"png\", \"html\"):\n    src = f\"lets-plot-images/plot-{THEME}.{ext}\"\n    if os.path.exists(src):\n        shutil.move(src, f\"plot-{THEME}.{ext}\")\nif os.path.exists(\"lets-plot-images\") and not os.listdir(\"lets-plot-images\"):\n    os.rmdir(\"lets-plot-images\")\n"}