{"spec_id":"ternary-density","library":"d3","language":"javascript","code":"// anyplot.ai\n// ternary-density: Ternary Density Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- RNG (fixed-seed LCG — the browser has no seeded Math.random) -----------\nfunction makeRng(seed) {\n  let state = seed;\n  return function () {\n    state = (state * 16807) % 2147483647;\n    return (state - 1) / 2147483646;\n  };\n}\nconst rng = makeRng(42);\n\n// --- Data: synthetic soil-texture samples (sand / silt / clay), three modes -\nfunction sampleCluster(center, spread, count) {\n  const points = [];\n  for (let i = 0; i < count; i++) {\n    const jitter = () => spread * (rng() + rng() + rng() - 1.5);\n    const sand = Math.max(center[0] + jitter(), 1);\n    const silt = Math.max(center[1] + jitter(), 1);\n    const clay = Math.max(center[2] + jitter(), 1);\n    const total = sand + silt + clay;\n    points.push({ sand: (100 * sand) / total, silt: (100 * silt) / total, clay: (100 * clay) / total });\n  }\n  return points;\n}\n\nconst samples = [\n  ...sampleCluster([70, 20, 10], 14, 500), // sandy loam\n  ...sampleCluster([15, 55, 30], 12, 450), // silty clay loam\n  ...sampleCluster([30, 25, 45], 13, 350), // clay loam\n];\n\n// --- Layout -------------------------------------------------------------------\nconst margin = { top: 110, right: 170, bottom: 60, left: 60 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst side = Math.min(iw, (ih * 2) / Math.sqrt(3));\nconst triHeight = (side * Math.sqrt(3)) / 2;\nconst offsetX = margin.left + (iw - side) / 2;\nconst offsetY = margin.top + (ih - triHeight) / 2;\n\nconst apex = [side / 2, 0]; // sand = 100%\nconst bottomLeft = [0, triHeight]; // silt = 100%\nconst bottomRight = [side, triHeight]; // clay = 100%\n\nfunction project(sand, silt, clay) {\n  const fa = sand / 100;\n  const fb = silt / 100;\n  const fc = clay / 100;\n  return [\n    fa * apex[0] + fb * bottomLeft[0] + fc * bottomRight[0],\n    fa * apex[1] + fb * bottomLeft[1] + fc * bottomRight[1],\n  ];\n}\n\nconst points = samples.map((d) => {\n  const [px, py] = project(d.sand, d.silt, d.clay);\n  return { ...d, px, py };\n});\n\n// --- SVG mount ------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${offsetX},${offsetY})`);\n\nconst trianglePoints = [apex, bottomLeft, bottomRight].map((p) => p.join(\",\")).join(\" \");\nsvg.append(\"clipPath\").attr(\"id\", \"tri-clip\").append(\"polygon\").attr(\"points\", trianglePoints);\n\nfunction drawGrid(target, strokeOpacity) {\n  for (const lvl of [20, 40, 60, 80]) {\n    const lines = [\n      [project(lvl, 100 - lvl, 0), project(lvl, 0, 100 - lvl)], // constant sand\n      [project(100 - lvl, lvl, 0), project(0, lvl, 100 - lvl)], // constant silt\n      [project(100 - lvl, 0, lvl), project(0, 100 - lvl, lvl)], // constant clay\n    ];\n    for (const [[x1, y1], [x2, y2]] of lines) {\n      target.append(\"line\").attr(\"x1\", x1).attr(\"y1\", y1).attr(\"x2\", x2).attr(\"y2\", y2)\n        .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1.5).attr(\"stroke-opacity\", strokeOpacity);\n    }\n  }\n}\n\n// Base grid pass for the empty triangle area outside the density blobs.\ndrawGrid(g.append(\"g\"), 1);\n\n// --- Kernel density estimate over the ternary plane, clipped to the triangle\nconst densityContours = d3.contourDensity()\n  .x((d) => d.px).y((d) => d.py)\n  .size([side, triHeight])\n  .bandwidth(22)\n  .thresholds(14)(points);\n\nconst maxDensity = d3.max(densityContours, (d) => d.value);\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain([0, maxDensity]);\nconst geoPath = d3.geoPath();\n\nconst contourG = g.append(\"g\").attr(\"clip-path\", \"url(#tri-clip)\");\n\ncontourG.selectAll(\"path.band\").data(densityContours).join(\"path\")\n  .attr(\"class\", \"band\")\n  .attr(\"d\", geoPath)\n  .attr(\"fill\", (d) => colorScale(d.value))\n  .attr(\"fill-opacity\", 0.7)\n  .attr(\"stroke\", \"none\");\n\n// Key density levels get a subtle outline so bands read individually.\nconst keyLevels = densityContours.filter((_, i) => i % 3 === 2);\ncontourG.selectAll(\"path.level\").data(keyLevels).join(\"path\")\n  .attr(\"class\", \"level\")\n  .attr(\"d\", geoPath)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-opacity\", 0.25)\n  .attr(\"stroke-width\", 1.2);\n\n// Re-drawn on top of the density fill (clipped to the triangle) so the grid\n// stays legible through the color, per spec's \"grid visible beneath density\n// with appropriate transparency\" — a stack of overlapping contour bands would\n// otherwise hide it no matter how transparent a single band is.\ndrawGrid(contourG.append(\"g\"), 0.55);\n\n// --- Triangle frame + vertex labels ------------------------------------------\ng.append(\"polygon\").attr(\"points\", trianglePoints)\n  .attr(\"fill\", \"none\").attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 2.5);\n\nconst vertexLabels = [\n  { pos: apex, dx: 0, dy: -20, anchor: \"middle\", text: \"Sand\" },\n  { pos: bottomLeft, dx: -14, dy: 32, anchor: \"end\", text: \"Silt\" },\n  { pos: bottomRight, dx: 14, dy: 32, anchor: \"start\", text: \"Clay\" },\n];\nfor (const v of vertexLabels) {\n  g.append(\"text\")\n    .attr(\"x\", v.pos[0] + v.dx).attr(\"y\", v.pos[1] + v.dy)\n    .attr(\"text-anchor\", v.anchor)\n    .style(\"font-size\", \"19px\").style(\"font-weight\", \"600\")\n    .attr(\"fill\", t.ink)\n    .text(v.text);\n}\n\n// --- Density legend (anchored to the triangle's own right edge, not the\n// canvas margin, so it sits snug beside the plot rather than floating in it) -\nconst legendWidth = 24;\nconst legendHeight = 320;\nconst legendGap = 50;\nconst legendX = offsetX + side + legendGap;\nconst legendY = offsetY + (triHeight - legendHeight) / 2;\n\nconst gradient = svg.append(\"defs\").append(\"linearGradient\")\n  .attr(\"id\", \"density-gradient\")\n  .attr(\"x1\", \"0%\").attr(\"y1\", \"100%\").attr(\"x2\", \"0%\").attr(\"y2\", \"0%\");\ngradient.selectAll(\"stop\").data(d3.range(0, 1.01, 0.1)).join(\"stop\")\n  .attr(\"offset\", (d) => `${d * 100}%`)\n  .attr(\"stop-color\", (d) => colorScale(d * maxDensity));\n\nconst legendG = svg.append(\"g\").attr(\"transform\", `translate(${legendX},${legendY})`);\nlegendG.append(\"rect\")\n  .attr(\"width\", legendWidth).attr(\"height\", legendHeight)\n  .attr(\"fill\", \"url(#density-gradient)\")\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1);\n\nconst legendScale = d3.scaleLinear().domain([0, maxDensity]).range([legendHeight, 0]);\nconst legendAxisG = legendG.append(\"g\")\n  .attr(\"transform\", `translate(${legendWidth},0)`)\n  .call(d3.axisRight(legendScale).ticks(5).tickSize(6));\nlegendAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nlegendAxisG.selectAll(\"line\").attr(\"stroke\", t.grid);\nlegendAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nlegendG.append(\"text\")\n  .attr(\"x\", legendWidth / 2).attr(\"y\", -18)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"14px\").attr(\"fill\", t.inkSoft)\n  .text(\"Density\");\n\n// --- Title --------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"20px\").style(\"font-weight\", \"600\")\n  .text(\"Soil Texture Composition · ternary-density · javascript · d3 · anyplot.ai\");\n"}