{"spec_id":"ternary-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nternary-basic: Basic Ternary Plot\nLibrary: bokeh 3.9.2 | Python 3.13.14\nQuality: 92/100 | Created: 2026-08-04\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\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\"\nBRAND = \"#009E73\"  # Imprint palette position 1\n\n# Data - Soil composition samples (Sand, Silt, Clay)\nnp.random.seed(42)\nn_points = 50\n\n# Generate random compositions that sum to 100%\nraw = np.random.dirichlet(alpha=[2, 2, 2], size=n_points) * 100\nsand = raw[:, 0]\nsilt = raw[:, 1]\nclay = raw[:, 2]\n\n# Compositional \"purity\" (distance from the balanced 1/3-1/3-1/3 centroid) —\n# drives the size/opacity emphasis below so near-pure samples pop forward\n# and balanced (loam-like) samples recede, surfacing the clustering pattern\n# instead of a flat, uniform scatter.\ndominance = raw.max(axis=1) / 100\npurity = np.clip((dominance - 1 / 3) / (1 - 1 / 3), 0, 1)\nmarker_size = 12 + purity * 16\nmarker_alpha = 0.55 + purity * 0.35\n\n# Most extreme sample — the single highest-purity point becomes the plot's\n# explicit focal callout, giving viewers a concrete entry point beyond the\n# implicit size/alpha gradient.\nidx_extreme = int(np.argmax(purity))\nextreme_component = [\"Sand\", \"Silt\", \"Clay\"][int(np.argmax(raw[idx_extreme]))]\nextreme_pct = raw[idx_extreme].max()\n\n\n# Convert ternary coordinates to Cartesian (equilateral triangle)\ndef ternary_to_cartesian(a, b, c):\n    \"\"\"Convert ternary coordinates (a, b, c) to Cartesian (x, y).\n    Triangle vertices: bottom-left (1,0,0), bottom-right (0,1,0), top (0,0,1)\n    \"\"\"\n    total = a + b + c\n    b_norm = b / total\n    c_norm = c / total\n    x = 0.5 * (2 * b_norm + c_norm)\n    y = (np.sqrt(3) / 2) * c_norm\n    return x, y\n\n\n# Convert data points\nx_data, y_data = ternary_to_cartesian(sand, silt, clay)\n\n# Triangle vertices (in Cartesian coordinates)\ntri_x = [0, 1, 0.5, 0]\ntri_y = [0, 0, np.sqrt(3) / 2, 0]\n\n# Create figure. Square canvas: a ternary plot has no preferred horizontal\n# axis. Equal-span ranges below (x: -0.12..1.12, y: -0.15..1.09, both span\n# 1.24) paired with symmetric min_border on a square figure keep the pixel\n# scale uniform in x and y, so the triangle renders truly equilateral.\n# HoverTool works as an active inspector even with toolbar_location=None (it\n# only shows a toolbar *button*, not the hover behavior), so the static PNG\n# render is unaffected while the HTML artifact gains sand/silt/clay tooltips.\nhover = HoverTool(tooltips=[(\"Sand\", \"@sand{0.0}%\"), (\"Silt\", \"@silt{0.0}%\"), (\"Clay\", \"@clay{0.0}%\")])\n\np = figure(\n    width=2400,\n    height=2400,\n    title=\"Soil Composition · ternary-basic · python · bokeh · anyplot.ai\",\n    x_range=(-0.12, 1.12),\n    y_range=(-0.15, 1.09),\n    tools=[hover],\n    toolbar_location=None,  # IMPORTANT: default toolbar adds ~30-50px, shrinking the saved PNG\n    min_border_left=60,\n    min_border_right=60,\n    min_border_top=60,\n    min_border_bottom=60,\n)\n\n# Theme styling\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Remove default axes\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\n\n# Draw triangle outline\np.line(tri_x, tri_y, line_width=3, color=INK_SOFT)\n\n# Draw grid lines at 20% intervals\ngrid_color = INK_SOFT\ngrid_alpha = 0.15\ngrid_width = 1.5\n\nfor pct in [20, 40, 60, 80]:\n    frac = pct / 100\n\n    # Lines parallel to each side\n    a1, b1, c1 = frac, 1 - frac, 0\n    a2, b2, c2 = frac, 0, 1 - frac\n    x1, y1 = ternary_to_cartesian(a1, b1, c1)\n    x2, y2 = ternary_to_cartesian(a2, b2, c2)\n    p.line([x1, x2], [y1, y2], line_width=grid_width, color=grid_color, alpha=grid_alpha)\n\n    a1, b1, c1 = 1 - frac, frac, 0\n    a2, b2, c2 = 0, frac, 1 - frac\n    x1, y1 = ternary_to_cartesian(a1, b1, c1)\n    x2, y2 = ternary_to_cartesian(a2, b2, c2)\n    p.line([x1, x2], [y1, y2], line_width=grid_width, color=grid_color, alpha=grid_alpha)\n\n    a1, b1, c1 = 1 - frac, 0, frac\n    a2, b2, c2 = 0, 1 - frac, frac\n    x1, y1 = ternary_to_cartesian(a1, b1, c1)\n    x2, y2 = ternary_to_cartesian(a2, b2, c2)\n    p.line([x1, x2], [y1, y2], line_width=grid_width, color=grid_color, alpha=grid_alpha)\n\n# Add tick labels along each edge\ntick_font_size = \"34pt\"\ntick_offset = 0.045\n\nfor pct in [0, 20, 40, 60, 80, 100]:\n    frac = pct / 100\n\n    x_tick, y_tick = ternary_to_cartesian(1 - frac, frac, 0)\n    label = Label(\n        x=x_tick,\n        y=y_tick - tick_offset,\n        text=f\"{int(100 - pct)}\",\n        text_font_size=tick_font_size,\n        text_color=INK_SOFT,\n        text_align=\"center\",\n        text_baseline=\"top\",\n    )\n    p.add_layout(label)\n\n    x_tick, y_tick = ternary_to_cartesian(0, 1 - frac, frac)\n    label = Label(\n        x=x_tick + tick_offset * 0.8,\n        y=y_tick + tick_offset * 0.5,\n        text=f\"{int(100 - pct)}\",\n        text_font_size=tick_font_size,\n        text_color=INK_SOFT,\n        text_align=\"left\",\n        text_baseline=\"middle\",\n    )\n    p.add_layout(label)\n\n    x_tick, y_tick = ternary_to_cartesian(frac, 0, 1 - frac)\n    label = Label(\n        x=x_tick - tick_offset * 0.8,\n        y=y_tick + tick_offset * 0.5,\n        text=f\"{int(100 - pct)}\",\n        text_font_size=tick_font_size,\n        text_color=INK_SOFT,\n        text_align=\"right\",\n        text_baseline=\"middle\",\n    )\n    p.add_layout(label)\n\n# Add vertex labels\nlabel_font_size = \"42pt\"\nlabel_offset = 0.08\n\nsand_label = Label(\n    x=0 - label_offset,\n    y=0 - label_offset,\n    text=\"Sand\",\n    text_font_size=label_font_size,\n    text_font_style=\"bold\",\n    text_color=INK,\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\np.add_layout(sand_label)\n\nsilt_label = Label(\n    x=1 + label_offset,\n    y=0 - label_offset,\n    text=\"Silt\",\n    text_font_size=label_font_size,\n    text_font_style=\"bold\",\n    text_color=INK,\n    text_align=\"center\",\n    text_baseline=\"top\",\n)\np.add_layout(silt_label)\n\nclay_label = Label(\n    x=0.5,\n    y=np.sqrt(3) / 2 + label_offset,\n    text=\"Clay\",\n    text_font_size=label_font_size,\n    text_font_style=\"bold\",\n    text_color=INK,\n    text_align=\"center\",\n    text_baseline=\"bottom\",\n)\np.add_layout(clay_label)\n\n# Plot data points — size and opacity scale with compositional purity so\n# near-pure (single-component-dominant) samples read as prominent, distinct\n# markers while balanced/loam-like samples recede into the cluster.\nsource = ColumnDataSource(\n    data={\n        \"x\": x_data,\n        \"y\": y_data,\n        \"sand\": sand,\n        \"silt\": silt,\n        \"clay\": clay,\n        \"size\": marker_size,\n        \"alpha\": marker_alpha,\n    }\n)\n\np.scatter(x=\"x\", y=\"y\", source=source, size=\"size\", color=BRAND, fill_alpha=\"alpha\", line_color=PAGE_BG, line_width=1.5)\n\n# Ring the single most extreme (highest-purity) sample and annotate it —\n# an explicit focal callout so the viewer has a concrete entry point into\n# the composition space, not just the implicit size/alpha gradient.\np.scatter(\n    x=[x_data[idx_extreme]],\n    y=[y_data[idx_extreme]],\n    size=marker_size[idx_extreme] + 14,\n    fill_color=None,\n    line_color=INK,\n    line_width=2.5,\n)\nextreme_label = Label(\n    x=x_data[idx_extreme],\n    y=y_data[idx_extreme] + 0.05,\n    text=f\"{extreme_pct:.0f}% {extreme_component}\",\n    text_font_size=\"30pt\",\n    text_font_style=\"bold\",\n    text_color=INK,\n    text_align=\"center\",\n    text_baseline=\"bottom\",\n)\np.add_layout(extreme_label)\n\n# Style title\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.align = \"center\"\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome\nW, H = 2400, 2400\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\n# IMPORTANT: headless Chrome's --window-size sets the OUTER window, which\n# still reserves a phantom title-bar height even headless; pin the viewport\n# exactly via CDP so the screenshot matches W x H.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}