{"spec_id":"ternary-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nternary-basic: Basic Ternary Plot\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-08-04\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Clean sys.path early to avoid importing this file as 'altair' (file naming conflict)\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nwhile _script_dir in sys.path:\n    sys.path.remove(_script_dir)\n\nimport altair as alt\n\n\n# Theme tokens (Imprint)\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\"\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\"]  # Imprint positions 1-3\n\n# Data - Soil composition samples (sand, silt, clay)\nnp.random.seed(42)\nn_points = 50\n\n# Generate random compositional data that sums to 100\nraw = np.random.dirichlet([2, 2, 2], size=n_points) * 100\nsand = raw[:, 0]\nsilt = raw[:, 1]\nclay = raw[:, 2]\n\n# Ternary to Cartesian conversion\n# In a standard ternary plot with equilateral triangle:\n# - Bottom-left vertex (0,0): 100% Sand\n# - Bottom-right vertex (1,0): 100% Silt\n# - Top vertex (0.5, sqrt(3)/2): 100% Clay\nheight = np.sqrt(3) / 2\ntotal = sand + silt + clay\nx = silt / total + 0.5 * clay / total\ny = clay / total * height\n\n# Classify each sample by its dominant component -- gives the point cloud a\n# focal data story (three texture groups radiating from their vertex) instead\n# of one undifferentiated color blob.\ndominant_idx = np.argmax(np.stack([sand, silt, clay], axis=1), axis=1)\ntexture = np.array([\"Sand-dominant\", \"Silt-dominant\", \"Clay-dominant\"])[dominant_idx]\n\ndf = pd.DataFrame(\n    {\n        \"x\": x,\n        \"y\": y,\n        \"Sand (%)\": sand.round(1),\n        \"Silt (%)\": silt.round(1),\n        \"Clay (%)\": clay.round(1),\n        \"Texture\": texture,\n    }\n)\n\n# Create triangle outline\ntriangle_vertices = pd.DataFrame({\"x\": [0, 1, 0.5, 0], \"y\": [0, 0, height, 0], \"order\": [0, 1, 2, 3]})\n\n# Create grid lines at 20% intervals\ngrid_lines = []\n\nfor pct in [20, 40, 60, 80]:\n    # Lines parallel to bottom edge (constant clay)\n    a1, b1, c1 = 100 - pct, 0, pct\n    a2, b2, c2 = 0, 100 - pct, pct\n    x1 = b1 / 100 + 0.5 * c1 / 100\n    y1 = c1 / 100 * height\n    x2 = b2 / 100 + 0.5 * c2 / 100\n    y2 = c2 / 100 * height\n    grid_lines.append({\"x\": x1, \"y\": y1, \"x2\": x2, \"y2\": y2})\n\n    # Lines parallel to left edge (constant silt)\n    a1, b1, c1 = 100 - pct, pct, 0\n    a2, b2, c2 = 0, pct, 100 - pct\n    x1 = b1 / 100 + 0.5 * c1 / 100\n    y1 = c1 / 100 * height\n    x2 = b2 / 100 + 0.5 * c2 / 100\n    y2 = c2 / 100 * height\n    grid_lines.append({\"x\": x1, \"y\": y1, \"x2\": x2, \"y2\": y2})\n\n    # Lines parallel to right edge (constant sand)\n    a1, b1, c1 = pct, 100 - pct, 0\n    a2, b2, c2 = pct, 0, 100 - pct\n    x1 = b1 / 100 + 0.5 * c1 / 100\n    y1 = c1 / 100 * height\n    x2 = b2 / 100 + 0.5 * c2 / 100\n    y2 = c2 / 100 * height\n    grid_lines.append({\"x\": x1, \"y\": y1, \"x2\": x2, \"y2\": y2})\n\ngrid_df = pd.DataFrame(grid_lines)\n\n# Create tick marks along each edge (exclude 0 and 100 to avoid vertex overlap)\ntick_data = []\ntick_length = 0.03\n\nfor pct in [20, 40, 60, 80]:\n    # Bottom edge ticks (sand axis) - from left (100%) to right (0%)\n    tx = pct / 100\n    tick_data.append(\n        {\"x\": tx, \"y\": 0, \"x2\": tx, \"y2\": -tick_length, \"label\": str(100 - pct), \"label_x\": tx, \"label_y\": -0.06}\n    )\n\n    # Left edge ticks (clay axis) - from bottom (0%) to top (100%)\n    cx = 0.5 * pct / 100\n    cy = pct / 100 * height\n    dx = -tick_length * np.cos(np.pi / 6)\n    dy = -tick_length * np.sin(np.pi / 6)\n    tick_data.append(\n        {\n            \"x\": cx,\n            \"y\": cy,\n            \"x2\": cx + dx,\n            \"y2\": cy + dy,\n            \"label\": str(pct),\n            \"label_x\": cx + dx * 2.5,\n            \"label_y\": cy + dy * 2.5,\n        }\n    )\n\n    # Right edge ticks (silt axis) - from bottom (0%) to top (100%)\n    sx = 1 - 0.5 * pct / 100\n    sy = pct / 100 * height\n    dx = tick_length * np.cos(np.pi / 6)\n    dy = -tick_length * np.sin(np.pi / 6)\n    tick_data.append(\n        {\n            \"x\": sx,\n            \"y\": sy,\n            \"x2\": sx + dx,\n            \"y2\": sy + dy,\n            \"label\": str(pct),\n            \"label_x\": sx + dx * 2.5,\n            \"label_y\": sy + dy * 2.5,\n        }\n    )\n\ntick_df = pd.DataFrame(tick_data)\n\n# Vertex labels\nvertex_labels = pd.DataFrame(\n    {\"x\": [0, 1, 0.5], \"y\": [-0.12, -0.12, height + 0.08], \"label\": [\"Sand (100%)\", \"Silt (100%)\", \"Clay (100%)\"]}\n)\n\n# Triangle outline\ntriangle = (\n    alt.Chart(triangle_vertices)\n    .mark_line(strokeWidth=1.5, color=INK_SOFT)\n    .encode(x=alt.X(\"x:Q\"), y=alt.Y(\"y:Q\"), order=\"order:O\")\n)\n\n# Grid lines\ngrid = (\n    alt.Chart(grid_df)\n    .mark_rule(strokeWidth=0.5, opacity=0.15, color=INK_SOFT)\n    .encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\")\n)\n\n# Tick marks\nticks = alt.Chart(tick_df).mark_rule(strokeWidth=0.75, color=INK_SOFT).encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\")\n\n# Tick labels\ntick_labels = (\n    alt.Chart(tick_df).mark_text(fontSize=10, color=INK_SOFT).encode(x=\"label_x:Q\", y=\"label_y:Q\", text=\"label:N\")\n)\n\n# Vertex labels\nvertex_text = (\n    alt.Chart(vertex_labels)\n    .mark_text(fontSize=13, fontWeight=\"bold\", color=INK)\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"label:N\")\n)\n\n# Data points - colored by dominant component, Imprint palette positions 1-3\npoints = (\n    alt.Chart(df)\n    .mark_point(filled=True, size=130, opacity=0.8)\n    .encode(\n        x=\"x:Q\",\n        y=\"y:Q\",\n        color=alt.Color(\n            \"Texture:N\",\n            scale=alt.Scale(domain=[\"Sand-dominant\", \"Silt-dominant\", \"Clay-dominant\"], range=IMPRINT_PALETTE),\n            legend=alt.Legend(\n                title=\"Dominant component\",\n                orient=\"bottom\",\n                direction=\"horizontal\",\n                titleColor=INK,\n                labelColor=INK_SOFT,\n                fillColor=ELEVATED_BG,\n                strokeColor=INK_SOFT,\n                symbolSize=90,\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"Sand (%):Q\", format=\".1f\"),\n            alt.Tooltip(\"Silt (%):Q\", format=\".1f\"),\n            alt.Tooltip(\"Clay (%):Q\", format=\".1f\"),\n            alt.Tooltip(\"Texture:N\"),\n        ],\n    )\n)\n\n# Combine all layers and hide default axes\nchart = (\n    alt.layer(grid, triangle, ticks, tick_labels, vertex_text, points)\n    .properties(\n        width=500,\n        height=460,\n        background=PAGE_BG,\n        title=alt.Title(text=\"ternary-basic · altair · anyplot.ai\", fontSize=16, color=INK),\n    )\n    .configure_axis(grid=False, domain=False, ticks=False, labels=False, title=None)\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n)\n\n# Square canvas: the triangle's natural aspect ratio (base 1.0, height sqrt(3)/2\n# plus label margins) is close to 1:1, so a square canvas wastes far less\n# horizontal space than landscape would.\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# vl-convert pads the view with title/label extents outside width/height, so the\n# saved PNG is larger than (width * scale_factor, height * scale_factor). PAD\n# (never crop) up to the exact canonical target.\nTW, TH = 2400, 2400\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}x{_h}, exceeds target {TW}x{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"}