{"spec_id":"heatmap-mandelbrot","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-mandelbrot: Mandelbrot Set Fractal Visualization\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-25\n//# anyplot-orientation: landscape\n\n// Chart.js has no native heatmap/matrix chart type (chartjs-chart-matrix is an\n// unpinned community plugin, out of scope). Instead this draws the fractal as a\n// raster image positioned via Chart.js's own (native, not a plugin package)\n// `plugins` draw-hook API onto a real linear x/y coordinate system, so the axes\n// still report true complex-plane coordinates.\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Mandelbrot parameters ---------------------------------------------------\nconst X_MIN = -2.5;\nconst X_MAX = 1.0;\nconst Y_MIN = -1.25;\nconst Y_MAX = 1.25;\nconst MAX_ITER = 100;\nconst PIXEL_BUDGET = 480000; // grid resolution budget, spent proportional to aspect\n\nfunction hexToRgb(hex) {\n  const v = parseInt(hex.slice(1), 16);\n  return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };\n}\n\n// Precompute a 256-step lookup table between the two imprint_seq stops so the\n// hot per-pixel loop below never parses hex strings.\nfunction buildPalette(seqColors) {\n  const c0 = hexToRgb(seqColors[0]);\n  const c1 = hexToRgb(seqColors[1]);\n  const steps = 256;\n  const pal = new Uint8ClampedArray(steps * 3);\n  for (let i = 0; i < steps; i++) {\n    const f = i / (steps - 1);\n    pal[i * 3] = c0.r + (c1.r - c0.r) * f;\n    pal[i * 3 + 1] = c0.g + (c1.g - c0.g) * f;\n    pal[i * 3 + 2] = c0.b + (c1.b - c0.b) * f;\n  }\n  return pal;\n}\n\nconst PALETTE = buildPalette(t.seq);\n\n// Draws a short leader line from a labeled point down to a target point inside\n// a fractal structure, plus a small dot marking the target — a lightweight\n// callout so the teaching-tool framing names the cardioid and period-2 bulb.\nfunction drawCallout(ctx, scaleX, scaleY, labelCx, labelCy, targetCx, targetCy, text) {\n  const labelPx = { x: scaleX.getPixelForValue(labelCx), y: scaleY.getPixelForValue(labelCy) };\n  const targetPx = { x: scaleX.getPixelForValue(targetCx), y: scaleY.getPixelForValue(targetCy) };\n\n  ctx.save();\n  ctx.strokeStyle = t.inkSoft;\n  ctx.lineWidth = 1.5;\n  ctx.beginPath();\n  ctx.moveTo(labelPx.x, labelPx.y + 4);\n  ctx.lineTo(targetPx.x, targetPx.y);\n  ctx.stroke();\n\n  ctx.fillStyle = t.inkSoft;\n  ctx.beginPath();\n  ctx.arc(targetPx.x, targetPx.y, 3, 0, Math.PI * 2);\n  ctx.fill();\n\n  ctx.font = \"bold 16px -apple-system, BlinkMacSystemFont, sans-serif\";\n  ctx.fillStyle = t.ink;\n  ctx.textAlign = \"center\";\n  ctx.textBaseline = \"bottom\";\n  ctx.fillText(text, labelPx.x, labelPx.y);\n  ctx.restore();\n}\n\n// Renders the Mandelbrot set into an offscreen canvas using smooth (continuous)\n// escape-time coloring — no discrete iteration bands. Points that never escape\n// (bounded, inside the set) render solid black, per the spec.\nfunction renderMandelbrot(xMin, xMax, yMin, yMax, w, h) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = w;\n  canvas.height = h;\n  const ctx = canvas.getContext(\"2d\");\n  const img = ctx.createImageData(w, h);\n  const data = img.data;\n  const xRange = xMax - xMin;\n  const yRange = yMax - yMin;\n  const log2 = Math.log(2);\n\n  for (let py = 0; py < h; py++) {\n    const ci = yMax - (py / h) * yRange;\n    for (let px = 0; px < w; px++) {\n      const cr = xMin + (px / w) * xRange;\n      let zr = 0;\n      let zi = 0;\n      let zr2 = 0;\n      let zi2 = 0;\n      let n = 0;\n      while (zr2 + zi2 <= 4 && n < MAX_ITER) {\n        zi = 2 * zr * zi + ci;\n        zr = zr2 - zi2 + cr;\n        zr2 = zr * zr;\n        zi2 = zi * zi;\n        n++;\n      }\n      const idx = (py * w + px) * 4;\n      if (n >= MAX_ITER) {\n        data[idx] = 0;\n        data[idx + 1] = 0;\n        data[idx + 2] = 0;\n      } else {\n        // Continuous escape count avoids the banding a raw integer n gives.\n        const zn = Math.sqrt(zr2 + zi2);\n        const nu = Math.log(Math.log(zn) / log2) / log2;\n        const smoothN = n + 1 - nu;\n        let ft = smoothN / MAX_ITER;\n        if (ft < 0) ft = 0;\n        if (ft > 1) ft = 1;\n        ft = Math.pow(ft, 0.6); // gamma-lift the low end so the green→blue gradient stays visible far from the boundary\n        const pi = Math.round(ft * 255);\n        data[idx] = PALETTE[pi * 3];\n        data[idx + 1] = PALETTE[pi * 3 + 1];\n        data[idx + 2] = PALETTE[pi * 3 + 2];\n      }\n      data[idx + 3] = 255;\n    }\n  }\n  ctx.putImageData(img, 0, 0);\n  return canvas;\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nlet mandelbrotCanvas = null;\n\nconst mandelbrotPlugin = {\n  id: \"mandelbrotPlugin\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const areaW = chartArea.right - chartArea.left;\n    const areaH = chartArea.bottom - chartArea.top;\n\n    ctx.save();\n    if (mandelbrotCanvas) {\n      ctx.imageSmoothingEnabled = true;\n      ctx.drawImage(mandelbrotCanvas, chartArea.left, chartArea.top, areaW, areaH);\n    }\n    // Enclosed frame — a heatmap grid reads better with all four spines.\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 2;\n    ctx.strokeRect(chartArea.left, chartArea.top, areaW, areaH);\n\n    // --- Callouts naming the mathematically significant structures --------\n    // Both anchor points are known to lie inside the respective structure:\n    // (-0.3, 0.35) satisfies the main cardioid's boundary test, and\n    // (-1, 0.2) lies inside the period-2 bulb (center -1, radius 0.25).\n    drawCallout(ctx, chart.scales.x, chart.scales.y, -0.3, 0.85, -0.3, 0.35, \"Cardioid\");\n    drawCallout(ctx, chart.scales.x, chart.scales.y, -1, 0.6, -1, 0.2, \"Period-2 bulb\");\n\n    // --- Colorbar legend (escape-time scale + \"in set\" swatch) -------------\n    const barX = chartArea.left;\n    const barW = Math.min(560, areaW * 0.5);\n    const barY = chart.height - 95;\n    const barH = 22;\n\n    ctx.font = \"14px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"alphabetic\";\n    ctx.fillText(\"Escape iterations (smooth-colored)\", barX, barY - 10);\n\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.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"0\", barX, barY + barH + 18);\n    ctx.textAlign = \"right\";\n    ctx.fillText(`${MAX_ITER}+`, barX + barW, barY + barH + 18);\n\n    const swatchX = barX + barW + 50;\n    ctx.fillStyle = \"#000000\";\n    ctx.fillRect(swatchX, barY, barH, barH);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.strokeRect(swatchX, barY, barH, barH);\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"In the set (bounded, never escapes)\", swatchX + barH + 12, barY + barH - 5);\n\n    ctx.restore();\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\n// A scatter chart with no data supplies a real-valued linear x/y coordinate\n// system (the complex plane) for the mandelbrotPlugin to draw the raster into.\nconst chart = new Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [{ data: [] }] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { bottom: 130 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"heatmap-mandelbrot · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 28 },\n      },\n      legend: { display: false },\n    },\n    scales: {\n      x: {\n        min: X_MIN,\n        max: X_MAX,\n        grid: { display: false },\n        border: { display: false },\n        // stepSize + includeBounds:false snap ticks to clean integers instead\n        // of the raw aspect-widened endpoints (e.g. -3.59, 2.09).\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 1, includeBounds: false, callback: (v) => v.toFixed(0) },\n        title: { display: true, text: \"Real axis — Re(c)\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        min: Y_MIN,\n        max: Y_MAX,\n        grid: { display: false },\n        border: { display: false },\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => v.toFixed(2) },\n        title: { display: true, text: \"Imaginary axis — Im(c)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n  plugins: [mandelbrotPlugin],\n});\n\n// Widen the real-axis range (never the imaginary one, which must keep the full\n// cardioid + period-2 bulb visible) so real/imaginary units occupy equal pixel\n// distance, preserving the complex plane's true proportions regardless of the\n// chart area's own aspect ratio.\nconst area = chart.chartArea;\nconst areaAspect = (area.right - area.left) / (area.bottom - area.top);\nconst yRange = Y_MAX - Y_MIN;\nconst desiredXRange = yRange * areaAspect;\nconst xCenter = (X_MIN + X_MAX) / 2;\nconst xMin2 = xCenter - desiredXRange / 2;\nconst xMax2 = xCenter + desiredXRange / 2;\n\nchart.options.scales.x.min = xMin2;\nchart.options.scales.x.max = xMax2;\n\nconst gridH = Math.round(Math.sqrt(PIXEL_BUDGET / areaAspect));\nconst gridW = Math.round(gridH * areaAspect);\nmandelbrotCanvas = renderMandelbrot(xMin2, xMax2, Y_MIN, Y_MAX, gridW, gridH);\n\nchart.update(\"none\");\n\nwindow.__anyplotReady = true;\n"}