{"spec_id":"ternary-density","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// ternary-density: Ternary Density Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst PAD = 100; // symmetric layout padding (CSS px) that keeps the chart area square\n\n// --- Deterministic PRNG (LCG) + Gaussian sampling ---------------------------\nlet seed = 42;\nfunction lcg() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction randn() {\n  const u1 = Math.max(lcg(), 1e-9);\n  const u2 = lcg();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Barycentric -> Cartesian (equilateral triangle) ------------------------\n// Vertices: Sand (a) at (0,0), Silt (b) at (1,0), Clay (c) at (0.5, H)\nconst H = Math.sqrt(3) / 2;\nfunction bary2xy(a, b, c) {\n  return { x: b + 0.5 * c, y: c * H };\n}\n\n// --- Data: sediment composition (sand / silt / clay), 3 facies clusters -----\nconst facies = [\n  { name: \"Sandy shoreface\", center: [0.7, 0.22, 0.08], spread: 0.06, weight: 0.4 },\n  { name: \"Silty mid-shelf\", center: [0.25, 0.6, 0.15], spread: 0.07, weight: 0.35 },\n  { name: \"Clay-rich basin\", center: [0.1, 0.3, 0.6], spread: 0.08, weight: 0.25 },\n];\nconst N_SAMPLES = 1200;\nconst cumWeights = [];\nfacies.reduce((sum, f, i) => (cumWeights[i] = sum + f.weight), 0);\n\nconst samples = [];\nfor (let i = 0; i < N_SAMPLES; i++) {\n  const r = lcg();\n  const cluster = facies[cumWeights.findIndex((w) => r <= w)] ?? facies[facies.length - 1];\n  let a = cluster.center[0] + cluster.spread * randn();\n  let b = cluster.center[1] + cluster.spread * randn();\n  let c = cluster.center[2] + cluster.spread * randn();\n  a = Math.max(a, 0.01);\n  b = Math.max(b, 0.01);\n  c = Math.max(c, 0.01);\n  const total = a + b + c;\n  const point = bary2xy(a / total, b / total, c / total);\n  samples.push(point);\n}\n\n// --- Kernel density estimate over a triangular grid --------------------------\nconst DIVISIONS = 70;\nconst BANDWIDTH = 0.05;\nconst cellX = [];\nconst cellY = [];\nconst cellDensity = [];\nlet maxDensity = 0;\n\nfor (let iy = 0; iy <= DIVISIONS; iy++) {\n  const gy = (iy / DIVISIONS) * H;\n  for (let ix = 0; ix <= DIVISIONS; ix++) {\n    const gx = ix / DIVISIONS;\n    // Skip grid points outside the triangle (barycentric feasibility check)\n    const gc = gy / H;\n    const gb = gx - 0.5 * gc;\n    const ga = 1 - gb - gc;\n    if (ga < -0.01 || gb < -0.01 || gc < -0.01) continue;\n\n    let density = 0;\n    for (let s = 0; s < samples.length; s++) {\n      const dx = gx - samples[s].x;\n      const dy = gy - samples[s].y;\n      density += Math.exp(-(dx * dx + dy * dy) / (2 * BANDWIDTH * BANDWIDTH));\n    }\n    density /= samples.length;\n    if (density > maxDensity) maxDensity = density;\n    cellX.push(gx);\n    cellY.push(gy);\n    cellDensity.push(density);\n  }\n}\n\n// --- Density color mapping (imprint_seq: brand green -> blue) ---------------\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nconst seqLo = hexToRgb(t.seq[0]);\nconst seqHi = hexToRgb(t.seq[1]);\nfunction densityColor(ratio, alpha) {\n  const r = Math.round(seqLo[0] + (seqHi[0] - seqLo[0]) * ratio);\n  const g = Math.round(seqLo[1] + (seqHi[1] - seqLo[1]) * ratio);\n  const b = Math.round(seqLo[2] + (seqHi[2] - seqLo[2]) * ratio);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\nconst DENSITY_FLOOR = 0.03;\nconst heatPoints = [];\n// Mount is a fixed 1200 CSS-px square (see prompts/library/chartjs.md); with\n// symmetric layout padding this gives an exact chart-area side of MOUNT - 2*PAD.\nconst CHART_AREA_PX = 1200 - 2 * PAD;\nconst cellSpacingPx = CHART_AREA_PX / DIVISIONS;\nconst cellRadius = cellSpacingPx * 1.35; // heavy overlap smooths the grid into a continuous surface\nfor (let i = 0; i < cellDensity.length; i++) {\n  const ratio = cellDensity[i] / maxDensity;\n  if (ratio < DENSITY_FLOOR) continue;\n  const alpha = 0.12 + 0.78 * ratio;\n  heatPoints.push({\n    x: cellX[i],\n    y: cellY[i],\n    r: cellRadius,\n    color: densityColor(ratio, alpha),\n  });\n}\n\n// --- Ternary grid geometry (drawn beneath the density layer) ----------------\nconst gridLevels = [0.2, 0.4, 0.6, 0.8];\nconst gridSegments = [];\nfor (const k of gridLevels) {\n  gridSegments.push([bary2xy(k, 1 - k, 0), bary2xy(k, 0, 1 - k)]); // constant sand\n  gridSegments.push([bary2xy(1 - k, k, 0), bary2xy(0, k, 1 - k)]); // constant silt\n  gridSegments.push([bary2xy(1 - k, 0, k), bary2xy(0, 1 - k, k)]); // constant clay\n}\nconst triangleOutline = [bary2xy(1, 0, 0), bary2xy(0, 1, 0), bary2xy(0, 0, 1), bary2xy(1, 0, 0)];\n\n// --- Domain: pad the y-range so x-range and y-range are equal ---------------\n// Guarantees an undistorted equilateral triangle once the chart area is square.\nconst marginY = (1 - H) / 2;\nconst xMin = 0;\nconst xMax = 1;\nconst yMin = -marginY;\nconst yMax = H + marginY;\n\n// --- Custom plugin: ternary grid, vertex labels, title, colorbar ------------\nfunction traceTriangle(ctx, px, py) {\n  ctx.beginPath();\n  triangleOutline.forEach((p, i) => {\n    const [px_, py_] = [px(p.x), py(p.y)];\n    if (i === 0) ctx.moveTo(px_, py_);\n    else ctx.lineTo(px_, py_);\n  });\n  ctx.closePath();\n}\n\nconst ternaryChrome = {\n  id: \"ternaryChrome\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const px = (x) => scales.x.getPixelForValue(x);\n    const py = (y) => scales.y.getPixelForValue(y);\n\n    ctx.save();\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1.5;\n    for (const [p0, p1] of gridSegments) {\n      ctx.beginPath();\n      ctx.moveTo(px(p0.x), py(p0.y));\n      ctx.lineTo(px(p1.x), py(p1.y));\n      ctx.stroke();\n    }\n\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 2.5;\n    traceTriangle(ctx, px, py);\n    ctx.stroke();\n    ctx.restore();\n\n    // Clip the upcoming density-bubble dataset to the simplex so heavily\n    // overlapping bubbles near the edges never paint outside the valid\n    // compositional triangle. Restored (and the outline re-stroked on top)\n    // in afterDatasetsDraw once the bubble layer is done.\n    ctx.save();\n    traceTriangle(ctx, px, py);\n    ctx.clip();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const px = (x) => scales.x.getPixelForValue(x);\n    const py = (y) => scales.y.getPixelForValue(y);\n\n    ctx.restore(); // drop the clip applied in beforeDatasetsDraw\n\n    // Re-stroke the outline on top of the (now clipped) density layer so the\n    // boundary stays crisp instead of being softened by adjacent bubbles.\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 2.5;\n    traceTriangle(ctx, px, py);\n    ctx.stroke();\n    ctx.restore();\n  },\n  afterDraw(chart) {\n    const { ctx, scales, chartArea } = chart;\n    const px = (x) => scales.x.getPixelForValue(x);\n    const py = (y) => scales.y.getPixelForValue(y);\n\n    // Title\n    ctx.save();\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 26px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillText(\"ternary-density · javascript · chartjs · anyplot.ai\", chart.width / 2, PAD / 2);\n\n    // Vertex labels\n    ctx.font = \"600 20px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"Sand\", px(0), py(0) + PAD * 0.72);\n    ctx.fillText(\"Silt\", px(1), py(0) + PAD * 0.72);\n    ctx.textBaseline = \"top\";\n    ctx.fillText(\"Clay\", px(0.5), py(H) - PAD * 0.78);\n\n    // Colorbar (density legend) in the bottom margin\n    const barW = chartArea.right - chartArea.left;\n    const barX = chartArea.left;\n    const barY = chart.height - PAD * 0.42;\n    const barH = 18;\n    const gradient = ctx.createLinearGradient(barX, 0, barX + barW, 0);\n    gradient.addColorStop(0, t.seq[0]);\n    gradient.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, barY, barW, barH);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, barY, barW, barH);\n\n    ctx.font = \"600 16px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textBaseline = \"top\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"Low density\", barX, barY + barH + 6);\n    ctx.textAlign = \"right\";\n    ctx.fillText(\"High density\", barX + barW, barY + barH + 6);\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: \"bubble\",\n  data: {\n    datasets: [\n      {\n        data: heatPoints,\n        backgroundColor: heatPoints.map((p) => p.color),\n        borderWidth: 0,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: PAD },\n    plugins: {\n      title: { display: false },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { type: \"linear\", min: xMin, max: xMax, display: false },\n      y: { type: \"linear\", min: yMin, max: yMax, display: false },\n    },\n  },\n  plugins: [ternaryChrome],\n});\n"}