{"spec_id":"heatmap-mandelbrot","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-mandelbrot: Mandelbrot Set Fractal Visualization\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-25\n\nconst tok = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Complex-plane bounds (spec defaults) -----------------------------------\nconst xMin = -2.5;\nconst xMax = 1.0;\nconst yMin = -1.25;\nconst yMax = 1.25;\nconst maxIterations = 100;\nconst bailoutSq = 4; // |z|^2 escape radius^2\n\n// Contrast-curve exponent applied to the normalized escape fraction before it\n// hits the LUT. Most background pixels escape within a handful of iterations,\n// so a low exponent (well below 1) pushes that dense low-iteration cluster\n// further along the green->blue ramp, spreading perceptual detail across the\n// escape zone instead of leaving it a flat near-uniform green.\nconst COLOR_GAMMA = 0.3;\n\n// --- Layout -------------------------------------------------------------------\n// Reserve space for title, axis labels/ticks, and a colorbar on the right.\nconst margin = { top: 90, right: 190, bottom: 90, left: 110 };\nconst availW = width - margin.left - margin.right;\nconst availH = height - margin.top - margin.bottom;\n\n// Fit the largest rectangle matching the complex plane's aspect ratio inside\n// the available area, then center it — this keeps circles circular instead of\n// stretching the fractal to fill the mount.\nconst dataAspect = (xMax - xMin) / (yMax - yMin);\nlet plotW = availW;\nlet plotH = plotW / dataAspect;\nif (plotH > availH) {\n  plotH = availH;\n  plotW = plotH * dataAspect;\n}\nconst plotX = margin.left + (availW - plotW) / 2;\nconst plotY = margin.top + (availH - plotH) / 2;\n\n// --- Escape-time raster (rendered off-DOM on a <canvas>) --------------------\nconst DPR = 2; // matches the harness deviceScaleFactor so the raster lands on exact device pixels\nconst canvasW = Math.round(plotW * DPR);\nconst canvasH = Math.round(plotH * DPR);\nconst rasterCanvas = document.createElement(\"canvas\");\nrasterCanvas.width = canvasW;\nrasterCanvas.height = canvasH;\nconst ctx = rasterCanvas.getContext(\"2d\");\nconst imageData = ctx.createImageData(canvasW, canvasH);\nconst pixels = imageData.data;\n\n// Precompute a 256-step lookup along the Imprint sequential ramp (brand green\n// -> blue) so the hot per-pixel loop never allocates or parses a color string.\nconst seqInterpolator = d3.interpolateRgb(tok.seq[0], tok.seq[1]);\nconst LUT_STEPS = 256;\nconst lut = new Uint8Array(LUT_STEPS * 3);\nfor (let i = 0; i < LUT_STEPS; i++) {\n  const c = d3.rgb(seqInterpolator(i / (LUT_STEPS - 1)));\n  lut[i * 3] = c.r;\n  lut[i * 3 + 1] = c.g;\n  lut[i * 3 + 2] = c.b;\n}\nconst insideColor = d3.rgb(\"#0A0A08\"); // points that never escape — distinct solid fill\n\nfor (let py = 0; py < canvasH; py++) {\n  const ci0 = yMax - (py / canvasH) * (yMax - yMin);\n  for (let px = 0; px < canvasW; px++) {\n    const cr = xMin + (px / canvasW) * (xMax - xMin);\n    const ci = ci0;\n\n    let zr = 0;\n    let zi = 0;\n    let n = 0;\n    let zr2 = 0;\n    let zi2 = 0;\n    while (zr2 + zi2 <= bailoutSq && n < maxIterations) {\n      zi = 2 * zr * zi + ci;\n      zr = zr2 - zi2 + cr;\n      zr2 = zr * zr;\n      zi2 = zi * zi;\n      n++;\n    }\n\n    const idx = (py * canvasW + px) * 4;\n    if (n >= maxIterations) {\n      pixels[idx] = insideColor.r;\n      pixels[idx + 1] = insideColor.g;\n      pixels[idx + 2] = insideColor.b;\n    } else {\n      // Smooth (renormalized) escape count avoids discrete color banding.\n      const logZn = Math.log(zr2 + zi2) / 2;\n      const nu = Math.log(logZn / Math.LN2) / Math.LN2;\n      const smoothed = n + 1 - nu;\n      const t = Math.pow(Math.min(1, Math.max(0, smoothed / maxIterations)), COLOR_GAMMA);\n      const lutIdx = Math.round(t * (LUT_STEPS - 1)) * 3;\n      pixels[idx] = lut[lutIdx];\n      pixels[idx + 1] = lut[lutIdx + 1];\n      pixels[idx + 2] = lut[lutIdx + 2];\n    }\n    pixels[idx + 3] = 255;\n  }\n}\nctx.putImageData(imageData, 0, 0);\nconst rasterDataUrl = rasterCanvas.toDataURL(\"image/png\");\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nsvg\n  .append(\"image\")\n  .attr(\"x\", plotX)\n  .attr(\"y\", plotY)\n  .attr(\"width\", plotW)\n  .attr(\"height\", plotH)\n  .attr(\"href\", rasterDataUrl);\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", plotX)\n  .attr(\"y\", plotY)\n  .attr(\"width\", plotW)\n  .attr(\"height\", plotH)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", tok.inkSoft)\n  .attr(\"stroke-width\", 1);\n\n// --- Axes (real / imaginary parts of c) ---------------------------------------\nconst x = d3.scaleLinear().domain([xMin, xMax]).range([0, plotW]);\nconst y = d3.scaleLinear().domain([yMin, yMax]).range([plotH, 0]);\n\nconst xAxis = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${plotX},${plotY + plotH})`)\n  .call(d3.axisBottom(x).ticks(7));\nconst yAxis = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${plotX},${plotY})`)\n  .call(d3.axisLeft(y).ticks(7));\n\nfor (const axisG of [xAxis, yAxis]) {\n  axisG.selectAll(\"text\").attr(\"fill\", tok.inkSoft).style(\"font-size\", \"14px\");\n  axisG.selectAll(\"line\").attr(\"stroke\", tok.grid);\n  axisG.select(\".domain\").attr(\"stroke\", tok.inkSoft);\n}\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", plotX + plotW / 2)\n  .attr(\"y\", plotY + plotH + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Real Axis — Re(c)\");\n\nsvg\n  .append(\"text\")\n  .attr(\"transform\", `translate(${plotX - 70},${plotY + plotH / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Imaginary Axis — Im(c)\");\n\n// --- Landmark annotations (data storytelling) ----------------------------\n// Callout the two features the spec names explicitly: the main cardioid and\n// the period-2 bulb tangent to it at c = -0.75. Labels sit on a pill so they\n// stay legible over both the black interior and the green/blue escape field.\nfunction addLandmark(dataX, targetDataY, labelDataY, text) {\n  const px = plotX + x(dataX);\n  const targetPy = plotY + y(targetDataY);\n  const labelPy = plotY + y(labelDataY);\n\n  svg\n    .append(\"line\")\n    .attr(\"x1\", px)\n    .attr(\"y1\", targetPy)\n    .attr(\"x2\", px)\n    .attr(\"y2\", labelPy)\n    .attr(\"stroke\", tok.inkSoft)\n    .attr(\"stroke-width\", 1);\n\n  svg\n    .append(\"circle\")\n    .attr(\"cx\", px)\n    .attr(\"cy\", targetPy)\n    .attr(\"r\", 3)\n    .attr(\"fill\", tok.elevatedBg)\n    .attr(\"stroke\", tok.inkSoft)\n    .attr(\"stroke-width\", 1.5);\n\n  const labelText = svg\n    .append(\"text\")\n    .attr(\"x\", px)\n    .attr(\"y\", labelPy)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"fill\", tok.ink)\n    .style(\"font-size\", \"14px\")\n    .style(\"font-weight\", \"500\")\n    .text(text);\n\n  const bbox = labelText.node().getBBox();\n  const padX = 8;\n  const padY = 5;\n  svg\n    .insert(\"rect\", () => labelText.node())\n    .attr(\"x\", bbox.x - padX)\n    .attr(\"y\", bbox.y - padY)\n    .attr(\"width\", bbox.width + padX * 2)\n    .attr(\"height\", bbox.height + padY * 2)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", tok.elevatedBg)\n    .attr(\"stroke\", tok.inkSoft)\n    .attr(\"stroke-width\", 1)\n    .attr(\"opacity\", 0.94);\n}\n\naddLandmark(-0.3, 0.45, 0.95, \"Main cardioid\");\naddLandmark(-1.0, 0.15, 0.75, \"Period-2 bulb\");\n\n// --- Colorbar legend (escape-iteration scale) ---------------------------------\nconst legendW = 22;\nconst legendH = plotH;\nconst legendX = plotX + plotW + 60;\nconst legendY = plotY;\n\nconst gradientId = \"mandelbrot-escape-gradient\";\nconst defs = svg.append(\"defs\");\nconst gradient = defs\n  .append(\"linearGradient\")\n  .attr(\"id\", gradientId)\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\");\n\nconst GRADIENT_STOPS = 12;\nfor (let i = 0; i <= GRADIENT_STOPS; i++) {\n  const frac = i / GRADIENT_STOPS;\n  const t = Math.pow(frac, COLOR_GAMMA); // matches the raster's contrast curve\n  gradient\n    .append(\"stop\")\n    .attr(\"offset\", `${frac * 100}%`)\n    .attr(\"stop-color\", seqInterpolator(t));\n}\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendY)\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"fill\", `url(#${gradientId})`)\n  .attr(\"stroke\", tok.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nconst legendScale = d3.scaleLinear().domain([0, maxIterations]).range([legendH, 0]);\nconst legendAxis = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${legendX + legendW},${legendY})`)\n  .call(d3.axisRight(legendScale).ticks(5));\nlegendAxis.selectAll(\"text\").attr(\"fill\", tok.inkSoft).style(\"font-size\", \"14px\");\nlegendAxis.selectAll(\"line\").attr(\"stroke\", tok.grid);\nlegendAxis.select(\".domain\").attr(\"stroke\", tok.inkSoft);\n\nsvg\n  .append(\"text\")\n  .attr(\"transform\", `translate(${legendX + legendW + 55},${legendY + legendH / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Escape iterations\");\n\n// --- Title ----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", tok.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"heatmap-mandelbrot · javascript · d3 · anyplot.ai\");\n"}