{"spec_id":"heatmap-mandelbrot","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// heatmap-mandelbrot: Mandelbrot Set Fractal Visualization\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-25\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Mandelbrot parameters ---------------------------------------------------\n// A square viewport (equal real/imaginary extent) so the square canvas can host\n// it with uniform x/y scale — no stretching of the complex plane. This framing\n// still shows the full classic view: the cardioid, the period-2 bulb on its\n// left, and the negative-real spike tapering out past Re(c) = -2.\nconst X_MIN = -2.1;\nconst X_MAX = 0.6;\nconst Y_MIN = -1.35;\nconst Y_MAX = 1.35;\nconst MAX_ITER = 100;\n\n// Raster resolution: comfortably above the spec's 800x600 floor, and close to\n// the plot area's own device-pixel size (square mount, deviceScaleFactor 2) so\n// the embedded image is neither blurrily upscaled nor wastefully oversampled.\nconst RES = 1400;\n\n// In-set points (orbit stays bounded) get a fixed, theme-independent solid\n// color — this is data, not chrome, so it does not follow ANYPLOT_THEME.\nconst IN_SET_RGB = [9, 9, 8];\n\nfunction hexToRgb(hex) {\n  return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];\n}\nconst SEQ_LO = hexToRgb(t.seq[0]); // #009E73 — escapes fast (low iteration count)\nconst SEQ_HI = hexToRgb(t.seq[1]); // #4467A3 — escapes slowly (near the boundary)\n\n// --- Escape-time raster (smooth/continuous iteration count) -----------------\n// Computed straight into an off-screen canvas's ImageData, not RES*RES SVG\n// rects — the core Highcharts bundle has no heatmap/colorAxis module, and that\n// many SVG nodes would be both a DOM-size problem and far slower to paint than\n// a raster. The finished raster is embedded as a single <image> in the chart's\n// SVG via the renderer, so the chart otherwise stays plain core Highcharts.\nconst canvas = document.createElement('canvas');\ncanvas.width = RES;\ncanvas.height = RES;\nconst ctx = canvas.getContext('2d');\nconst imageData = ctx.createImageData(RES, RES);\nconst pixels = imageData.data;\n\nconst LOG2 = Math.log(2);\n\nfor (let py = 0; py < RES; py++) {\n  const ci = Y_MAX - (py / (RES - 1)) * (Y_MAX - Y_MIN); // row 0 = top = Y_MAX\n  for (let px = 0; px < RES; px++) {\n    const cr = X_MIN + (px / (RES - 1)) * (X_MAX - X_MIN);\n    let zr = 0;\n    let zi = 0;\n    let zr2 = 0;\n    let zi2 = 0;\n    let n = 0;\n    while (n < MAX_ITER && zr2 + zi2 <= 4) {\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 * RES + px) * 4;\n    if (n >= MAX_ITER) {\n      pixels[idx] = IN_SET_RGB[0];\n      pixels[idx + 1] = IN_SET_RGB[1];\n      pixels[idx + 2] = IN_SET_RGB[2];\n    } else {\n      // Smooth normalized iteration count (renormalized escape-time) avoids\n      // the discrete color banding a raw integer count produces at this\n      // max_iterations, per the spec's \"smooth coloring\" requirement.\n      const modulus = Math.sqrt(zr2 + zi2);\n      const nu = n + 1 - Math.log(Math.log(modulus)) / LOG2;\n      const f = Math.max(0, Math.min(1, nu / MAX_ITER));\n      pixels[idx] = Math.round(SEQ_LO[0] + (SEQ_HI[0] - SEQ_LO[0]) * f);\n      pixels[idx + 1] = Math.round(SEQ_LO[1] + (SEQ_HI[1] - SEQ_LO[1]) * f);\n      pixels[idx + 2] = Math.round(SEQ_LO[2] + (SEQ_HI[2] - SEQ_LO[2]) * f);\n    }\n    pixels[idx + 3] = 255;\n  }\n}\nctx.putImageData(imageData, 0, 0);\nconst RASTER_URL = canvas.toDataURL('image/png');\n\n// --- Title (fontsize scaled off the 67-char baseline) -----------------------\nconst TITLE_TEXT = 'heatmap-mandelbrot · javascript · highcharts · anyplot.ai';\nconst TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);\n\n// --- Fixed chart geometry -----------------------------------------------------\n// Margins chosen so left+right sums to top+bottom: on a square mount that\n// forces plotWidth === plotHeight, so the square raster maps onto the plot\n// area with no distortion (and the axis extents above are square too).\nconst CHART_MARGIN = [130, 130, 80, 80]; // [top, right, bottom, left]\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 drawAll() {\n  const chart = this;\n  clearDrawn();\n  const r = chart.renderer;\n\n  const rasterImage = r\n    .image(RASTER_URL, chart.plotLeft, chart.plotTop, chart.plotWidth, chart.plotHeight)\n    .attr({ zIndex: 2 })\n    .add();\n  drawn.push(rasterImage);\n  if (rasterImage.element) {\n    rasterImage.element.addEventListener('load', () => {\n      window.__anyplotReady = true;\n    });\n    if (rasterImage.element.complete) window.__anyplotReady = true;\n  }\n\n  // Escape-iteration colorbar in the freed right margin.\n  const barLeft = chart.plotLeft + chart.plotWidth + 30;\n  const barTop = chart.plotTop + 10;\n  const barWidth = 20;\n  const barHeight = chart.plotHeight - 46;\n  const segments = 60;\n  const segH = barHeight / segments;\n\n  for (let i = 0; i < segments; i++) {\n    const f = 1 - i / (segments - 1);\n    const red = Math.round(SEQ_LO[0] + (SEQ_HI[0] - SEQ_LO[0]) * f);\n    const green = Math.round(SEQ_LO[1] + (SEQ_HI[1] - SEQ_LO[1]) * f);\n    const blue = Math.round(SEQ_LO[2] + (SEQ_HI[2] - SEQ_LO[2]) * f);\n    drawn.push(\n      r\n        .rect(barLeft, barTop + i * segH, barWidth, segH + 0.5)\n        .attr({ fill: `rgb(${red},${green},${blue})`, zIndex: 2 })\n        .add()\n    );\n  }\n  drawn.push(\n    r\n      .rect(barLeft, barTop, barWidth, barHeight)\n      .attr({ fill: 'none', stroke: t.inkSoft, 'stroke-width': 1, zIndex: 2 })\n      .add()\n  );\n  drawn.push(\n    r\n      .rect(barLeft, barTop + barHeight + 16, barWidth, barWidth)\n      .attr({\n        fill: `rgb(${IN_SET_RGB[0]},${IN_SET_RGB[1]},${IN_SET_RGB[2]})`,\n        stroke: t.inkSoft,\n        'stroke-width': 1,\n        zIndex: 2,\n      })\n      .add()\n  );\n  [\n    ['Fast', 0],\n    ['Slow', 1],\n  ].forEach(([label, frac]) => {\n    drawn.push(\n      r\n        .text(label, barLeft + barWidth + 8, barTop + frac * barHeight + 5)\n        .attr({ align: 'left', zIndex: 2 })\n        .css({ color: t.inkSoft, fontSize: '13px' })\n        .add()\n    );\n  });\n  drawn.push(\n    r\n      .text('In set', barLeft + barWidth + 8, barTop + barHeight + 16 + barWidth / 2 + 5)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '13px' })\n      .add()\n  );\n  drawn.push(\n    r\n      .text('Iterations', barLeft, barTop - 16)\n      .attr({ align: 'left', zIndex: 2 })\n      .css({ color: t.inkSoft, fontSize: '14px', fontWeight: '500' })\n      .add()\n  );\n\n  // Feature callouts: the cardioid and period-2 bulb are visible in the raster\n  // but unlabeled — a light callout naming each one strengthens the data\n  // storytelling on top of the spec-required visual (cardioid cusp is the\n  // real point c=0.25; the period-2 bulb is the circle centered at c=-1).\n  const toPx = (x, y) => [chart.xAxis[0].toPixels(x, false), chart.yAxis[0].toPixels(y, false)];\n  const addCallout = (text, boxX, boxY, anchorX, anchorY) => {\n    const [bx, by] = toPx(boxX, boxY);\n    const [ax, ay] = toPx(anchorX, anchorY);\n    drawn.push(\n      r\n        .label(text, bx, by, 'callout', ax, ay)\n        .attr({ fill: t.elevatedBg, stroke: t.inkSoft, 'stroke-width': 1, r: 4, padding: 6, zIndex: 3 })\n        .css({ color: t.ink, fontSize: '13px' })\n        .add()\n    );\n  };\n  addCallout('Cardioid', -0.05, 0.85, 0.15, 0.25);\n  addCallout('Period-2 bulb', -1.55, 0.55, -1.05, 0.2);\n}\n\nHighcharts.chart('container', {\n  chart: {\n    backgroundColor: 'transparent',\n    animation: false,\n    style: { fontFamily: 'inherit' },\n    margin: CHART_MARGIN,\n    events: { load: drawAll, redraw: drawAll },\n  },\n  credits: { enabled: false },\n  title: {\n    text: TITLE_TEXT,\n    style: { color: t.ink, fontSize: TITLE_FS + 'px', fontWeight: '600' },\n  },\n  subtitle: {\n    text: `z(n+1) = z(n)² + c · max iterations = ${MAX_ITER}`,\n    style: { color: t.inkSoft, fontSize: '14px' },\n  },\n  xAxis: {\n    title: { text: 'Re(c)', style: { color: t.inkSoft, fontSize: '16px' } },\n    min: X_MIN,\n    max: X_MAX,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: '14px' } },\n  },\n  yAxis: {\n    title: { text: 'Im(c)', style: { color: t.inkSoft, fontSize: '16px' } },\n    min: Y_MIN,\n    max: Y_MAX,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: '14px' } },\n  },\n  legend: { enabled: false },\n  tooltip: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  // A chart with zero series bound to the x/y axes never renders those axes at\n  // all (Highcharts skips Cartesian axis rendering when hasCartesianSeries is\n  // false) — this invisible corner-anchored series is what makes the Re(c) /\n  // Im(c) axes (and their tick labels) actually draw.\n  series: [\n    {\n      type: 'scatter',\n      data: [\n        { x: X_MIN, y: Y_MIN },\n        { x: X_MAX, y: Y_MAX },\n      ],\n      marker: { enabled: false },\n      enableMouseTracking: false,\n      showInLegend: false,\n    },\n  ],\n});\n"}