{"spec_id":"ternary-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// ternary-basic: Basic Ternary Plot\n// Library: d3 7.9.0 | JavaScript 22.23.1\n// Quality: 92/100 | Updated: 2026-08-05\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 210, right: 110, bottom: 110, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Soil samples: clay / sand / silt proportions (%), each triplet sums to 100.\nfunction lcg(seed) {\n  let s = seed;\n  return () => {\n    s = (s * 1103515245 + 12345) & 0x7fffffff;\n    return s / 0x7fffffff;\n  };\n}\nconst rand = lcg(42123);\nconst samples = [];\nfor (let i = 0; i < 28; i++) {\n  const wClay = 0.02 + rand() * 0.98;\n  const wSand = 0.02 + rand() * 0.98;\n  const wSilt = 0.02 + rand() * 0.98;\n  const total = wClay + wSand + wSilt;\n  samples.push({\n    clay: (wClay / total) * 100,\n    sand: (wSand / total) * 100,\n    silt: (wSilt / total) * 100,\n  });\n}\n\n// --- Triangle geometry --------------------------------------------------------\n// Apex = clay, bottom-left = sand, bottom-right = silt (classic soil-texture layout).\nconst side = iw;\nconst triHeight = (side * Math.sqrt(3)) / 2;\nconst apex = { x: margin.left + side / 2, y: margin.top };\nconst left = { x: margin.left, y: margin.top + triHeight };\nconst right = { x: margin.left + side, y: margin.top + triHeight };\nconst centroid = {\n  x: (apex.x + left.x + right.x) / 3,\n  y: (apex.y + left.y + right.y) / 3,\n};\n\n// Barycentric (a=clay, b=sand, c=silt, each 0-1, a+b+c=1) -> pixel coordinates.\nfunction toXY(a, b, c) {\n  return {\n    x: a * apex.x + b * left.x + c * right.x,\n    y: a * apex.y + b * left.y + c * right.y,\n  };\n}\nfunction outward(p, dist) {\n  const dx = p.x - centroid.x;\n  const dy = p.y - centroid.y;\n  const len = Math.hypot(dx, dy) || 1;\n  return { x: p.x + (dx / len) * dist, y: p.y + (dy / len) * dist };\n}\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Grid lines (20% intervals, three families parallel to each edge) --------\nconst levels = [0.2, 0.4, 0.6, 0.8];\nconst gridSegments = [];\nfor (const lv of levels) {\n  gridSegments.push([toXY(lv, 1 - lv, 0), toXY(lv, 0, 1 - lv)]); // parallel to base\n  gridSegments.push([toXY(1 - lv, lv, 0), toXY(0, lv, 1 - lv)]); // parallel to apex-right\n  gridSegments.push([toXY(1 - lv, 0, lv), toXY(0, 1 - lv, lv)]); // parallel to apex-left\n}\nsvg\n  .selectAll(\".grid-line\")\n  .data(gridSegments)\n  .join(\"line\")\n  .attr(\"class\", \"grid-line\")\n  .attr(\"x1\", (d) => d[0].x)\n  .attr(\"y1\", (d) => d[0].y)\n  .attr(\"x2\", (d) => d[1].x)\n  .attr(\"y2\", (d) => d[1].y)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Triangle border ------------------------------------------------------------\nsvg\n  .append(\"path\")\n  .attr(\"d\", `M${apex.x},${apex.y} L${left.x},${left.y} L${right.x},${right.y} Z`)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 2.5);\n\n// --- Tick marks + labels (one axis per edge, 0-100 by 20%) -------------------\nconst tickLevels = [0, 0.2, 0.4, 0.6, 0.8, 1];\nconst ticks = [];\nfor (const lv of tickLevels) {\n  ticks.push(toXY(lv, 1 - lv, 0)); // clay axis, edge apex-left\n  ticks.push(toXY(1 - lv, 0, lv)); // silt axis, edge apex-right\n  ticks.push(toXY(0, lv, 1 - lv)); // sand axis, edge base\n}\nfor (const p of ticks) {\n  const tickEnd = outward(p, 14);\n  const labelPos = outward(p, 34);\n  svg\n    .append(\"line\")\n    .attr(\"x1\", p.x)\n    .attr(\"y1\", p.y)\n    .attr(\"x2\", tickEnd.x)\n    .attr(\"y2\", tickEnd.y)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.5);\n}\n// Skip lv=0: each axis's \"0\" tick lands exactly on the vertex where a\n// different axis's \"100\" tick already sits (e.g. the silt axis's 0 coincides\n// with the clay axis's 100 at the apex) - drawing both stacks two labels on\n// one pixel. The \"100\" from the owning axis is kept and is unambiguous.\nconst tickLabels = [];\nfor (const lv of tickLevels) {\n  if (lv === 0) continue;\n  tickLabels.push({ p: outward(toXY(lv, 1 - lv, 0), 34), text: Math.round(lv * 100) });\n  tickLabels.push({ p: outward(toXY(1 - lv, 0, lv), 34), text: Math.round(lv * 100) });\n  tickLabels.push({ p: outward(toXY(0, lv, 1 - lv), 34), text: Math.round(lv * 100) });\n}\nsvg\n  .selectAll(\".tick-label\")\n  .data(tickLabels)\n  .join(\"text\")\n  .attr(\"class\", \"tick-label\")\n  .attr(\"x\", (d) => d.p.x)\n  .attr(\"y\", (d) => d.p.y)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-family\", \"sans-serif\")\n  .text((d) => d.text);\n\n// --- Vertex labels --------------------------------------------------------------\n// The apex label stays centered (ample horizontal room either side), but the\n// left/right vertex labels grow inward from their vertex (\"start\"/\"end\"\n// anchors) instead of centering past the canvas edge - \"Sand (%)\" is wide\n// enough that a centered anchor pushed its leading \"S\" off the left border.\nconst vertexLabels = [\n  { p: outward(apex, 78), text: \"Clay (%)\", anchor: \"middle\" },\n  { p: outward(left, 78), text: \"Sand (%)\", anchor: \"start\" },\n  { p: outward(right, 78), text: \"Silt (%)\", anchor: \"end\" },\n];\nsvg\n  .selectAll(\".vertex-label\")\n  .data(vertexLabels)\n  .join(\"text\")\n  .attr(\"class\", \"vertex-label\")\n  .attr(\"x\", (d) => d.p.x)\n  .attr(\"y\", (d) => d.p.y)\n  .attr(\"text-anchor\", (d) => d.anchor)\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"20px\")\n  .style(\"font-weight\", \"600\")\n  .style(\"font-family\", \"sans-serif\")\n  .text((d) => d.text);\n\n// --- Data points --------------------------------------------------------------\n// Marker radius scales with compositional purity (the dominant component's\n// share) so the rarer near-vertex, high-purity samples read as a focal point\n// against the more common balanced mixtures clustered mid-triangle.\nconst points = samples.map((s) => {\n  const purity = Math.max(s.clay, s.sand, s.silt) / 100;\n  return { ...toXY(s.clay / 100, s.sand / 100, s.silt / 100), r: 7 + purity * 7 };\n});\nsvg\n  .selectAll(\".sample\")\n  .data(points)\n  .join(\"circle\")\n  .attr(\"class\", \"sample\")\n  .attr(\"cx\", (d) => d.x)\n  .attr(\"cy\", (d) => d.y)\n  .attr(\"r\", (d) => d.r)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.85)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Title ------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .style(\"font-family\", \"sans-serif\")\n  .text(\"ternary-basic · javascript · d3 · anyplot.ai\");\n"}