{"spec_id":"ternary-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// ternary-basic: Basic Ternary Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.1\n// Quality: 92/100 | Created: 2026-08-04\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Soil texture samples (sand / silt / clay %, each triplet sums to 100) drawn\n// from four notional field sites so the cloud shows realistic clustering\n// rather than a uniform smear across the simplex.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction gaussian() {\n  const u1 = Math.max(rand(), 1e-6);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst SITES = [\n  { sand: 65, silt: 25, clay: 10 },\n  { sand: 40, silt: 40, clay: 20 },\n  { sand: 15, silt: 55, clay: 30 },\n  { sand: 30, silt: 30, clay: 40 },\n];\n\nconst samples = [];\nSITES.forEach((site) => {\n  for (let i = 0; i < 21; i++) {\n    const sand = Math.max(2, site.sand + gaussian() * 7);\n    const silt = Math.max(2, site.silt + gaussian() * 7);\n    const clay = Math.max(2, site.clay + gaussian() * 7);\n    const total = sand + silt + clay;\n    samples.push({\n      sand: (sand / total) * 100,\n      silt: (silt / total) * 100,\n      clay: (clay / total) * 100,\n    });\n  }\n});\n\n// --- Ternary <-> Cartesian projection ---------------------------------------\n// Equilateral triangle: sand at (0,0), silt at (1,0), clay at (0.5, sqrt(3)/2).\nconst SQRT3_2 = Math.sqrt(3) / 2;\nfunction ternaryToXY(sandFrac, siltFrac, clayFrac) {\n  return { x: siltFrac + 0.5 * clayFrac, y: clayFrac * SQRT3_2 };\n}\nconst VERTEX_SAND = ternaryToXY(1, 0, 0);\nconst VERTEX_SILT = ternaryToXY(0, 1, 0);\nconst VERTEX_CLAY = ternaryToXY(0, 0, 1);\nconst GRID_STEPS = [0.2, 0.4, 0.6, 0.8];\n\nfunction drawLine(ctx, toPx, toPy, p1, p2) {\n  ctx.beginPath();\n  ctx.moveTo(toPx(p1), toPy(p1));\n  ctx.lineTo(toPx(p2), toPy(p2));\n  ctx.stroke();\n}\n\n// --- Custom plugin: triangle frame, 20% grid, vertex + tick labels ---------\nconst ternaryFramePlugin = {\n  id: \"ternaryFrame\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const toPx = (p) => scales.x.getPixelForValue(p.x);\n    const toPy = (p) => scales.y.getPixelForValue(p.y);\n\n    ctx.save();\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1.5;\n    GRID_STEPS.forEach((v) => {\n      drawLine(ctx, toPx, toPy, ternaryToXY(v, 1 - v, 0), ternaryToXY(v, 0, 1 - v));\n      drawLine(ctx, toPx, toPy, ternaryToXY(1 - v, v, 0), ternaryToXY(0, v, 1 - v));\n      drawLine(ctx, toPx, toPy, ternaryToXY(1 - v, 0, v), ternaryToXY(0, 1 - v, v));\n    });\n\n    ctx.strokeStyle = t.ink;\n    ctx.lineWidth = 3;\n    ctx.beginPath();\n    ctx.moveTo(toPx(VERTEX_SAND), toPy(VERTEX_SAND));\n    ctx.lineTo(toPx(VERTEX_SILT), toPy(VERTEX_SILT));\n    ctx.lineTo(toPx(VERTEX_CLAY), toPy(VERTEX_CLAY));\n    ctx.closePath();\n    ctx.stroke();\n    ctx.restore();\n  },\n  afterDraw(chart) {\n    const { ctx, scales } = chart;\n    const toPx = (p) => scales.x.getPixelForValue(p.x);\n    const toPy = (p) => scales.y.getPixelForValue(p.y);\n\n    ctx.save();\n    ctx.font = \"13px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    GRID_STEPS.forEach((v) => {\n      const pct = String(Math.round(v * 100));\n\n      ctx.textAlign = \"right\";\n      ctx.textBaseline = \"middle\";\n      const sandPt = ternaryToXY(v, 0, 1 - v);\n      ctx.fillText(pct, toPx(sandPt) - 10, toPy(sandPt));\n\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"top\";\n      const siltPt = ternaryToXY(1 - v, v, 0);\n      ctx.fillText(pct, toPx(siltPt), toPy(siltPt) + 10);\n\n      ctx.textAlign = \"left\";\n      ctx.textBaseline = \"middle\";\n      const clayPt = ternaryToXY(0, 1 - v, v);\n      ctx.fillText(pct, toPx(clayPt) + 10, toPy(clayPt));\n    });\n\n    ctx.font = \"600 20px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(\"Sand\", toPx(VERTEX_SAND), toPy(VERTEX_SAND) + 26);\n    ctx.fillText(\"Silt\", toPx(VERTEX_SILT), toPy(VERTEX_SILT) + 26);\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"Clay\", toPx(VERTEX_CLAY), toPy(VERTEX_CLAY) - 14);\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Soil sample\",\n        data: samples.map((s) => ternaryToXY(s.sand / 100, s.silt / 100, s.clay / 100)),\n        backgroundColor: t.palette[0],\n        borderColor: t.pageBg,\n        borderWidth: 1.5,\n        pointRadius: 8,\n        pointHoverRadius: 8,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 20 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"ternary-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: { display: false },\n      tooltip: {\n        enabled: true,\n        callbacks: {\n          title: () => \"\",\n          label: (ctx) => {\n            const s = samples[ctx.dataIndex];\n            return `Sand ${s.sand.toFixed(1)}% · Silt ${s.silt.toFixed(1)}% · Clay ${s.clay.toFixed(1)}%`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: { type: \"linear\", min: -0.16, max: 1.16, display: false },\n      y: { type: \"linear\", min: -0.3, max: 1.02, display: false },\n    },\n  },\n  plugins: [ternaryFramePlugin],\n});\n"}