{"spec_id":"heatmap-mandelbrot","library":"muix","language":"javascript","code":"// anyplot.ai\n// heatmap-mandelbrot: Mandelbrot Set Fractal Visualization\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-08-25\n//# anyplot-orientation: square\n// anyplot.ai\n// heatmap-mandelbrot: Mandelbrot Set Fractal Visualization\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-08-25\n\nimport * as React from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Complex-plane window (spec defaults, cropped to a square aspect so both\n// axes share one scale — the classic cardioid + period-2 bulb sit fully inside) ---\nconst X_MIN = -2.0;\nconst X_MAX = 0.5;\nconst Y_MIN = -1.25;\nconst Y_MAX = 1.25;\nconst MAX_ITERATIONS = 100;\nconst ESCAPE_RADIUS_SQ = 4;\n\n// --- Imprint sequential colormap: seq[0] (brand green, fast escape) -> seq[1]\n// (blue, slow escape); points that never escape get a fixed near-black swatch\n// — a hard-coded data color (not the theme-adaptive `ink` chrome token) so it\n// stays identical between light and dark renders, distinct from the gradient.\nfunction hexRgb(hex: string): [number, number, number] {\n  return [\n    parseInt(hex.slice(1, 3), 16),\n    parseInt(hex.slice(3, 5), 16),\n    parseInt(hex.slice(5, 7), 16),\n  ];\n}\nconst SEQ_LOW = hexRgb(t.seq[0]);\nconst SEQ_HIGH = hexRgb(t.seq[1]);\nconst INTERIOR_HEX = \"#1A1A17\";\nconst INTERIOR_RGB = hexRgb(INTERIOR_HEX);\n\nfunction escapeColor(frac: number): [number, number, number] {\n  return [\n    Math.round(SEQ_LOW[0] + (SEQ_HIGH[0] - SEQ_LOW[0]) * frac),\n    Math.round(SEQ_LOW[1] + (SEQ_HIGH[1] - SEQ_LOW[1]) * frac),\n    Math.round(SEQ_LOW[2] + (SEQ_HIGH[2] - SEQ_LOW[2]) * frac),\n  ];\n}\n\n// Most exterior points escape within the first handful of iterations, so a\n// linear ramp spends nearly all of its range on the interior's near-boundary\n// halo. A gamma < 1 stretches the low end across more of the ramp, pulling\n// the filamentary detail near the set's boundary out of a flat green field.\nconst COLOR_GAMMA = 0.42;\nfunction gammaFrac(frac: number): number {\n  return Math.pow(Math.max(0, Math.min(1, frac)), COLOR_GAMMA);\n}\n\n// --- Raster fractal layer -----------------------------------------------------\n// MUI X Charts' community package has no per-pixel raster primitive (Heatmap is\n// Pro-only), so the fractal field is painted onto a plain <canvas> — not a\n// competing charting library, just the browser's 2D drawing API — sized and\n// positioned from MUI X's own drawing-area context so it exactly fills the\n// axes' plot rectangle. Escape-time colour, axes, ticks and the legend below\n// are all genuine MUI X / Imprint pieces.\nfunction MandelbrotCanvas() {\n  const { left, top, width: areaW, height: areaH } = useDrawingArea();\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n\n  React.useLayoutEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || areaW <= 0 || areaH <= 0) return;\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const pxW = Math.max(1, Math.round(areaW * dpr));\n    const pxH = Math.max(1, Math.round(areaH * dpr));\n    canvas.width = pxW;\n    canvas.height = pxH;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const image = ctx.createImageData(pxW, pxH);\n    const data = image.data;\n\n    for (let py = 0; py < pxH; py++) {\n      const im0 = Y_MAX - (py / (pxH - 1)) * (Y_MAX - Y_MIN);\n      for (let px = 0; px < pxW; px++) {\n        const re0 = X_MIN + (px / (pxW - 1)) * (X_MAX - X_MIN);\n\n        let zr = 0;\n        let zi = 0;\n        let n = 0;\n        while (n < MAX_ITERATIONS && zr * zr + zi * zi <= ESCAPE_RADIUS_SQ) {\n          const zr2 = zr * zr - zi * zi + re0;\n          zi = 2 * zr * zi + im0;\n          zr = zr2;\n          n++;\n        }\n\n        const idx = (py * pxW + px) * 4;\n        if (n >= MAX_ITERATIONS) {\n          data[idx] = INTERIOR_RGB[0];\n          data[idx + 1] = INTERIOR_RGB[1];\n          data[idx + 2] = INTERIOR_RGB[2];\n        } else {\n          // Smooth (renormalized) escape count — a continuous colour ramp with\n          // no discrete banding between adjacent iteration counts.\n          const magSq = zr * zr + zi * zi;\n          const smoothN =\n            n + 1 - Math.log(Math.log(Math.sqrt(magSq))) / Math.LN2;\n          const frac = Math.max(0, Math.min(1, smoothN / MAX_ITERATIONS));\n          const [r, g, b] = escapeColor(gammaFrac(frac));\n          data[idx] = r;\n          data[idx + 1] = g;\n          data[idx + 2] = b;\n        }\n        data[idx + 3] = 255;\n      }\n    }\n    ctx.putImageData(image, 0, 0);\n  }, [areaW, areaH]);\n\n  return (\n    <foreignObject x={left} y={top} width={areaW} height={areaH}>\n      <canvas\n        ref={canvasRef}\n        style={{ width: \"100%\", height: \"100%\", display: \"block\" }}\n      />\n    </foreignObject>\n  );\n}\n\n// --- Legend: escape-time colour ramp + a swatch for the bounded interior ------\n// Anchored well below the drawing area's bottom edge (past the x-axis's own\n// tick labels + axis-title chrome) so the two never collide.\nfunction Legend() {\n  const { left, top, width: areaW, height: areaH } = useDrawingArea();\n  const drawingBottom = top + areaH;\n  const barX = left;\n  const barW = areaW;\n  const barH = 26;\n  const titleY = drawingBottom + 130;\n  const barY = titleY + 16;\n  const endLabelY = barY + barH + 26;\n  const swatchSize = 26;\n  const swatchY = endLabelY + 28;\n  const swatchX = barX + barW / 2 - swatchSize / 2;\n\n  return (\n    <>\n      <defs>\n        <linearGradient id=\"mandelbrotRamp\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n          {[0, 0.25, 0.5, 0.75, 1].map((stop) => {\n            const [r, g, b] = escapeColor(gammaFrac(stop));\n            return (\n              <stop\n                key={stop}\n                offset={`${stop * 100}%`}\n                stopColor={`rgb(${r},${g},${b})`}\n              />\n            );\n          })}\n        </linearGradient>\n      </defs>\n      <text\n        x={barX + barW / 2}\n        y={titleY}\n        textAnchor=\"middle\"\n        fontSize={14}\n        fill={t.ink}\n      >\n        Escape-time colour scale (max {MAX_ITERATIONS} iterations)\n      </text>\n      <rect\n        x={barX}\n        y={barY}\n        width={barW}\n        height={barH}\n        fill=\"url(#mandelbrotRamp)\"\n        rx={4}\n      />\n      <text\n        x={barX}\n        y={endLabelY}\n        textAnchor=\"start\"\n        fontSize={13}\n        fill={t.inkSoft}\n      >\n        fast escape\n      </text>\n      <text\n        x={barX + barW}\n        y={endLabelY}\n        textAnchor=\"end\"\n        fontSize={13}\n        fill={t.inkSoft}\n      >\n        slow escape\n      </text>\n      {/* Fixed fill color happens to equal the dark theme's page background\n          (#1A1A17), so the swatch would vanish on dark without a border. The\n          stroke is chrome (theme-adaptive ink), not a data color, so it stays\n          within the \"only chrome flips\" rule while guaranteeing visibility. */}\n      <rect\n        x={swatchX}\n        y={swatchY}\n        width={swatchSize}\n        height={swatchSize}\n        fill={INTERIOR_HEX}\n        stroke={t.ink}\n        strokeWidth={1}\n        rx={4}\n      />\n      <text\n        x={swatchX - 12}\n        y={swatchY + swatchSize / 2 + 5}\n        textAnchor=\"end\"\n        fontSize={13}\n        fill={t.inkSoft}\n      >\n        bounded (never escapes) —\n      </text>\n    </>\n  );\n}\n\nfunction ChartTitle() {\n  const { top } = useDrawingArea();\n  return (\n    <>\n      <text\n        x={width / 2}\n        y={top - 56}\n        textAnchor=\"middle\"\n        fontSize={24}\n        fontWeight={600}\n        fill={t.ink}\n      >\n        heatmap-mandelbrot · javascript · muix · anyplot.ai\n      </text>\n      <text\n        x={width / 2}\n        y={top - 30}\n        textAnchor=\"middle\"\n        fontSize={14}\n        fill={t.inkSoft}\n      >\n        z(n+1) = z(n)² + c · escape-time coloured over the complex plane\n      </text>\n    </>\n  );\n}\n\nconst MARGIN = { top: 140, right: 90, bottom: 320, left: 110 };\n\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={width}\n      height={height}\n      series={[]}\n      skipAnimation\n      margin={MARGIN}\n      xAxis={[\n        {\n          scaleType: \"linear\",\n          min: X_MIN,\n          max: X_MAX,\n          label: \"Re(c)\",\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          labelStyle: { fontSize: 16, fill: t.ink },\n        },\n      ]}\n      yAxis={[\n        {\n          scaleType: \"linear\",\n          min: Y_MIN,\n          max: Y_MAX,\n          label: \"Im(c)\",\n          // ChartsYAxis positions the rotated axis label at a fixed offset of\n          // `tickFontSize + tickSize + 10` from the axis line — not the actual\n          // measured width of the tick text — so with the default tickFontSize\n          // (12) the label sat almost on top of wide tick labels like \"-0.2\".\n          // tickLabelStyle.fontSize below still controls the *rendered* tick\n          // font size; bumping this deprecated prop only pushes the label\n          // further left to clear the tick text.\n          tickFontSize: 40,\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          labelStyle: { fontSize: 16, fill: t.ink },\n        },\n      ]}\n    >\n      <ChartTitle />\n      <MandelbrotCanvas />\n      <ChartsXAxis />\n      <ChartsYAxis />\n      <Legend />\n    </ChartContainer>\n  );\n}\n"}