{"spec_id":"ternary-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// ternary-basic: Basic Ternary Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.1\n// Quality: 90/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 (USDA-style sand / silt / clay proportions, in %).\n// Tiny LCG so the jittered clusters are reproducible without a network RNG.\nfunction makeLcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nconst random = makeLcg(42);\n\nfunction soilCluster(meanSand, meanSilt, meanClay, count, spread) {\n  const samples = [];\n  for (let i = 0; i < count; i++) {\n    const sand = Math.max(2, meanSand + (random() - 0.5) * spread);\n    const silt = Math.max(2, meanSilt + (random() - 0.5) * spread);\n    const clay = Math.max(2, meanClay + (random() - 0.5) * spread);\n    const total = sand + silt + clay;\n    samples.push({ sand: (sand / total) * 100, silt: (silt / total) * 100, clay: (clay / total) * 100 });\n  }\n  return samples;\n}\n\nconst sandySoils = soilCluster(72, 18, 10, 16, 24);\nconst loamySoils = soilCluster(38, 38, 24, 16, 20);\nconst clayeySoils = soilCluster(16, 24, 60, 16, 24);\n\n// --- Barycentric -> Cartesian transform --------------------------------------\n// Equilateral triangle: Sand at the apex, Silt at bottom-left, Clay at bottom-right.\n// x = 0.5 * sand_frac + clay_frac, y = sand_frac * TRI_H  (weighted average of the vertices)\nconst TRI_H = Math.sqrt(3) / 2;\n\n// --- Chart -------------------------------------------------------------------\nconst PLOT_MARGIN_LR = 100;\nconst PLOT_MARGIN_TOP = 100;\nconst PLOT_MARGIN_BOTTOM = 100; // marginTop + marginBottom == 2 * marginLR -> square plot area\n\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    marginLeft: PLOT_MARGIN_LR,\n    marginRight: PLOT_MARGIN_LR,\n    marginTop: PLOT_MARGIN_TOP,\n    marginBottom: PLOT_MARGIN_BOTTOM,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      render: function () {\n        const chart = this;\n        if (chart.ternaryFrame) chart.ternaryFrame.destroy();\n        const group = chart.renderer.g(\"ternary-frame\").add();\n        chart.ternaryFrame = group;\n\n        const xAxis = chart.xAxis[0];\n        const yAxis = chart.yAxis[0];\n        const toPx = (x, y) => [xAxis.toPixels(x), yAxis.toPixels(y)];\n\n        const apex = toPx(0.5, TRI_H); // Sand\n        const bottomLeft = toPx(0, 0); // Silt\n        const bottomRight = toPx(1, 0); // Clay\n\n        // Outer triangle\n        chart.renderer\n          .path([\"M\", apex[0], apex[1], \"L\", bottomRight[0], bottomRight[1], \"L\", bottomLeft[0], bottomLeft[1], \"Z\"])\n          .attr({ stroke: t.inkSoft, \"stroke-width\": 2, fill: \"none\" })\n          .add(group);\n\n        // Internal gridlines at 20% composition steps\n        [0.2, 0.4, 0.6, 0.8].forEach((k) => {\n          const sandLine = [toPx(0.5 * k, k * TRI_H), toPx(1 - 0.5 * k, k * TRI_H)];\n          const siltLine = [toPx(1 - k, 0), toPx(0.5 * (1 - k), (1 - k) * TRI_H)];\n          const clayLine = [toPx(k, 0), toPx(0.5 + 0.5 * k, (1 - k) * TRI_H)];\n          [sandLine, siltLine, clayLine].forEach(([from, to]) => {\n            chart.renderer\n              .path([\"M\", from[0], from[1], \"L\", to[0], to[1]])\n              .attr({ stroke: t.grid, \"stroke-width\": 1 })\n              .add(group);\n          });\n        });\n\n        // Vertex labels\n        [\n          { text: \"Sand\", x: 0.5, y: TRI_H, nx: 0, ny: -0.09, anchor: \"middle\" },\n          { text: \"Silt\", x: 0, y: 0, nx: -0.07, ny: 0.06, anchor: \"end\" },\n          { text: \"Clay\", x: 1, y: 0, nx: 0.07, ny: 0.06, anchor: \"start\" },\n        ].forEach((v) => {\n          const [px, py] = toPx(v.x + v.nx, v.y + v.ny);\n          chart.renderer\n            .text(v.text, px, py)\n            .attr({ align: v.anchor })\n            .css({ color: t.ink, fontSize: \"18px\", fontWeight: \"600\" })\n            .add(group);\n        });\n\n        // Edge tick labels (% composition, every 20%) with short outward tick marks\n        const edgeTicks = [];\n        [0.2, 0.4, 0.6, 0.8].forEach((k) => {\n          edgeTicks.push({ x: k, y: 0, nx: 0, ny: -0.055, anchor: \"middle\", label: Math.round(100 * k) }); // Clay, bottom edge\n          edgeTicks.push({\n            x: 0.5 * (1 - k),\n            y: (1 - k) * TRI_H,\n            nx: -0.055,\n            ny: 0.03,\n            anchor: \"end\",\n            label: Math.round(100 * k),\n          }); // Silt, left edge\n          edgeTicks.push({\n            x: 1 - 0.5 * k,\n            y: k * TRI_H,\n            nx: 0.055,\n            ny: 0.03,\n            anchor: \"start\",\n            label: Math.round(100 * k),\n          }); // Sand, right edge\n        });\n        edgeTicks.forEach((tick) => {\n          const [tx1, ty1] = toPx(tick.x, tick.y);\n          const [tx2, ty2] = toPx(tick.x + tick.nx * 0.4, tick.y + tick.ny * 0.4);\n          chart.renderer\n            .path([\"M\", tx1, ty1, \"L\", tx2, ty2])\n            .attr({ stroke: t.inkSoft, \"stroke-width\": 1 })\n            .add(group);\n          const [lx, ly] = toPx(tick.x + tick.nx, tick.y + tick.ny);\n          chart.renderer\n            .text(String(tick.label), lx, ly)\n            .attr({ align: tick.anchor })\n            .css({ color: t.inkSoft, fontSize: \"12px\" })\n            .add(group);\n        });\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"ternary-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    margin: 30,\n  },\n  xAxis: {\n    min: -0.1,\n    max: 1.1,\n    startOnTick: false,\n    endOnTick: false,\n    gridLineWidth: 0,\n    lineWidth: 0,\n    tickLength: 0,\n    labels: { enabled: false },\n    title: { text: null },\n  },\n  yAxis: {\n    // Same 1.2 total span as xAxis (keeps the triangle undistorted), but shifted\n    // down so the apex sits close to the top instead of leaving a blank band.\n    min: -0.25,\n    max: 0.95,\n    startOnTick: false,\n    endOnTick: false,\n    gridLineWidth: 0,\n    lineWidth: 0,\n    tickLength: 0,\n    labels: { enabled: false },\n    title: { text: null },\n  },\n  legend: {\n    align: \"center\",\n    verticalAlign: \"bottom\",\n    layout: \"horizontal\",\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: {\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink, fontSize: \"13px\" },\n    formatter: function () {\n      return (\n        `<b>${this.series.name}</b><br/>` +\n        `Sand: ${this.point.sand.toFixed(1)}%<br/>` +\n        `Silt: ${this.point.silt.toFixed(1)}%<br/>` +\n        `Clay: ${this.point.clay.toFixed(1)}%`\n      );\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      marker: { radius: 5, lineWidth: 1, lineColor: t.pageBg, symbol: \"circle\" },\n      states: { hover: { enabled: true } },\n    },\n  },\n  // x = 0.5 * sand_frac + clay_frac, y = sand_frac * TRI_H (barycentric -> Cartesian)\n  series: [\n    {\n      name: \"Sandy soils\",\n      color: t.palette[0],\n      data: sandySoils.map((s) => ({ x: 0.5 * (s.sand / 100) + s.clay / 100, y: (s.sand / 100) * TRI_H, ...s })),\n    },\n    {\n      name: \"Loamy soils\",\n      color: t.palette[1],\n      data: loamySoils.map((s) => ({ x: 0.5 * (s.sand / 100) + s.clay / 100, y: (s.sand / 100) * TRI_H, ...s })),\n    },\n    {\n      name: \"Clayey soils\",\n      color: t.palette[2],\n      data: clayeySoils.map((s) => ({ x: 0.5 * (s.sand / 100) + s.clay / 100, y: (s.sand / 100) * TRI_H, ...s })),\n    },\n  ],\n});\n"}