{"spec_id":"ternary-density","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// ternary-density: Ternary Density Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst TITLE_TEXT = 'ternary-density · javascript · highcharts · anyplot.ai';\nconst SUBTITLE_TEXT = 'Simulated soil-texture composition — 1,280 sediment samples (clay · sand · silt), Gaussian KDE overlay';\n\n// --- Deterministic PRNG (LCG) — the browser has no seeded RNG --------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(7);\nfunction gaussian() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Ternary geometry (unit triangle: x in [0,1], y in [0, H_UNIT]) --------\n// Vertices: clay (top, fa=1), sand (bottom-left, fb=1), silt (bottom-right, fc=1)\nconst H_UNIT = Math.sqrt(3) / 2;\nfunction baryToXY(fa, fb, fc) {\n  return [fc + fa * 0.5, fa * H_UNIT];\n}\nfunction xyToBary(x, y) {\n  const fa = y / H_UNIT;\n  const fc = x - fa * 0.5;\n  const fb = 1 - fa - fc;\n  return [fa, fb, fc];\n}\nconst INSIDE_EPS = 0.006;\nfunction insideAt(x, y) {\n  const [fa, fb, fc] = xyToBary(x, y);\n  return fa >= -INSIDE_EPS && fb >= -INSIDE_EPS && fc >= -INSIDE_EPS;\n}\n\n// --- Data: three soil-texture clusters, sampled around their centroid ------\n// (fa=clay fraction, fb=sand fraction, fc=silt fraction)\nconst CLUSTERS = [\n  { fa: 0.1, fb: 0.65, fc: 0.25, n: 480, spread: 0.065 }, // sandy loam\n  { fa: 0.3, fb: 0.15, fc: 0.55, n: 420, spread: 0.07 }, // silty clay loam\n  { fa: 0.6, fb: 0.2, fc: 0.2, n: 380, spread: 0.06 }, // clay\n];\nconst CLAMP_MIN = 0.01;\n\nconst points = [];\nCLUSTERS.forEach(({ fa, fb, fc, n, spread }) => {\n  const [ccx, ccy] = baryToXY(fa, fb, fc);\n  for (let i = 0; i < n; i++) {\n    let x = ccx + gaussian() * spread;\n    let y = ccy + gaussian() * spread;\n    let [pa, pb, pc] = xyToBary(x, y);\n    if (pa < CLAMP_MIN || pb < CLAMP_MIN || pc < CLAMP_MIN) {\n      pa = Math.max(pa, CLAMP_MIN);\n      pb = Math.max(pb, CLAMP_MIN);\n      pc = Math.max(pc, CLAMP_MIN);\n      const s = pa + pb + pc;\n      [x, y] = baryToXY(pa / s, pb / s, pc / s);\n    }\n    points.push([x, y]);\n  }\n});\n\n// --- Kernel density estimate over a regular grid covering the triangle -----\nconst NX = 96;\nconst dx = 1 / NX;\nconst NY = Math.ceil(H_UNIT / dx);\nconst gx = Array.from({ length: NX + 1 }, (_, i) => i * dx);\nconst gy = Array.from({ length: NY + 1 }, (_, j) => j * dx);\n\nconst BANDWIDTH = 0.06;\nconst TWO_BW2 = 2 * BANDWIDTH * BANDWIDTH;\nfunction kdeAt(x, y) {\n  let sum = 0;\n  for (let k = 0; k < points.length; k++) {\n    const ddx = x - points[k][0];\n    const ddy = y - points[k][1];\n    sum += Math.exp(-(ddx * ddx + ddy * ddy) / TWO_BW2);\n  }\n  return sum;\n}\n\nconst density = [];\nlet maxDensity = 0;\nfor (let i = 0; i <= NX; i++) {\n  density[i] = [];\n  for (let j = 0; j <= NY; j++) {\n    const val = kdeAt(gx[i], gy[j]);\n    density[i][j] = val;\n    if (val > maxDensity) maxDensity = val;\n  }\n}\nconst normDensity = density.map((col) => col.map((v) => v / maxDensity));\n\n// --- Filled density cells (only where all 4 corners lie inside the triangle) -\n// Color/opacity interpolate continuously with the cell's average density\n// (rather than quantizing into discrete bands) so adjacent cells blend into\n// a smooth gradient instead of showing stair-stepped edges.\nconst CUTOFF = 0.035;\nconst cells = [];\nfor (let i = 0; i < NX; i++) {\n  for (let j = 0; j < NY; j++) {\n    const x0 = gx[i],\n      x1 = gx[i + 1],\n      y0 = gy[j],\n      y1 = gy[j + 1];\n    if (!insideAt(x0, y0) || !insideAt(x1, y0) || !insideAt(x0, y1) || !insideAt(x1, y1)) continue;\n    const avg = (normDensity[i][j] + normDensity[i + 1][j] + normDensity[i][j + 1] + normDensity[i + 1][j + 1]) / 4;\n    if (avg < CUTOFF) continue;\n    cells.push({ x0, y0, x1, y1, avg });\n  }\n}\n\n// --- Contour lines via marching squares (same technique as contour-basic) --\nconst CONTOUR_LEVELS = [0.25, 0.5, 0.75];\nfunction contourSegments(level) {\n  const segs = [];\n  for (let i = 0; i < NX; i++) {\n    for (let j = 0; j < NY; j++) {\n      const a = normDensity[i][j],\n        b = normDensity[i + 1][j],\n        c = normDensity[i + 1][j + 1],\n        d = normDensity[i][j + 1];\n      const code = (a >= level ? 1 : 0) | (b >= level ? 2 : 0) | (c >= level ? 4 : 0) | (d >= level ? 8 : 0);\n      if (code === 0 || code === 15) continue;\n\n      function ep(x1, y1, v1, x2, y2, v2) {\n        const s = (level - v1) / (v2 - v1);\n        return [x1 + s * (x2 - x1), y1 + s * (y2 - y1)];\n      }\n\n      const eAB = ep(gx[i], gy[j], a, gx[i + 1], gy[j], b);\n      const eBC = ep(gx[i + 1], gy[j], b, gx[i + 1], gy[j + 1], c);\n      const eCD = ep(gx[i + 1], gy[j + 1], c, gx[i], gy[j + 1], d);\n      const eDA = ep(gx[i], gy[j + 1], d, gx[i], gy[j], a);\n\n      const lookup = {\n        1: [[eDA, eAB]],\n        14: [[eDA, eAB]],\n        2: [[eAB, eBC]],\n        13: [[eAB, eBC]],\n        3: [[eDA, eBC]],\n        12: [[eDA, eBC]],\n        4: [[eBC, eCD]],\n        11: [[eBC, eCD]],\n        6: [[eAB, eCD]],\n        9: [[eAB, eCD]],\n        7: [[eDA, eCD]],\n        8: [[eDA, eCD]],\n        5: [\n          [eDA, eAB],\n          [eBC, eCD],\n        ],\n        10: [\n          [eDA, eCD],\n          [eAB, eBC],\n        ],\n      }[code];\n\n      if (lookup) {\n        lookup.forEach((seg) => {\n          const midx = (seg[0][0] + seg[1][0]) / 2;\n          const midy = (seg[0][1] + seg[1][1]) / 2;\n          if (insideAt(midx, midy)) segs.push(seg);\n        });\n      }\n    }\n  }\n  return segs;\n}\nconst contourSegs = CONTOUR_LEVELS.map((level) => contourSegments(level));\n\n// --- Colors: Imprint imprint_seq — density is single-polarity -------------\nfunction lerpColor(c1, c2, tt) {\n  const parse = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));\n  const [r1, g1, b1] = parse(c1);\n  const [r2, g2, b2] = parse(c2);\n  return (\n    '#' +\n    [r1 + tt * (r2 - r1), g1 + tt * (g2 - g1), b1 + tt * (b2 - b1)]\n      .map((v) => Math.round(v).toString(16).padStart(2, '0'))\n      .join('')\n  );\n}\nfunction cellColor(v) {\n  return lerpColor(t.seq[0], t.seq[1], v);\n}\nfunction cellAlpha(v) {\n  return 0.32 + 0.6 * v;\n}\n\n// --- Sparse hover layer (native Highcharts tooltip) — subsample the fine ---\n// grid rather than one marker per cell, to keep the interactive layer light.\nconst HOVER_STRIDE = 4;\nconst hoverPoints = [];\nfor (let i = 0; i <= NX; i += HOVER_STRIDE) {\n  for (let j = 0; j <= NY; j += HOVER_STRIDE) {\n    if (!insideAt(gx[i], gy[j])) continue;\n    const norm = normDensity[i][j];\n    if (norm < CUTOFF) continue;\n    const [fa, fb, fc] = xyToBary(gx[i], gy[j]);\n    hoverPoints.push({ x: gx[i], y: gy[j], clay: fa * 100, sand: fb * 100, silt: fc * 100, density: norm * 100 });\n  }\n}\n\n// --- Fixed chart geometry (square canvas, harness-guaranteed 1200x1200 CSS) -\nconst W = window.ANYPLOT_SIZE.width;\nconst H_PX = window.ANYPLOT_SIZE.height;\nconst MARGIN_LEFT = 80;\nconst MARGIN_RIGHT = 185;\nconst MARGIN_TOP_MIN = 155;\nconst MARGIN_BOTTOM_MIN = 95;\nconst PLOT_W = W - MARGIN_LEFT - MARGIN_RIGHT;\nconst PLOT_H = PLOT_W * H_UNIT;\nconst V_SLACK = H_PX - MARGIN_TOP_MIN - PLOT_H - MARGIN_BOTTOM_MIN;\nconst MARGIN_TOP = MARGIN_TOP_MIN + V_SLACK / 2;\nconst MARGIN_BOTTOM = MARGIN_BOTTOM_MIN + V_SLACK / 2;\nconst CHART_MARGIN = [MARGIN_TOP, MARGIN_RIGHT, MARGIN_BOTTOM, MARGIN_LEFT];\nconst HOVER_RADIUS = (dx * PLOT_W * HOVER_STRIDE) / 2 * 0.85;\n\nconst drawn = [];\nfunction clearDrawn() {\n  drawn.forEach((el) => {\n    try {\n      el.destroy();\n    } catch (_err) {\n      // already removed\n    }\n  });\n  drawn.length = 0;\n}\n\nfunction drawTernary() {\n  const chart = this;\n  clearDrawn();\n  const r = chart.renderer;\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n  const toPx = (xu, yu) => [xAxis.toPixels(xu, false), yAxis.toPixels(yu, false)];\n  const baryPx = (fa, fb, fc) => toPx(fc + fa * 0.5, fa * H_UNIT);\n\n  // Ternary gridlines (drawn first — the density layer's transparency lets\n  // them show through in low-density cells and at the empty triangle edges).\n  const gridGroup = r.g('ternary-gridlines').add();\n  [0.2, 0.4, 0.6, 0.8].forEach((k) => {\n    [\n      [baryPx(k, 1 - k, 0), baryPx(k, 0, 1 - k)],\n      [baryPx(1 - k, k, 0), baryPx(0, k, 1 - k)],\n      [baryPx(1 - k, 0, k), baryPx(0, 1 - k, k)],\n    ].forEach(([p1, p2]) => {\n      r.path(['M', p1[0], p1[1], 'L', p2[0], p2[1]])\n        .attr({ stroke: t.grid, 'stroke-width': 1 })\n        .add(gridGroup);\n    });\n  });\n  drawn.push(gridGroup);\n\n  // Filled density cells (KDE heatmap overlay, continuous color per cell).\n  const cellGroup = r.g('density-cells').add();\n  cells.forEach(({ x0, y0, x1, y1, avg }) => {\n    const [px0] = toPx(x0, 0);\n    const [px1] = toPx(x1, 0);\n    const [, py0] = toPx(0, y0);\n    const [, py1] = toPx(0, y1);\n    r.rect(Math.min(px0, px1), Math.min(py0, py1), Math.abs(px1 - px0), Math.abs(py1 - py0))\n      .attr({ fill: cellColor(avg), opacity: cellAlpha(avg) })\n      .add(cellGroup);\n  });\n  drawn.push(cellGroup);\n\n  // Contour lines at key density levels.\n  const contourGroup = r.g('contour-lines').add();\n  CONTOUR_LEVELS.forEach((level, li) => {\n    const color = lerpColor(t.seq[0], t.seq[1], level);\n    contourSegs[li].forEach(([p1, p2]) => {\n      const [x1p, y1p] = toPx(p1[0], p1[1]);\n      const [x2p, y2p] = toPx(p2[0], p2[1]);\n      r.path(['M', x1p, y1p, 'L', x2p, y2p])\n        .attr({ stroke: color, 'stroke-width': 1.5, opacity: 0.85 })\n        .add(contourGroup);\n    });\n  });\n  drawn.push(contourGroup);\n\n  // Triangle outline, on top so it stays crisp against the density fill.\n  const apex = baryPx(1, 0, 0);\n  const baseLeft = baryPx(0, 1, 0);\n  const baseRight = baryPx(0, 0, 1);\n  drawn.push(\n    r\n      .path(['M', apex[0], apex[1], 'L', baseLeft[0], baseLeft[1], 'L', baseRight[0], baseRight[1], 'Z'])\n      .attr({ stroke: t.inkSoft, 'stroke-width': 2.5, fill: 'none' })\n      .add()\n  );\n\n  // Edge tick labels — each edge scales the component opposite its far vertex.\n  [0.2, 0.4, 0.6, 0.8].forEach((k) => {\n    const label = `${Math.round(k * 100)}`;\n    const leftTick = baryPx(k, 1 - k, 0);\n    drawn.push(\n      r\n        .text(label, leftTick[0] - 10, leftTick[1] + 4)\n        .attr({ align: 'right' })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n    const rightTick = baryPx(1 - k, 0, k);\n    drawn.push(\n      r\n        .text(label, rightTick[0] + 10, rightTick[1] + 4)\n        .attr({ align: 'left' })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n    const bottomTick = baryPx(0, k, 1 - k);\n    drawn.push(\n      r\n        .text(label, bottomTick[0], bottomTick[1] + 24)\n        .attr({ align: 'center' })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n\n  // Vertex labels.\n  drawn.push(\n    r\n      .text('Clay', apex[0], apex[1] - 28)\n      .attr({ align: 'center' })\n      .css({ color: t.ink, fontSize: '17px', fontWeight: '600' })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('Sand', baseLeft[0] - 14, baseLeft[1] + 46)\n      .attr({ align: 'right' })\n      .css({ color: t.ink, fontSize: '17px', fontWeight: '600' })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('Silt', baseRight[0] + 14, baseRight[1] + 46)\n      .attr({ align: 'left' })\n      .css({ color: t.ink, fontSize: '17px', fontWeight: '600' })\n      .add()\n  );\n\n  // Vertical colorbar (core Highcharts has no colorAxis module loaded).\n  const barLeft = chart.plotLeft + chart.plotWidth + 60;\n  const barTop = chart.plotTop + 20;\n  const barWidth = 26;\n  const barHeight = chart.plotHeight - 40;\n  drawn.push(\n    r\n      .rect(barLeft, barTop, barWidth, barHeight)\n      .attr({\n        fill: {\n          linearGradient: { x1: 0, y1: 1, x2: 0, y2: 0 },\n          stops: [\n            [0, t.seq[0]],\n            [1, t.seq[1]],\n          ],\n        },\n        stroke: t.inkSoft,\n        'stroke-width': 1,\n      })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('Sample', barLeft, barTop - 26)\n      .attr({ align: 'left' })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('density', barLeft, barTop - 10)\n      .attr({ align: 'left' })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('High', barLeft + barWidth + 10, barTop + 12)\n      .attr({ align: 'left' })\n      .css({ color: t.inkSoft, fontSize: '13px' })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('Low', barLeft + barWidth + 10, barTop + barHeight)\n      .attr({ align: 'left' })\n      .css({ color: t.inkSoft, fontSize: '13px' })\n      .add()\n  );\n}\n\n// --- Chart -------------------------------------------------------------\nHighcharts.chart('container', {\n  chart: {\n    type: 'scatter',\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: CHART_MARGIN,\n    events: { load: drawTernary, redraw: drawTernary },\n  },\n  credits: { enabled: false },\n  title: { text: TITLE_TEXT, style: { color: t.ink, fontSize: '22px', fontWeight: '600' } },\n  subtitle: { text: SUBTITLE_TEXT, style: { color: t.inkSoft, fontSize: '14px' } },\n  xAxis: { visible: false, min: 0, max: 1, lineWidth: 0, tickWidth: 0, gridLineWidth: 0 },\n  yAxis: { visible: false, min: 0, max: H_UNIT, lineWidth: 0, tickWidth: 0, gridLineWidth: 0 },\n  legend: { enabled: false },\n  tooltip: {\n    enabled: true,\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    borderRadius: 6,\n    style: { color: t.ink, fontSize: '13px' },\n    formatter: function () {\n      const p = this.point;\n      return `<b>Clay ${p.clay.toFixed(0)}% · Sand ${p.sand.toFixed(0)}% · Silt ${p.silt.toFixed(0)}%</b><br/>Relative density: ${p.density.toFixed(0)}%`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      enableMouseTracking: true,\n      stickyTracking: false,\n      marker: {\n        enabled: true,\n        symbol: 'circle',\n        radius: HOVER_RADIUS,\n        fillColor: 'rgba(0,0,0,0.001)',\n        lineWidth: 0,\n        states: { hover: { enabled: false } },\n      },\n    },\n  },\n  series: [{ type: 'scatter', name: 'Composition', data: hoverPoints }],\n});\n"}