Infinite Mandelbrot

A Mandelbrot exlorer with 10^300 depth. Demonstrates Web workers, WebAssembly, WebGL2, custom urls.

---
format: typebulb/v1
name: Infinite Mandelbrot
---

**code.tsx**

```tsx
/**
 * A Mandelbrot exlorer with 10^300 depth.
 * This is an example of non trivial app.
 * Web workers, WebAssembly, WebGL2.
 */

import { App, Component, div, h1, button, inputSelect, canvas, svg, path, circle, line, type UpdateEvent, type HValues, type VAttributes} from "domeleon"
import Decimal from "decimal.js"

function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): (...args: Parameters<T>) => void {
  let t: number | undefined
  return function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
    if (t !== undefined) clearTimeout(t)
    t = window.setTimeout(() => fn.apply(this, args), ms)
  }
}

function getUrlParam(key: string): string | null {
  return new URLSearchParams(window.location.search).get(key)
}

function parseDecimal(str: string | null, defaultValue: Decimal): Decimal {
  if (!str) return defaultValue
  try {
    const val = new Decimal(str)
    return val.isNaN() ? defaultValue : val
  } catch {
    return defaultValue
  }
}

function parseInteger(
  str: string | null,
  defaultValue: number,
  constraints?: { min?: number; max?: number }
): number {
  const val = str ? parseInt(str) : NaN
  if (Number.isNaN(val)) return defaultValue
  const { min = -Infinity, max = Infinity } = constraints ?? {}
  return Math.min(Math.max(val, min), max)
}

function clearCanvas(canvasEl: HTMLCanvasElement): void {
  const ctx = canvasEl.getContext("2d")
  if (ctx) ctx.clearRect(0, 0, canvasEl.width, canvasEl.height)
}

/* How many frames a tween needs to read as motion rather than as a broken jump,
 * and how many measured frames before the full bar is applied. */
const TWEEN_MIN_FRAMES = 8
const TWEEN_PROBE_FRAMES = 3
/* The controls split two ways on their own: the iteration cap is a setting that
 * needs its name spelled out (a nameless dropdown reads as nothing), and the
 * other three are actions with an unambiguous glyph. So the actions are icons at
 * a third of the width a word would cost, which is also what lets the whole panel
 * be one row rather than a 2x2 block. The name survives as the tooltip and the
 * accessible label; only the visible word is spent. */
function icon(...children: HValues[]) {
  return svg({
    class: "icon", viewBox: "0 0 24 24", width: 20, height: 20,
    fill: "none", stroke: "currentColor", strokeWidth: 1.8,
    strokeLineCap: "round", strokeLineJoin: "round"
  }, ...children)
}

function iconButton(label: string, onClick: () => void, ...children: HValues[]) {
  return button({ class: "controlButton iconButton", title: label, ariaLabel: label, onClick }, icon(...children))
}

/* An icon button that is a mode rather than an action, so it has to read as on or
 * off while you are not touching it. `aria-pressed` carries that to assistive tech
 * and `.on` carries it to the eye - see the CSS for why it inverts rather than
 * merely brightening. */
function toggleButton(label: string, pressed: boolean, onClick: () => void, ...children: HValues[]) {
  return button({
    class: "controlButton iconButton" + (pressed ? " on" : ""),
    title: label, ariaLabel: label, ariaPressed: pressed, onClick
  }, icon(...children))
}

type TipState = "not_shown" | "shown" | "dismissed"

class Tip extends Component {
  element?: HTMLElement;
  tipState: TipState = "not_shown"

  view(attrs: Record<string, any>, ...content: HValues[]) {
    return div({
      ...attrs,
      onMounted: e => {
        this.element = e as HTMLElement
        this.element.style.opacity = "0"
        this.element.style.visibility = "hidden"
      }
    }, content);
  }

  show(): void {
    if (this.tipState === "not_shown") {
      this.tipState = "shown"
      this.element.style.opacity = "1"
      this.element.style.visibility = "visible"
    }
  }

  hide(): void {
    if (this.tipState === "shown") {
      this.tipState = "dismissed"
      this.element.style.opacity = "0"
      this.element.style.visibility = "hidden"
    }
  }
}

interface Coords { x: number; y: number; }
interface Rect { left: number; top: number; width: number; height: number; }
type PointerEventLike = PointerEvent | TouchEvent

class Marquee extends Component {
  active = false;
  startPos: Coords = { x: 0, y: 0 }
  currentPos: Coords = { x: 0, y: 0 }
  canvasEl?: HTMLCanvasElement
  onStart?: () => void
  onEnd?: (rect: Rect) => void

  view(attrs: VAttributes) {
    return canvas({
      ...attrs,
      onPointerDown: e => this.handlePointerDown(e),
      onPointerMove: e => this.handlePointerMove(e),
      onPointerUp: e => this.handlePointerUp(e),
      onMounted: el => {
        this.canvasEl = el as HTMLCanvasElement;
        el.addEventListener("touchstart", e => { e.preventDefault(); this.handlePointerDown(e as any); }, { passive: false })
        el.addEventListener("touchmove", e => { e.preventDefault(); this.handlePointerMove(e as any); }, { passive: false })
        el.addEventListener("touchend", e => { e.preventDefault(); this.handlePointerUp(e as any); }, { passive: false })
      }
    });
  }

  getEventCoords(e: any): Coords {
    const rect = this.canvasEl.getBoundingClientRect()
    if (e.touches && e.touches.length) {
      return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top }
    } else if (e.changedTouches && e.changedTouches.length) {
      return { x: e.changedTouches[0].clientX - rect.left, y: e.changedTouches[0].clientY - rect.top }
    }
    return { x: e.clientX - rect.left, y: e.clientY - rect.top }
  }

  handlePointerDown(e: any): void {
    const coords = this.getEventCoords(e)
    this.active = true
    this.startPos = { ...coords }
    this.currentPos = { ...coords }
    clearCanvas(this.canvasEl)
    this.onStart && this.onStart()
  }

  handlePointerMove(e: PointerEventLike): void {
    if (!this.active) return
    this.currentPos = this.getEventCoords(e)
    this.draw()
  }

  handlePointerUp(e: PointerEventLike): void {
    if (!this.active) return
    this.active = false
    const rect = this.getRect()
    clearCanvas(this.canvasEl)
    this.onEnd && this.onEnd(rect)
  }

  draw(): void {
    if (!this.canvasEl) return
    const ctx = this.canvasEl.getContext("2d")
    clearCanvas(this.canvasEl)
    const left = Math.min(this.startPos.x, this.currentPos.x)
    const top = Math.min(this.startPos.y, this.currentPos.y)
    const width = Math.abs(this.currentPos.x - this.startPos.x)
    const height = Math.abs(this.currentPos.y - this.startPos.y)
    ctx.save()
    ctx.strokeStyle = "#444"
    ctx.lineWidth = 2
    ctx.setLineDash([6])
    ctx.strokeRect(left, top, width, height)
    ctx.restore()
  }

  getRect(): Rect {
    return {
      left: Math.min(this.startPos.x, this.currentPos.x),
      top: Math.min(this.startPos.y, this.currentPos.y),
      width: Math.abs(this.currentPos.x - this.startPos.x),
      height: Math.abs(this.currentPos.y - this.startPos.y)
    }
  }
}

class MandelbrotExplorer extends Component {
  defaultCenterX = new Decimal(-0.75)
  defaultCenterY = new Decimal(0.0)
  defaultZ = new Decimal(1)
  centerX?: Decimal
  centerY?: Decimal
  z?: Decimal
  pleasantCanvasRatio: number

  canvasWidth = window.innerWidth
  canvasHeight = window.innerHeight

  /* Must be one of iterationOptions: onUpdated writes maxIter straight to the
   * select, and a value not among the options leaves it showing blank. */
  maxIterDefault = 10000
  maxIter = this.maxIterDefault
  colorMax = 1200
  colorScale = 24.2
  animating = false
  rendering = false
  zoomOutFactor = 2
  maxZ = new Decimal(1E300)
  prevZ = undefined

  /* When set, the reference orbit is built here instead of at the view centre.
   * The orbit then survives a change of centre, which is the whole point: a zoom
   * tween can build one orbit at its target and reuse it for every frame. */
  refPoint?: { x: Decimal, y: Decimal }
  /* Fixed for the duration of a tween: the enlarged radius the one shared orbit
   * was built for. Holding it constant is what lets the orbit be reused - the
   * orbit table itself does not depend on the radius, but polylim and the poly
   * scaling do, so a changing radius would force a rebuild anyway. */
  tweenRRef?: Decimal

  zoomStack: any[] = []
  zoomDuration = 600

  tip = new Tip()
  marquee = new Marquee()
  mandelbrotRenderer?: MandelbrotRenderer
  canvasEl: HTMLCanvasElement

  get zActual () { return this.z.times(this.pleasantCanvasRatio) }
  set zActual (value) { this.z = value.dividedBy(this.pleasantCanvasRatio) }

  /* Whether zooms animate at all. Off by hand, from the strip; there is no
   * automatic refusal any more.
   *
   * It was `maxIter <= 5000` - a guess about cost from a number that does not
   * predict it. Two views at the same cap differ by 10x in frame time, and the
   * same view can be slower at a *lower* cap when everything runs to the limit.
   *
   * There is no up-front *predictor* at all now. Every one that has been tried was
   * biased toward refusing: a smoothed tween cost is one global number, so a
   * reading taken at 4.11e45 suppresses animation at z=300 where tweens run at
   * 65 fps; and predicting from the last settled frame charges the tween for a
   * reference orbit build that its steady frames do not pay, which at depth is
   * 57-81% of a frame. Start the tween, measure it, abort it if it is not
   * delivering - the cost of guessing wrong is one frame.
   *
   * The manual switch is not a fifth attempt at that prediction. It is for wanting
   * the picture without the flight, which no measurement can decide. */
  noAnimation = false
  get zoomAnimationsEnabled () { return !this.noAnimation }

  toggleAnimation() {
    this.noAnimation = !this.noAnimation
    this.update()
  }

  // The last tween's measurements, for the tweenstats probe.
  /* Reference orbit build time of the last frame rendered - 0 when the frame
   * reused a shared one. The tween's budget needs it: frame 1 pays it, frames 2+
   * do not, so frame1 - lastOrbitMs is a steady frame. */
  lastOrbitMs = 0
  lastTween?: {
    frames: number, first: number, firstOrbit: number, steady: number, settled: number,
    fps: number, aborted: boolean,
    mean: number, meanWouldAbort: boolean
  }

  busy () {
    return this.animating || this.rendering
  }

  /* Re-express the view as a delta from an arbitrary reference point.
   *
   * The orbit's radius is enlarged to reach the far corner of the view from the
   * reference, so every pixel still satisfies |dc| <= rRef and the series
   * approximation's convergence test stays honest. Offset and scale are ratios of
   * comparable magnitudes, so they carry full float32 precision however deep the
   * view is - the arbitrary precision all stays on this side. */
  refFrame() {
    const r = new Decimal(1).dividedBy(this.zActual)
    if (!this.refPoint) return { r: r.toString() }
    const dx = this.centerX!.minus(this.refPoint.x)
    const dy = this.centerY!.minus(this.refPoint.y)
    const rRef = this.tweenRRef ?? Decimal.max(dx.abs(), dy.abs()).plus(r)
    return {
      r: r.toString(),
      refX: this.refPoint.x.toString(), refY: this.refPoint.y.toString(),
      rRef: rRef.toString(),
      dcOffset: [dx.dividedBy(rRef).toNumber(), dy.dividedBy(rRef).toNumber()],
      dcScale: r.dividedBy(rRef).toNumber(),
      shareOrbit: !!this.tweenRRef
    }
  }

  onAttached() {
    setTimeout(() => this.init(), 1)
    window.addEventListener("resize", debounce(() => this.updateLayout(), 100))
  }

  async init() {                             
    this.mandelbrotRenderer = new MandelbrotRenderer()
    await this.mandelbrotRenderer.ready()
    this.marquee.onStart = () => this.tip.show()
    this.marquee.onEnd = rect => this.processMarquee(rect)
    this.updateLayout(() => this.initFromURL())
  }

  updateLayout(afterLayout?: () => void) {
    this.canvasWidth = this.canvasEl.width = this.marquee.canvasEl.width = window.innerWidth
    this.canvasHeight = this.canvasEl.height = this.marquee.canvasEl.height = window.innerHeight

    this.pleasantCanvasRatio = this.canvasWidth / this.canvasHeight >= 1 ? 0.8 : 0.6

    if (afterLayout)
      afterLayout()

    this.renderFractal()
  }

  initFromURL() {          
    this.centerX = parseDecimal(getUrlParam("x"), new Decimal(this.defaultCenterX))
    this.centerY = parseDecimal(getUrlParam("y"), new Decimal(this.defaultCenterY))
    this.z = this.prevZ = parseDecimal(getUrlParam("z"), new Decimal(this.defaultZ))
    this.maxIter = parseInteger(getUrlParam("iterations"), this.maxIterDefault, { min: 100, max: 1000000 })
    this.update()
  }

  zArg() {
    return this.z.lessThan(1000000) ?
            this.z.toSignificantDigits(3).toString() :
            this.z.toSignificantDigits(3).toExponential().replace("+", "")
  }

  getParamString() {
    const params = new URLSearchParams()
    const sf = this.z.toFixed(2).length
    params.set("z", this.zArg())
    params.set("iterations", "" + this.maxIter)
    params.set("x", this.centerX.toSignificantDigits(sf).toString())
    params.set("y", this.centerY.toSignificantDigits(sf).toString())
    return params.toString()
  }

  updateURL() {
    if (
      this.centerX.equals(this.defaultCenterX) &&
      this.centerY.equals(this.defaultCenterY) &&
      this.z.equals(this.defaultZ) &&
      this.maxIter === this.maxIterDefault
    ) {
      history.replaceState(null, "", window.location.pathname)
    } else {
      history.replaceState(null, "", window.location.pathname + "?" + this.getParamString())
    }
  }

  onUpdated(event: UpdateEvent) {
    const sel = document.getElementById("maxIter") as HTMLSelectElement
    if (event.key == "maxIter") {
      this.renderFractal()
      sel.blur()
    }
    sel.value =""+this.maxIter
  }

  async renderFractal(options?: any) {                    
    const zoomingIn = this.prevZ && this.prevZ.lessThan(this.z);
    const zoomingOut = this.prevZ && this.prevZ.greaterThan(this.z)

    if (!this.animating) {
      Decimal.set({ precision: Math.max(this.prevZ.e, this.zActual.e) + 6 })
      this.prevZ = this.z
    }                              
    if (zoomingOut && this.z.lessThan(this.defaultZ.add(0.01))) {
      this.reset()
    } else {                 
      const args = {
        width: this.canvasWidth,
        height: this.canvasHeight,
        x: this.centerX.toString(),
        y: this.centerY.toString(),
        iterations: this.maxIter,
        colorScale: this.colorScale,
        colorMax: this.colorMax,
        lowRes: this.animating,
        // supplies r, and during a tween the shared off-centre reference as well
        ...this.refFrame(),
        ...options
      }
      try {
        this.rendering = true
        await this.mandelbrotRenderer.startRenderer(args)
        const ctx = this.canvasEl.getContext("2d")
        while (true) {
          const { done, value } = await this.mandelbrotRenderer.nextFrame()
          if (done) break
          const { bitmap, sourceRect, destinationRect, progress, orbitMs } = value
          if (orbitMs != null) this.lastOrbitMs = orbitMs
          if (bitmap) {
            ctx.drawImage(bitmap,
              sourceRect.x, sourceRect.y, sourceRect.width, sourceRect.height,
              destinationRect.x, destinationRect.y, destinationRect.width, destinationRect.height 
            )
          }                
        }
      }
      catch (e) {
        console.error(e)
      }
      finally {
        this.rendering = false
      }
      if (!this.animating) {
        this.updateURL()
      }            
    }
  }

  processMarquee(rect) {
    const { left, top, width, height } = rect
    if (width < 5 || height < 5) return
    this.tip.hide()

    const aspect = this.canvasWidth / this.canvasHeight

    const pixelToFractal = (px, py) => {
      const normX = (px + 0.5) / this.canvasWidth
      const normY = (py + 0.5) / this.canvasHeight
      const canvasX = normX * (aspect >= 1 ? 2 * aspect : 2) + (aspect >= 1 ? -aspect : -1)
      const canvasY = normY * (aspect >= 1 ? -2 : -2 / aspect) + (aspect >= 1 ? 1 : 1 / aspect)
      const fractalX = this.centerX.plus(new Decimal(canvasX).dividedBy(this.zActual))
      const fractalY = this.centerY.plus(new Decimal(canvasY).dividedBy(this.zActual))
      return { x: fractalX, y: fractalY }
    };

    const p1 = pixelToFractal(left, top)
    const p2 = pixelToFractal(left + width, top + height)

    const newCenterX = p1.x.plus(p2.x).dividedBy(2)
    const newCenterY = p1.y.plus(p2.y).dividedBy(2)

    const fractalWidth = p2.x.minus(p1.x).abs()
    const fractalHeight = p1.y.minus(p2.y).abs()

    const newActualWidth = fractalWidth.dividedBy(aspect >= 1 ? (2 * aspect) : 2)
    const newActualHeight = fractalHeight.dividedBy(aspect >= 1 ? 2 : (2 / aspect))

    const newR = Decimal.max(newActualWidth, newActualHeight)
    const newZActual = new Decimal(1).dividedBy(newR)

    if (newZActual.greaterThan(this.maxZ) || !newZActual.isFinite()) return

    if (!this.zoomAnimationsEnabled) {
      this.centerX = newCenterX
      this.centerY = newCenterY
      this.zActual = newZActual
      this.renderFractal()
    } else {
      const prevState = { centerX: this.centerX, centerY: this.centerY, zActual: this.zActual }
      const targetState = { centerX: newCenterX, centerY: newCenterY, zActual: newZActual }
      this.zoomStack.push({ prevState, targetState })
      this.animateZoom(prevState, targetState)
    }
  }

  animateZoom(fromState, toState): Promise<void> {
    this.animating = true
    const zoom = toState.zActual.dividedBy(fromState.zActual)
    const startTime = performance.now()
    let settle: () => void
    const done = new Promise<void>(r => { settle = r })

    /* One reference orbit for the whole tween, built at the destination and held
     * still while the view moves onto it. The radius has to bound every frame,
     * not just the first: the centre never leaves the segment between the two
     * ends, and the radius never leaves the interval between them, so the
     * separation plus the wider of the two ends covers all of them. The tween
     * runs both ways - zoomOut animates back up the stack - and taking only the
     * starting radius would leave the last frames of an outward tween sitting
     * outside the radius their series approximation was validated against. */
    const r0 = new Decimal(1).dividedBy(fromState.zActual)
    const r1 = new Decimal(1).dividedBy(toState.zActual)
    const ddx = fromState.centerX.minus(toState.centerX)
    const ddy = fromState.centerY.minus(toState.centerY)
    this.refPoint = { x: toState.centerX, y: toState.centerY }
    this.tweenRRef = Decimal.max(ddx.abs(), ddy.abs()).plus(Decimal.max(r0, r1))

    /* Measured, not predicted. Each entry is one frame's own duration, taken
     * around the awaited render below, so a tick is a delivered frame. */
    const frameMs: number[] = []
    // Orbit build charged to each frame; only frame 1 of a shared tween pays it.
    const frameOrbitMs: number[] = []
    let aborted = false

    /* The steady rate, which is deliberately not the mean.
     *
     * Frame 1 builds the reference orbit that every later frame reuses - at depth
     * that is most of its cost - so averaging it in reads a startup price as the
     * running rate. That alone is enough to abort tweens whose real rate is fine.
     *
     * min rather than mean over the rest, because frame times here carry
     * unexplained outliers an order of magnitude above their own median, and a
     * mean lets one of those veto a whole tween. A minimum is the optimistic
     * reading, which is the right direction to be wrong when the failure being
     * fixed is refusing to animate something that would have been fine. */
    const steadyMs = () => frameMs.length > 1 ? Math.min(...frameMs.slice(1)) : frameMs[0]

    const animateFrame = async now => {
      let t = (now - startTime) / this.zoomDuration
      if (t > 1) t = 1
      /* requestAnimationFrame hands back the timestamp of the frame it belongs to,
       * which can predate the performance.now() taken just before scheduling it -
       * so the first t of an animation is routinely slightly negative. Left
       * unclamped that runs the tween backwards for one frame, and from the home
       * view the momentary dip below defaultZ trips renderFractal's zoomingOut
       * reset. It also corrupts the frame the measurement starts from. */
      if (t < 0) t = 0

      /* Abort rather than stutter: a tween that cannot deliver TWEEN_MIN_FRAMES
       * reads as a broken jump, which is worse than the honest cut it would
       * otherwise have been.
       *
       * Two estimators, one bar. Once two steady frames exist, project from their
       * minimum - that is the real answer. Before that, project from the first
       * frame with its measured orbit build subtracted, which is exactly what a
       * steady frame will cost, since frames 2+ reuse that orbit.
       *
       * Subtracting a *measured* term rather than a guessed share of one: at a high
       * iteration cap the draw dominates and the orbit is a rounding error, at
       * depth with a cheap draw it is most of the frame. A fixed 80% share was
       * tried and let 1e7 / 200K through at 4 fps, because it discounted a startup
       * cost that view did not have.
       *
       * Both are needed, and each covers what the other cannot. "One frame must
       * not outlast the tween" is too weak: measured at 1e7 / 200K, frames cost
       * 545 and 330 ms, neither outlasting a 600 ms tween on its own, but together
       * ending it - so the wall clock reached t = 1 with three frames at 3 fps and
       * nothing was ever cut. Waiting for the steady frames is likewise too slow
       * for exactly that view. And judging the full bar on a *single* steady frame
       * is too harsh: min gets more optimistic with more samples, so an early
       * verdict is a systematically stricter one, and it flipped 1e7 / 50K from
       * nine delivered frames to a cut on nothing but thermal drift. */
      if (t < 1 && frameMs.length >= 1) {
        const projected = frameMs.length >= TWEEN_PROBE_FRAMES
          ? this.zoomDuration / steadyMs()
          : this.zoomDuration / Math.max(1, frameMs[0] - frameOrbitMs[0])
        if (projected < TWEEN_MIN_FRAMES) { t = 1; aborted = true }
      }

      const interpolatedZoomFactor = new Decimal(1).plus(new Decimal(t).times(zoom.minus(1)))
      const factor = new Decimal(t).times(zoom).dividedBy(interpolatedZoomFactor)

      this.centerX = fromState.centerX.minus(factor.times(fromState.centerX.minus(toState.centerX)))
      this.centerY = fromState.centerY.minus(factor.times(fromState.centerY.minus(toState.centerY)))
      this.zActual = fromState.zActual.times(interpolatedZoomFactor)

      if (t === 1) {
        this.animating = false
        // The settled frame gets its own centred orbit and full accuracy.
        this.refPoint = undefined
        this.tweenRRef = undefined
      }

      /* Wait for the frame before asking for the next one. Scheduling the next
       * rAF immediately starts renders faster than they finish, and takeLatest
       * quietly drops the surplus - pure work thrown away, and every per-frame
       * measurement becomes the duration of an overlapping call rather than of a
       * frame. Serialised, the tween still ends on time (t is wall-clock, not a
       * frame count); it simply draws fewer, real ones. */
      const t0 = performance.now()
      await this.renderFractal({forcePreview: t == 1})
      frameMs.push(performance.now() - t0)
      frameOrbitMs.push(this.lastOrbitMs)

      if (t < 1) {
        requestAnimationFrame(animateFrame)
      } else {
        /* Reported steady rate, which is not steadyMs(). By here the settled
         * full-resolution frame has been pushed too, and on an aborted tween that
         * is the *only* entry past frame 1 - so steadyMs() would report a 1278 ms
         * settled frame as the tween's rate. Drop both ends: frame 1 carries the
         * orbit, the last carries the settled render. With nothing left in between
         * fall back to the estimate the decision was actually made on. */
        const between = frameMs.slice(1, -1)
        const steady = between.length ? Math.min(...between)
                                      : Math.max(1, frameMs[0] - frameOrbitMs[0])
        /* What the rule this replaced would have decided, on this tween's own
         * frames: a running mean whose first term is the orbit-building frame,
         * judged at TWEEN_PROBE_FRAMES against the full bar. Reported, never acted
         * on - it makes the change an A/B on identical data rather than an
         * argument. Conservative in the old rule's favour: it divided elapsed wall
         * clock by frames, so it also carried the idle between them, and was
         * therefore always at least this large. */
        const probe = frameMs.slice(0, TWEEN_PROBE_FRAMES)
        const mean = probe.reduce((a, b) => a + b, 0) / probe.length
        const meanWouldAbort = frameMs.length >= TWEEN_PROBE_FRAMES &&
                               this.zoomDuration / mean < TWEEN_MIN_FRAMES
        this.lastTween = {
          frames: frameMs.length,
          first: Math.round(frameMs[0]),
          firstOrbit: Math.round(frameOrbitMs[0]),
          steady: Math.round(steady),
          // The t=1 frame: a full-resolution settled render, not a tween frame.
          settled: Math.round(frameMs[frameMs.length - 1]),
          fps: Math.round(1000 / steady),
          aborted,
          mean: Math.round(mean),
          meanWouldAbort
        }
        tb.log("tween", this.lastTween)
        settle()
      }
    }

    requestAnimationFrame(animateFrame)
    return done
  }

  /* Probe: animate to a pinned view and hand back what the tween measured. The
   * only way in from the terminal - a real tween needs a pointer drag, and
   * zoomOut with an empty stack does not animate at all.
   *
   * Takes the same branch processMarquee does, rather than calling animateZoom
   * outright, so that what it reports is what a drag would actually do - including
   * when animation is switched off. */
  async probeZoom(spec: { x: string, y: string, z: string, iterations?: number }) {
    if (this.busy()) return { error: "busy" }
    if (spec.iterations) this.maxIter = spec.iterations
    const targetZ = new Decimal(spec.z)
    Decimal.set({ precision: Math.max(this.zActual.e, targetZ.e) + 20 })
    const from = { centerX: this.centerX, centerY: this.centerY, zActual: this.zActual }
    const to = {
      centerX: new Decimal(spec.x),
      centerY: new Decimal(spec.y),
      zActual: targetZ.times(this.pleasantCanvasRatio)
    }
    if (!this.zoomAnimationsEnabled) {
      // No stack push here either: processMarquee only records a step it tweened.
      this.centerX = to.centerX
      this.centerY = to.centerY
      this.zActual = to.zActual
      await this.renderFractal()
      return { animated: false }
    }
    this.zoomStack.push({ prevState: from, targetState: to })
    await this.animateZoom(from, to)
    return { animated: true, ...this.lastTween }
  }

  zoomOut() {          
    if (!this.zoomAnimationsEnabled) {
      this.zoomStack = []
    }
    if (this.zoomAnimationsEnabled && this.zoomStack.length > 0) {
      const zoomEntry = this.zoomStack.pop()
      this.animateZoom(zoomEntry.targetState, zoomEntry.prevState)
    }
    else {
      this.z = this.z.dividedBy(this.zoomOutFactor)
      this.renderFractal()
    }
  }

  reset() {                 
    this.zoomStack = []
    this.z = this.prevZ = this.defaultZ
    this.centerX = this.defaultCenterX
    this.centerY = this.defaultCenterY
    this.maxIter = this.maxIterDefault
    this.refPoint = undefined
    this.tweenRRef = undefined
    this.update()
    setTimeout(() => this.renderFractal(), 1)
  }

  saveImage() {
    if (this.busy()) return
    const dataURL = this.canvasEl.toDataURL("image/jpeg")
    let filename = "mandelbrot_" + this.getParamString() + ".jpeg"
    const link = document.createElement("a")
    link.href = dataURL
    link.download = filename
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
  }

  iterationOptions = [
    /* "Anim." and "Still" encoded the old `maxIter <= 5000` coupling: the cap
     * decided whether zooms animated. It never predicted that, and the decision is
     * measured now, so the labels say only what the number is. */
    { label: "10K", value: 10000 },
    { label: "30K", value: 30000 },
    { label: "100K", value: 100000 }
  ];

  view() {
    return div(
      canvas({
        id: "mainCanvas",
        ariaLabel: "fractal",
        width: this.canvasWidth,
        height: this.canvasHeight,
        onMounted: el => { this.canvasEl = el as HTMLCanvasElement }
      }),
      this.marquee.view({ id: "marqueeCanvas", ariaLabel: "marquee", width: this.canvasWidth, height: this.canvasHeight }),
      this.tip.view({ class: "tipOverlay" }, "click and drag to zoom"),
      div({ class: "infoBox" },
        h1({ class: "title" }, "∞Mandelbrot Ex²plorer"),
        /* One row, ordered by what you reach for: the two ways back out - reset,
         * then zoom out - then the camera, and the one setting last. */
        div({ class: "controls" },
          iconButton("Reset", () => this.reset(),
            path({ d: "M3.5 5.5v5h5" }),
            path({ d: "M4.2 10.5a8 8 0 1 1 .6 5.4" })),
          iconButton("Zoom Out", () => this.zoomOut(),
            circle({ cx: 10.5, cy: 10.5, r: 6.5 }),
            line({ x1: 15.4, y1: 15.4, x2: 21, y2: 21 }),
            line({ x1: 7.5, y1: 10.5, x2: 13.5, y2: 10.5 })),
          iconButton("Snapshot", () => this.saveImage(),
            path({ d: "M3 8.5h3.2l1.5-2.2h8.6l1.5 2.2H21v10H3z" }),
            circle({ cx: 12, cy: 13.2, r: 3.4 })),
          /* Skip-to-end: go straight there, no in-between, which is exactly what
           * the mode does - a refused tween cuts to the target. */
          toggleButton("No zoom animation", this.noAnimation, () => this.toggleAnimation(),
            path({ d: "M5.5 5.5 15 12l-9.5 6.5z" }),
            line({ x1: 18.5, y1: 5.5, x2: 18.5, y2: 18.5 })),
          /* The three actions carry their name as tooltip and aria-label; this one
           * had neither, so the only control whose name is not on screen was also
           * the only one with no name at all. */
          inputSelect({
            target: this, prop: () => this.maxIter, options: this.iterationOptions,
            id: "maxIter", attrs: { title: "iterations", ariaLabel: "iterations" }
          })
        )
      )
    )
  }
}    

class WorkerApi {
  messageId = 0
  pending = new Map()
  worker: Worker
  constructor (worker: Worker) {
    this.worker = worker
    worker.addEventListener("message", event => {
      const { id, result, error } = event.data
      const deferred = this.pending.get(id)
      if (deferred) {
        error ? deferred.reject(new Error(error)) : deferred.resolve(result)
        this.pending.delete(id)
      }
    });
  }                        
  call(method:string, args?: {}) {
    return new Promise((resolve, reject) => {
      const id = this.messageId++
      this.pending.set(id, { resolve, reject })
      this.worker.postMessage({ id, method, args })
    });
  }
}

type FrameRect = {x: number, y: number, width: number, height: number}

class MandelbrotRenderer extends WorkerApi {
  static getWorker() {
    const workerUrl = URL.createObjectURL(new Blob([getInlineWorkerCode()], { type: "application/javascript" }))
    return new Worker(workerUrl, { type: "module" })
  }
  constructor () { super (MandelbrotRenderer.getWorker()) }

  ready() { return this.call("ready") }
  startRenderer(args) { return this.call("startRenderer", args) }
  nextFrame() { return this.call("nextFrame") as Promise<{
    done: boolean,
    value: { bitmap: ImageBitmap, sourceRect: FrameRect, destinationRect: FrameRect, progress: number, orbitMs: number }}>
  }
}

const explorer = new MandelbrotExplorer()
new App({root: explorer, id: "app"})

// Probe: `typebulb send <file> tweenstats --wait` prints the last tween's numbers.
tb.onMessage((m: any) => {
  if (m === "tweenstats") return { noAnimation: explorer.noAnimation, ...(explorer.lastTween ?? {}) }
  if (m && m.zoomto) return explorer.probeZoom(m.zoomto)
})

function getInlineWorkerCode() { return `
/* ------------------------------------------------------------------------------------------
* Algorithm adapted from Hastings Greer that allows 10^300 zoom depth; significant revisions:
*     
* - Progressive rendering, w/ low resolution previews & tiling
* - Faster calculation heuristic by skipping calculation on probably costly pixels
* - Animation friendly w/ lower resolution for in-between frames
* - Works with fractional zoom levels, not only integer powers of two
* - Works with any aspect ratio, automatically filling the available space
* - Consistent colouring independent of max iterations
* - Renders asynchronously on an OffscreenCanvas & web worker enabled
* - Stateless render w/ cached lookups & throttling
* - Direct float32 path for views shallow enough not to need perturbation at all
* - One reference orbit shared across a zoom tween, held at the tween's target
* - The reference orbit stores the Z it escaped at, so the pixel loop never reads fill
*/

import { mat4 } from "https://esm.sh/gl-matrix";
import { init } from "https://esm.sh/[email protected]";

let binding = null; // Global MPFR binding

/* The reference orbit a tween reuses across its frames, keyed by everything it
 * depends on. Any centred build clears it - createReferenceOrbit writes into one
 * shared scratch buffer, so a cached entry whose buffer has since been rewritten
 * must never be served, and nulling on every non-shared build is what guarantees
 * that without having to reason about who ran in between. */
let sharedOrbit = null;

/* ------------------ Worker API ------------------ */

class ApiWorker {
  constructor() {
    self.onmessage = async event => {
      const { id, method, args } = event.data;
      try {
        const result = await this[method](args);
        self.postMessage({ id, result });
      } catch (e) {
        self.postMessage({ id, error: e.message });
      }
    };
    this.currentIterator = null;
  }
}

class MandelbrotWorker extends ApiWorker {
  constructor() { super(); this.startRenderer = takeLatest(this.startRenderer.bind(this)); }
  async ready() {
    const result = await init();
    binding = result.binding;
  }
  async startRenderer(args) { this.currentIterator = progressiveRenderMandelbrot(args);}
  async nextFrame() {
    if (!this.currentIterator) throw new Error("Renderer not started");
    return this.currentIterator.next();
  }
}

new MandelbrotWorker();

function takeLatest(fn) {
  let latestCallId = 0;
  return async function(...args) {
    const callId = ++latestCallId;
    const result = await fn(...args);
    if (callId === latestCallId) { return result; }
  };
}

/* ------------------ Utility Functions ------------------ */

function uniforms(gl, program, definitions) {
  const uniformsMap = {};
  definitions.forEach(({ name, type }) => {
    const location = gl.getUniformLocation(program, name);
    if (location === null) {
      console.warn("Uniform " + name + " not found in the shader.");
    }
    uniformsMap[name] = { location, type, value: undefined };
  });
  function applyUniform(name, value) {
    const { location, type } = uniformsMap[name];
    if (!location) return;
    switch (type) {
      case "1f": gl.uniform1f(location, value); break;
      case "1i": gl.uniform1i(location, value); break;
      case "2f": gl.uniform2f(location, value[0], value[1]); break;
      case "3f": gl.uniform3f(location, value[0], value[1], value[2]); break;
      case "4f": gl.uniform4f(location, value[0], value[1], value[2], value[3]); break;
      case "Matrix4fv": gl.uniformMatrix4fv(location, false, value); break;
      default: console.error("Uniform type " + type + " not implemented.");
    }
  }
  return new Proxy({}, {
    get(target, prop) {
      return prop in uniformsMap ? uniformsMap[prop].value : undefined;
    },
    set(target, prop, value) {
      if (prop in uniformsMap) {
        uniformsMap[prop].value = value;
        applyUniform(prop, value);
        return true;
      }
      target[prop] = value;
      return true;
    }
  });
}

function loadShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
    throw new Error("Error compiling shader: " + gl.getShaderInfoLog(shader));
  return shader;
}

function initShaderProgram(gl, vsSource, fsSource) {
  const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
  const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
  const shaderProgram = gl.createProgram();
  gl.attachShader(shaderProgram, vertexShader);
  gl.attachShader(shaderProgram, fragmentShader);
  gl.linkProgram(shaderProgram);
  if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS))
    throw new Error("Unable to initialize shader program: " + gl.getProgramInfoLog(shaderProgram));
  return shaderProgram;
}

function createAndSetupTexture(gl, unit, width, height, internalFormat, format, type, data = null) {
  const tex = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0 + unit);
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
  gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, width, height, 0, format, type, data);
  return tex;
}

function createFramebufferForTexture(gl, tex) {
  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
  if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE)
    throw new Error("Framebuffer not complete.");
  return fbo;
}

function drawPass(gl, framebuffer, width, height) {
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.viewport(0, 0, width, height);
  gl.clearColor(0.0, 0.0, 0.0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.finish();
}

function blit(gl, framebuffer, targetWidth, targetHeight, canvas, finalTex) {
  gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer);
  gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.blitFramebuffer(
    0, 0, targetWidth, targetHeight,
    0, 0, canvas.width, canvas.height,
    gl.COLOR_BUFFER_BIT, gl.NEAREST
  );
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.deleteFramebuffer(framebuffer);
  gl.deleteTexture(finalTex);
}

/* ------------------ Mandelbrot Rendering ------------------ */

function getLayout(width, height) {
  const aspect = width / height;
  const right = Math.max(aspect, 1);
  const top = Math.max(1, 1 / aspect);
  return { left: -right, right, top, bottom: -top };
}

/* ------------------ Shader Sources ------------------ */

// --- Vertex Shader ---
// Updated to include uTileOffset and uTileScale and to output tileDelta.
const vsSource = \`#version 300 es
in vec4 aVertexPosition;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform vec2 uTileOffset;  // Tile offset in fractal space.
uniform vec2 uTileScale;   // Tile scale factor.
out highp vec2 tileDelta;  // Transformed coordinate passed to fragment shader.
void main() {
  gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
  // Map the vertex positions from local (tile) space into global fractal space.
  tileDelta = (aVertexPosition.xy * uTileScale) + uTileOffset;
}\`;

// --- Fragment Shader ---
// Updated to use tileDelta and uFractalDomain for mask lookup.
const fsSource = \`#version 300 es
precision highp float;
uniform int uOrbitSize;
in highp vec2 tileDelta;
out vec4 fragColor;
uniform vec4 uState;
uniform float uColorMax;
uniform bool uUseHeuristic;
uniform bool uMaskTestSeeSkippedPixels;
uniform sampler2D uLowResMask;
uniform vec4 poly1;
uniform vec4 poly2;
uniform sampler2D sequence;
uniform vec4 uFractalDomain;
uniform bool uDirect;   // shallow enough that c is exact in float32; see directIsExact
uniform vec2 uCenter;   // reference centre, float32 - only read when uDirect
uniform float uRadius;  // half-width of the view in fractal units, float32
/* The reference orbit does not have to sit at the view centre - it only has to be
 * a point whose orbit the whole view can be expressed as a delta from. tileDelta
 * is measured from the centre, so an off-centre reference needs the pixel's dc
 * re-expressed in units of the reference's own radius:
 *
 *   dc = (centre - ref) + tileDelta * r   ->   dcv = tileDelta * uDcScale + uDcOffset
 *
 * with both in units of the enlarged radius the orbit was built for. Reference at
 * the centre gives scale 1 and offset 0, which is the arithmetic that was here
 * before. This is what lets one orbit serve a whole zoom tween instead of being
 * rebuilt every frame - measured at 57-81% of a tween frame. */
uniform vec2 uDcOffset;
uniform float uDcScale;

float get_orbit_x(int i) {
  i = i * 3;
  int row = i / uOrbitSize;
  return texelFetch(sequence, ivec2(i % uOrbitSize, row), 0)[0];
}
float get_orbit_y(int i) {
  i = i * 3 + 1;
  int row = i / uOrbitSize;
  return texelFetch(sequence, ivec2(i % uOrbitSize, row), 0)[0];
}
float get_orbit_scale(int i) {
  i = i * 3 + 2;
  int row = i / uOrbitSize;
  return texelFetch(sequence, ivec2(i % uOrbitSize, row), 0)[0];
}

void main() {
  if (uUseHeuristic) {
    // Compute normalized coordinates for the mask by mapping the global fractal coordinate.
    vec2 uv = (tileDelta - vec2(uFractalDomain.x, uFractalDomain.y)) /
              (vec2(uFractalDomain.z, uFractalDomain.w) - vec2(uFractalDomain.x, uFractalDomain.y));
    if (texture(uLowResMask, uv).r > 0.5) {
      fragColor = vec4(uMaskTestSeeSkippedPixels ? 1.0 : 0.0, 0.0, 0.0, 1.0);
      return;
    }
  }

  float maxIter = uState[3];
  float iter = maxIter;

  if (uDirect) {
    /* No perturbation at all. Everything in the other branch exists to compute an
     * orbit for a c that a float cannot hold - and in a shallow view a float
     * holds c perfectly well, so all that machinery is pure cost. Worse than
     * cost: its rebasing step fires exactly when |z| < |dz|, which is to say when
     * z is the small sum of two comparable larger numbers, and float32 keeps only
     * three or four digits of such a sum. Every pixel that rebases carries that
     * truncation forward, so the boundary between pixels that rebased and pixels
     * that did not is a hard edge - a disc sitting in the middle of a smooth
     * gradient, worst where the arithmetic is easiest. */
    vec2 c = uCenter + tileDelta * uRadius;
    vec2 z = vec2(0.0);
    for (int i = 0; float(i) < maxIter; i++) {
      z = vec2(z.x * z.x - z.y * z.y + c.x, 2.0 * z.x * z.y + c.y);
      if (dot(z, z) > 4.0) { iter = float(i + 1); break; }
    }
  } else {
    float q = uState[2] - 1.0;
    float cq = q;
    q = q + poly2[3];
    float S = exp2(q);
    vec2 dcv = tileDelta * uDcScale + uDcOffset;
    float dcx = dcv.x;
    float dcy = dcv.y;
    float x;
    float y;
    float sqrx = (dcx * dcx - dcy * dcy);
    float sqry = (2.0 * dcx * dcy);
    float dx = poly1[0] * dcx - poly1[1] * dcy + poly1[2] * sqrx - poly1[3] * sqry;
    float dy = poly1[0] * dcy + poly1[1] * dcx + poly1[2] * sqry + poly1[3] * sqrx;
    int k = int(poly2[2]);
    int j = k;
    x = get_orbit_x(k);
    y = get_orbit_y(k);
    /* The orbit's scale exponent at the current index, carried across the iteration
     * rather than re-fetched. Next pass wants the scale at the index this pass just
     * moved to, which is precisely what was fetched here for the escape test - so the
     * loop reads three texels per step instead of five. Every rebase has to refresh
     * it, since a rebase moves the index without passing through the fetch below. */
    float osk = get_orbit_scale(k);
    for (int i = k; float(i) < maxIter; i++) {
      j++;
      float os = osk;
      k++;
      /* exp2, not pow(2.0, ...): the same value, but pow is exp2(y * log2(x)) and
       * only some drivers fold the constant away. The scale factor for dc was also
       * being computed twice for what is one number. */
      float dcs = exp2(cq - q - os);
      dcx = dcv.x * dcs;
      dcy = dcv.y * dcs;
      float unS = exp2(q - os);
      if (isinf(unS)) unS = 0.0;
      float tx = 2.0 * x * dx - 2.0 * y * dy + unS * dx * dx - unS * dy * dy + dcx;
      dy = 2.0 * x * dy + 2.0 * y * dx + unS * 2.0 * dx * dy + dcy;
      dx = tx;
      q = q + os;
      S = exp2(q);
      x = get_orbit_x(k);
      y = get_orbit_y(k);
      /* The table stops where the reference orbit escaped; past that it is the -1
       * fill. Reading one of those is not a small error - it reads as a reference
       * sitting at -0.5 when the real one is past |Z| = 20, so a pixel that has
       * certainly escaped fails the test below, and the rebase then folds the fill
       * into its delta. Everything that pixel computes afterwards is invented, which
       * is the whole of the dome-and-bullseye artifact. The orbit now carries the
       * escaped Z, so this is a backstop rather than the common path: hitting it
       * early means the reference is long gone and so is this pixel, while hitting
       * it at the iteration limit just means the loop ran out - and j is maxIter
       * there, so that pixel still colours as interior. */
      if (x == -1.0 && y == -1.0) break;
      osk = get_orbit_scale(k);
      float zs = exp2(osk);
      float fx = x * zs + S * dx;
      float fy = y * zs + S * dy;
      float rr = fx * fx + fy * fy;
      if (rr > 4.0) break;
      // |z|^2 and |dz mantissa|^2 were each being formed two or three times over.
      float dd = dx * dx + dy * dy;
      if (dd > 1000000.0) {
        dx *= 0.5;
        dy *= 0.5;
        dd *= 0.25;
        q = q + 1.0;
        S = exp2(q);
      }
      // dcx/dcy used to be recomputed in both of these branches and then overwritten
      // unread at the top of the next pass; the exponent bookkeeping is what matters.
      if (rr < S * S * dd) {
        dx = fx;
        dy = fy;
        q = 0.0;
        S = 1.0;
        k = 0;
        x = get_orbit_x(0);
        y = get_orbit_y(0);
        osk = get_orbit_scale(0);
      }
    }
    iter = float(j);
  }

  // coloring
  float scaleFactor = uState[1];
  float normIter = (uColorMax - iter) / scaleFactor;

  fragColor = iter >= maxIter ?
    vec4(0.0, 0.0, 0.0, 1.0) :
    vec4(
        (cos(normIter)          / -2.0) + 0.5,
        (cos(1.1214 * normIter) / -2.0) + 0.5,
        (cos(0.8 * normIter)    / -2.0) + 0.5,
        1.0
    );
}\`;

function mpfr_zero() {
  const zero = binding.mpfr_t();
  binding.mpfr_init2(zero, 1200);
  binding.mpfr_set_d(zero, 0, 0);
  return zero;
}
function get_exp(val) {
  const tmp = mpfr_zero();
  binding.mpfr_log2(tmp, val, 0);
  return binding.mpfr_get_d(tmp, 0);
}
function alignExponents(a, b) {
  let [am, ae] = a, [bm, be] = b;
  const retE = Math.max(ae, be);
  if (retE > ae) am *= Math.pow(2, ae - retE);
  else bm *= Math.pow(2, be - retE);
  return [am, bm, retE];
}
function sub(a, b) {
  let [am, bm, e] = alignExponents(a, b);
  return [am - bm, e];
}
function add(a, b) {
  let [am, bm, e] = alignExponents(a, b);
  return [am + bm, e];
}
function mul(a, b) {
  let [am, ae] = a, [bm, be] = b;
  let m = am * bm, e = ae + be;
  if (m !== 0) {
    const logm = Math.round(Math.log2(Math.abs(m)));
    m = m / Math.pow(2, logm);
    e += logm;
  }
  return [m, e];
}
function maxabs(a, b) {
  let [am, bm, e] = alignExponents(a, b);
  return [Math.max(Math.abs(am), Math.abs(bm)), e];
}
function gt(a, b) {
  const [am, bm] = alignExponents(a, b);
  return am > bm;
}
function floaty(d) {
  return Math.pow(2, d[1]) * d[0];
}

function createReferenceOrbit(cx, cy, radius, iterations, orbitSize) {
  const orbit = getOrCreateFloat32Array("orbit", orbitSize * orbitSize); 
  orbit.fill(-1);
  let x = mpfr_zero(), y = mpfr_zero();    
  const txx = mpfr_zero(), txy = mpfr_zero(), tyy = mpfr_zero();
  let polylim = 0,
      Bx = [0, 0], By = [0, 0],
      Cx = [0, 0], Cy = [0, 0],
      Dx = [0, 0], Dy = [0, 0],
      poly = [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0]],
      not_failed = true;
  for (let i = 0; i < iterations; i++) {
    const x_exponent = binding.mpfr_get_exp(x);
    const y_exponent = binding.mpfr_get_exp(y);
    let scale_exponent = Math.max(x_exponent, y_exponent);
    if (scale_exponent < -10000) scale_exponent = 0;
    let tmp = 0;
    orbit[3 * i] = binding.mpfr_get_d_2exp(tmp, x, 0) / Math.pow(2, scale_exponent - x_exponent);
    orbit[3 * i + 1] = binding.mpfr_get_d_2exp(tmp, y, 0) / Math.pow(2, scale_exponent - y_exponent);
    orbit[3 * i + 2] = scale_exponent;
    const fx = [orbit[3 * i], orbit[3 * i + 2]];
    const fy = [orbit[3 * i + 1], orbit[3 * i + 2]];
    binding.mpfr_mul(txx, x, x, 0);
    binding.mpfr_mul(txy, x, y, 0);
    binding.mpfr_mul(tyy, y, y, 0);
    binding.mpfr_sub(x, txx, tyy, 0);
    binding.mpfr_add(x, x, cx, 0);
    binding.mpfr_add(y, txy, txy, 0);
    binding.mpfr_add(y, y, cy, 0);
    const prev_poly = [Bx, By, Cx, Cy, Dx, Dy];
    [Bx, By, Cx, Cy, Dx, Dy] = [
      add(mul([2, 0], sub(mul(fx, Bx), mul(fy, By))), [1, 0]),
      mul([2, 0], add(mul(fx, By), mul(fy, Bx))),
      sub(add(mul([2, 0], sub(mul(fx, Cx), mul(fy, Cy))), mul(Bx, Bx)), mul(By, By)),
      add(mul([2, 0], add(mul(fx, Cy), mul(fy, Cx))), mul(mul([2, 0], Bx), By)),
      mul([2, 0], add(sub(mul(fx, Dx), mul(fy, Dy)), sub(mul(Cx, Bx), mul(Cy, By)))),
      mul([2, 0], add(add(add(mul(fx, Dy), mul(fy, Dx)), mul(Cx, By)), mul(Cy, Bx)))
    ];
    tmp = 0;
    const fx_val = [binding.mpfr_get_d_2exp(tmp, x, 0), binding.mpfr_get_exp(x)];
    const fy_val = [binding.mpfr_get_d_2exp(tmp, y, 0), binding.mpfr_get_exp(y)];
    if (i === 0 || gt(maxabs(Cx, Cy), mul([1000, binding.mpfr_get_exp(radius)], maxabs(Dx, Dy)))) {
      if (not_failed) {
        poly = prev_poly;
        polylim = i;
      }
    } else {
      not_failed = false;
    }
    if (gt(add(mul(fx_val, fx_val), mul(fy_val, fy_val)), [400, 0])) {
      /* Store the escaped Z before stopping. The pixel loop always reads one entry
       * past the last one it stepped through, and what it used to find there was
       * the fill. A reference that leaves early is not exotic - the view centre
       * escapes in 32 iterations at one of the test views and in 14 at another -
       * so this was most of the frame rather than an edge case. */
      const esc_i = i + 1;
      if (3 * esc_i + 2 < orbit.length) {
        const esc_xe = binding.mpfr_get_exp(x);
        const esc_ye = binding.mpfr_get_exp(y);
        let esc_se = Math.max(esc_xe, esc_ye);
        if (esc_se < -10000) esc_se = 0;
        orbit[3 * esc_i] = binding.mpfr_get_d_2exp(tmp, x, 0) / Math.pow(2, esc_se - esc_xe);
        orbit[3 * esc_i + 1] = binding.mpfr_get_d_2exp(tmp, y, 0) / Math.pow(2, esc_se - esc_ye);
        orbit[3 * esc_i + 2] = esc_se;
      }
      break;
    }
  }
  return [orbit, poly, polylim];
}

function computeOrbitAndPoly(x, y, r, iterations, orbitSize) {
  const center = [mpfr_zero(), mpfr_zero()];
  const radius = mpfr_zero();
  binding.mpfr_set_string(center[0], x, 10, 0);
  binding.mpfr_set_string(center[1], y, 10, 0);
  binding.mpfr_set_string(radius, r, 10, 0);
  const [orbit, poly, polylim] = createReferenceOrbit(center[0], center[1], radius, iterations, orbitSize);
  const rexp = binding.mpfr_get_exp(radius);
  const r_val = binding.mpfr_get_d_2exp(0, radius, 0);
  const poly_scale_exp = mul([1, 0], maxabs(poly[0], poly[1]));
  const poly_scale = [1, -poly_scale_exp[1]];
  const poly_scaled = [
    mul(poly_scale, poly[0]),
    mul(poly_scale, poly[1]),
    mul(poly_scale, mul([r_val, rexp], poly[2])),
    mul(poly_scale, mul([r_val, rexp], poly[3])),
    mul(poly_scale, mul([r_val, rexp], mul([r_val, rexp], poly[4]))),
    mul(poly_scale, mul([r_val, rexp], mul([r_val, rexp], poly[5]))),
  ].map(floaty);
  return { orbit, poly_scaled, polylim, poly_scale_exp, radius };
}

function spiralOutFromCenter(flatArray) {
  const n = Math.sqrt(flatArray.length);
  const center = (n - 1) / 2;
  const cells = flatArray.map((value, idx) => {
    const i = Math.floor(idx / n);
    const j = idx % n;
    return { value, dist: Math.hypot(i - center, j - center) };
  });
  cells.sort((a, b) => a.dist - b.dist);
  return cells.map(cell => cell.value);
}

function generateTileRects(width, height, tilesX, tilesY) {
  const tiles = [];
  const tileWidth = Math.floor(width / tilesX);
  const tileHeight = Math.floor(height / tilesY);
  for (let row = 0; row < tilesY; row++) {
    const y = row * tileHeight;
    for (let col = 0; col < tilesX; col++) {
      const x = col * tileWidth;
      const w = col === tilesX - 1 ? (width - x) : tileWidth;
      const h = row === tilesY - 1 ? (height - y) : tileHeight;
      tiles.push({ x, y, width: w, height: h });
    }
  }
  return spiralOutFromCenter (tiles);
}

/**
 * Computes the transformation for a tile by mapping its rectangle from canvas
 * coordinates to the fractal domain and then to the target layout.
 *
 * @param {Object} tileRect - The tile rectangle in canvas coordinates {x, y, width, height}.
 * @param {Object} canvasDims - The canvas dimensions {width, height}.
 * @param {Object} fractalDomain - The fractal domain {left, bottom, right, top}.
 * @param {Object} targetLayout - The target layout for rendering {left, bottom, right, top}.
 * @returns {{offset: number[], scale: number[]}} The computed transform with offset and scale.
 */
function computeTileTransform(tileRect, canvasDims, fractalDomain, targetLayout) {
  if (!tileRect) return { offset: [0, 0], scale: [1, 1] };

  const normalizeRect = ({ x, y, width, height }) => ({
    x: x / canvasDims.width,
    y: (canvasDims.height - y - height) / canvasDims.height,
    width: width / canvasDims.width,
    height: height / canvasDims.height
  });

  const mapToFractalDomain = ({ x, y, width, height }) => ({
    x: fractalDomain.left + x * (fractalDomain.right - fractalDomain.left),
    y: fractalDomain.bottom + y * (fractalDomain.top - fractalDomain.bottom),
    width: width * (fractalDomain.right - fractalDomain.left),
    height: height * (fractalDomain.top - fractalDomain.bottom)
  });

  const normTile = normalizeRect(tileRect);
  const tileFractal = mapToFractalDomain(normTile);

  const scaleX = tileFractal.width / (targetLayout.right - targetLayout.left);
  const scaleY = tileFractal.height / (targetLayout.top - targetLayout.bottom);
  const offsetX = tileFractal.x - targetLayout.left * scaleX;
  const offsetY = tileFractal.y - targetLayout.bottom * scaleY;

  return { offset: [offsetX, offsetY], scale: [scaleX, scaleY] };
}

function computeMaskFromPixels(pixelBuffer, width, height, settings) {
  const { foveaWidth: w, foveaDetail: d } = settings;
  const mask = new Uint8Array(width * height);
  for (let y = w; y < height - w; y++) {
    for (let x = w; x < width - w; x++) {
      let allBlack = true;
      for (let j = -w; j <= w && allBlack; j += d) {
        for (let i = -w; i <= w; i += d) {
          const idx = 4 * ((y + j) * width + (x + i));
          if (!(pixelBuffer[idx] === 0 &&
                pixelBuffer[idx + 1] === 0 &&
                pixelBuffer[idx + 2] === 0 &&
                pixelBuffer[idx + 3] === 255)) {
            allBlack = false;
            break;
          }
        }
      }
      mask[y * width + x] = allBlack ? 255 : 0;
    }
  }
  return mask;
}

const getOrCreateFloat32Array = (key, length) =>
  getOrCreate(key, o => o.length == length, () => new Float32Array(length));

const getOrCreate = (() => {
  const cache = new Map();
  return (key, isCompatible, createNew, cleanup) => {
    const cachedObject = cache.get(key);
    if (cachedObject && isCompatible(cachedObject)) {
      return cachedObject;
    }
    if (cachedObject && cleanup) {
      cleanup(cachedObject);
    }
    const newObject = createNew();
    cache.set(key, newObject);
    return newObject;
  };
})();

const getOrCreateOffscreenCanvas = (key, width, height) =>
  getOrCreate(
    key,
    canvas => canvas.width === width && canvas.height === height,
    () => new OffscreenCanvas(width, height)    
  );

function cleanupWebGLResources(resource) {
  const { gl, buffers, programInfo } = resource;
  if (!gl) return;  
  if (buffers) {
    for (const key in buffers) {
      gl.deleteBuffer(buffers[key]);
    }
  }  
  if (programInfo && programInfo.program) {
    gl.deleteProgram(programInfo.program);
  }  
  const ext = gl.getExtension("WEBGL_lose_context");
  if (ext) {
    ext.loseContext();
  }
}

function fetchWebGLResources(canvas, canvasKey) {
  const resourceKey = canvasKey + "_resources";
  const resources = getOrCreate(
    resourceKey,
    resource => resource.canvas === canvas &&
                resource.canvas.width === canvas.width &&
                resource.canvas.height === canvas.height,
    () => {
      const gl = canvas.getContext("webgl2", {
        antialias: false,
        preserveDrawingBuffer: true
      });
      if (!gl) throw new Error("WebGL2 not supported.");

      const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
      gl.useProgram(shaderProgram);
      const programInfo = {
        program: shaderProgram,
        attribLocations: {
          vertexPosition: gl.getAttribLocation(shaderProgram, "aVertexPosition")
        },
        uniforms: uniforms(gl, shaderProgram, [
          { name: "uProjectionMatrix", type: "Matrix4fv" },
          { name: "uModelViewMatrix", type: "Matrix4fv" },
          { name: "uState", type: "4f" },
          { name: "uColorMax", type: "1f" },
          { name: "uUseHeuristic", type: "1i" },
          { name: "uMaskTestSeeSkippedPixels", type: "1i" },
          { name: "uLowResMask", type: "1i" },
          { name: "poly1", type: "4f" },
          { name: "poly2", type: "4f" },
          { name: "sequence", type: "1i" },
          { name: "uOrbitSize", type: "1i" },
          { name: "uTileOffset", type: "2f" },
          { name: "uTileScale", type: "2f" },
          { name: "uFractalDomain", type: "4f" },
          { name: "uDirect", type: "1i" },
          { name: "uCenter", type: "2f" },
          { name: "uRadius", type: "1f" },
          { name: "uDcOffset", type: "2f" },
          { name: "uDcScale", type: "1f" }
        ])
      };

      const buffers = {};
      gl.viewport(0, 0, canvas.width, canvas.height);
      const rect = getLayout(canvas.width, canvas.height);
      const projectionMatrix = mat4.create();
      mat4.ortho(projectionMatrix, rect.left, rect.right, rect.bottom, rect.top, -1, 1);
      const modelViewMatrix = mat4.create();

      gl.useProgram(programInfo.program);
      programInfo.uniforms.uProjectionMatrix = projectionMatrix;
      programInfo.uniforms.uModelViewMatrix = modelViewMatrix;
      const positionBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
      const layout = [
        rect.right, rect.top,
        rect.left, rect.top,
        rect.right, rect.bottom,
        rect.left, rect.bottom
      ];
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(layout), gl.STATIC_DRAW);
      buffers.position = positionBuffer;

      return { gl, programInfo, canvas, buffers };
    },
    cleanupWebGLResources // Cleanup callback when replacing an old resource.
  );

  // Sizing
  const { gl, programInfo } = resources;
  gl.viewport(0, 0, canvas.width, canvas.height);
  const rect = getLayout(canvas.width, canvas.height);
  const projectionMatrix = mat4.create();
  mat4.ortho(projectionMatrix, rect.left, rect.right, rect.bottom, rect.top, -1, 1);
  const modelViewMatrix = mat4.create();
  gl.useProgram(programInfo.program);
  programInfo.uniforms.uProjectionMatrix = projectionMatrix;
  programInfo.uniforms.uModelViewMatrix = modelViewMatrix;

  return resources;
}

/* The survey is rendered at 1/SURVEY_SCALE_STEP of whatever raster it is asked
 * for, and both the mask and the frame-cost estimate are read off that one
 * readback. */
const SURVEY_SCALE_STEP = 3;

/* renders a mask in low resolution of black pixels presumed not worth computing in higher resolution, as also presumed to be black */
function renderMask(gl, scaleFactor, fullWidth, fullHeight, cachedObjects, updateUniforms) {  
  // maskSettings: optimal blend of accuracy and performance
  const maskSettings = { active: true, testSeeSkippedPixels: false, foveaWidth: 3, foveaDetail: 1 };
  const appliedScale = scaleFactor * SURVEY_SCALE_STEP; // scale compounds
  const maskWidth = Math.floor(fullWidth / appliedScale);
  const maskHeight = Math.floor(fullHeight / appliedScale);

  let cachedMask = cachedObjects.maskData; // caching is essential for tiling; we only need 1 mask computation for the full image
  if (!cachedMask || cachedMask.maskWidth !== maskWidth || cachedMask.maskHeight !== maskHeight) {
    const heuristicTex = createAndSetupTexture(gl, 3, maskWidth, maskHeight, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE);
    const heuristicFbo = createFramebufferForTexture(gl, heuristicTex);

    updateUniforms({ uUseHeuristic: 0 }); // the mask itself doesn't need/use the masking heuristic
    drawPass(gl, heuristicFbo, maskWidth, maskHeight); // render a scaled down mandelbrot

    const pixelBuffer = new Uint8Array(maskWidth * maskHeight * 4);
    gl.bindFramebuffer(gl.FRAMEBUFFER, heuristicFbo);

    // readPixels is costly; crosses the JS/WebGL2 boundary. trade-off is render will be marginally slower on
    // regions with few black pixels, but far faster on regions with many black pixels. tried pushing
    // this into a WebGL2 shader, but ironically performance was actually worse

    const tComputationStart = performance.now();    
    gl.readPixels(0, 0, maskWidth, maskHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixelBuffer);
    const computationTime = performance.now() - tComputationStart;

    gl.bindFramebuffer(gl.FRAMEBUFFER, null);

    const mask = computeMaskFromPixels(pixelBuffer, maskWidth, maskHeight, maskSettings);
    gl.deleteFramebuffer(heuristicFbo);
    gl.deleteTexture(heuristicTex);

    cachedMask = { mask, maskWidth, maskHeight, computationTime };
    cachedObjects.maskData = cachedMask;
  }

  createAndSetupTexture(gl, 2, cachedMask.maskWidth, cachedMask.maskHeight, gl.R8, gl.RED, gl.UNSIGNED_BYTE, cachedMask.mask);
  updateUniforms({
    uLowResMask: 2,
    uUseHeuristic: maskSettings.active,
    uMaskTestSeeSkippedPixels: maskSettings.testSeeSkippedPixels
  });
}

/* Whether c can be built in the shader from a float32 centre without the
 * perturbation machinery - which is to say, whether one float step at the centre
 * is small against a pixel. ulp is |centre| * 2^-23; asking for DIRECT_SUBPIXEL
 * steps of headroom gives the test. Deliberately a statement about precision
 * rather than a zoom threshold, so it stays right if the canvas or the centre
 * moves. The centre is floored at 1 because that is where the exponent sits for
 * any view of the set worth looking at, and erring large errs safe. */
const DIRECT_SUBPIXEL = 16;

function directIsExact(x, y, r, width) {
  const centre = Math.max(1, Math.abs(parseFloat(x)), Math.abs(parseFloat(y)));
  const radius = Math.abs(parseFloat(r));
  if (!(radius > 0) || !isFinite(radius)) return false;
  return centre * Math.pow(2, -23) * DIRECT_SUBPIXEL < 2 * radius / Math.max(1, width);
}

function renderMandelbrot(args) {
  const defaults = { lowRes: false, scaleFactor: 3, orbitSize: 1024, cachedObjects: {} };
  args = { ...defaults, ...args };
  const { width, height, tileRect } = args;
  const finalRect = tileRect ?? { x: 0, y: 0, width, height };

  /* The orbit belongs to the reference point and its enlarged radius, not to the
   * view - so refX/refY/rRef default to the view's own centre and radius, which
   * reproduces exactly what this did before. */
  const refX = args.refX ?? args.x, refY = args.refY ?? args.y, rRef = args.rRef ?? args.r;
  let orbitData = args.cachedObjects.orbitData;
  if (!orbitData) {
    /* Across frames as well as across passes, when the caller says the reference
     * is being deliberately held still - which only a tween does. The key is every
     * input the orbit depends on, so a stale one cannot be served by accident. */
    const shareKey = args.shareOrbit
      ? refX + "|" + refY + "|" + rRef + "|" + args.iterations + "|" + args.orbitSize : null;
    if (shareKey && sharedOrbit && sharedOrbit.key === shareKey) {
      orbitData = args.cachedObjects.orbitData = sharedOrbit.data;
      args.cachedObjects.orbitMs = 0;
    } else {
      /* Timed because the tween's frame budget needs it: frame 1 of a tween pays
       * this and every later frame reuses it, so frame1 - orbitMs is what a steady
       * frame will cost. Guessing that as a share of frame 1 does not work - at a
       * high iteration cap the draw dominates and the share is near zero, at depth
       * with a cheap draw it is most of the frame. */
      const tOrbitStart = performance.now();
      orbitData = args.cachedObjects.orbitData =
        computeOrbitAndPoly(refX, refY, rRef, args.iterations, args.orbitSize);
      args.cachedObjects.orbitMs = performance.now() - tOrbitStart;
      sharedOrbit = shareKey ? { key: shareKey, data: orbitData } : null;
    }
  }

  const canvasKey = tileRect ? "tileCanvas" : "mainCanvas";
  const offscreenCanvas = getOrCreateOffscreenCanvas(canvasKey, finalRect.width, finalRect.height);
  const { gl, programInfo, buffers } = fetchWebGLResources(offscreenCanvas, canvasKey);

  gl.useProgram(programInfo.program);
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
  gl.vertexAttribPointer(programInfo.attribLocations.vertexPosition, 2, gl.FLOAT, false, 0, 0);
  gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);

  createAndSetupTexture(gl, 0, args.orbitSize, args.orbitSize, gl.R32F, gl.RED, gl.FLOAT, new Float32Array(orbitData.orbit));

  const updateUniforms = (newUniforms) => Object.assign(programInfo.uniforms, newUniforms);

  const fullLayout = getLayout(width, height);
  const targetLayout = getLayout(finalRect.width, finalRect.height);
  const transform = computeTileTransform(tileRect, { width, height }, fullLayout, targetLayout);

  updateUniforms({
    uFractalDomain: [fullLayout.left, fullLayout.bottom, fullLayout.right, fullLayout.top],
    uTileOffset: transform.offset,
    uTileScale: transform.scale,
    uState: [0, args.colorScale, 1 + get_exp(orbitData.radius), args.iterations],
    uColorMax: args.colorMax || args.iterations,
    poly1: orbitData.poly_scaled.slice(0, 4),
    poly2: [orbitData.poly_scaled[4], orbitData.poly_scaled[5], orbitData.polylim, orbitData.poly_scale_exp[1]],
    uOrbitSize: args.orbitSize,
    uDcOffset: args.dcOffset ?? [0, 0],
    uDcScale: args.dcScale ?? 1,
    uDirect: directIsExact(args.x, args.y, args.r, args.width) ? 1 : 0,
    uCenter: [parseFloat(args.x), parseFloat(args.y)],
    uRadius: parseFloat(args.r)
  });

  const appliedScale = args.lowRes ? args.scaleFactor : 1;
  const targetWidth = Math.floor(finalRect.width / appliedScale);
  const targetHeight = Math.floor(finalRect.height / appliedScale);

  /* The survey's resolution is a property of the view, not of whichever raster is
   * being written. Left to inherit appliedScale it is 1/81 of the frame while
   * previewing and 1/9 while drawing final, so the dimensional cache key misses
   * and a frame surveys itself twice. surveyScale pins it: one survey per frame,
   * measured at 37.6 ms -> 26.0 ms on a 10^7 view. */
  renderMask(gl, args.surveyScale ?? appliedScale, width, height, args.cachedObjects, updateUniforms);

  /* renderMask times its own readPixels - the sync point where the GPU has actually
   * finished - and stashes the number on the cached mask. It was never handed back
   * to the caller, so progressiveRenderMandelbrot's fast-path test was reading
   * undefined all along, and every comparison against undefined is false: the
   * two-pass path was never once taken, at any zoom or iteration count. Returning
   * it is what makes that decision real. */
  const computationTime = args.cachedObjects.maskData
    ? args.cachedObjects.maskData.computationTime : 0;
  const orbitMs = args.cachedObjects.orbitMs || 0;

  if (args.maskOnlyRender) {
    return {cachedObjects: args.cachedObjects, computationTime, orbitMs };
  }
  else {
    const finalTex = !args.lowRes ? null : createAndSetupTexture(gl, 4, targetWidth, targetHeight, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE); 
    const framebuffer = !args.lowRes ? null : createFramebufferForTexture(gl, finalTex);
    gl.viewport(finalRect.x, finalRect.y, finalRect.width, finalRect.height);
    drawPass(gl, framebuffer, targetWidth, targetHeight);
    if (args.lowRes) {
      blit(gl, framebuffer, targetWidth, targetHeight, offscreenCanvas, finalTex);
      gl.deleteFramebuffer(framebuffer);
      gl.deleteTexture(finalTex);
    }
    return { bitmap: offscreenCanvas.transferToImageBitmap(), cachedObjects: args.cachedObjects, computationTime, orbitMs };
  }
}

/* Predicted cost of a full-resolution draw, above which the frame is worth
 * previewing and tiling. 1620 = the old 20 ms threshold times the 81x of the
 * coarse survey it was tuned against, so the decision is unchanged where it was
 * last calibrated - but it is now a statement about the frame rather than about
 * the survey, which the bare "computationTime < 20" was not: that number silently
 * meant a different frame cost at every survey resolution. */
const FRAME_PROGRESSIVE_MS = 1620;

/**
 * Returns a Mandelbrot set as a bitmap.
 * @param {Object} args - Rendering parameters.
 * @param {number} args.width - Pixel width.
 * @param {number} args.height - Pixel height.
 * @param {string} args.x - The arbitrary precision x-coordinate of the Mandelbrot center as a string.
 * @param {string} args.y - The arbitrary precision y-coordinate of the Mandelbrot center as a string.
 * @param {string} args.r - The arbitrary precision radius/zoom magnification.
 * @param {string} [args.refX] - Reference point for the orbit; defaults to the view centre.
 * @param {string} [args.refY] - Reference point for the orbit; defaults to the view centre.
 * @param {string} [args.rRef] - Radius the reference orbit is built for; defaults to args.r.
 * @param {number[]} [args.dcOffset] - Pixel offset from the reference, in units of rRef.
 * @param {number} [args.dcScale] - View radius in units of rRef.
 * @param {boolean} [args.shareOrbit] - Reuse one reference orbit across frames (a tween).
 * @param {number} args.iterations - Maximum iterations. 
 * @param {number} args.colorScale - Color map scale factor.
 * @param {number} args.colorMax - Fixed iteration count for coloring; defaults to iterations.
 * @param {boolean} [args.lowRes=false] - Render in low resolution if true; full resolution if false.
 * @param {number} [args.scaleFactor=3] - Scale factor to use when lowRes is true.
 * @param {number} [args.orbitSize=1024] - The size of the orbit texture.
 * @param {number} [args.forcePreview=false] - Whether to force a low resolution preview, even if performing quickly.
 * @returns {ImageBitmap}
 */
async function* progressiveRenderMandelbrot(args) { 

  const { width, height, lowRes, forcePreview, scaleFactor = 3 } = args;  
  const canvasRect = { x: 0, y: 0, width, height };    
  /* One survey per frame, at a resolution chosen by what the frame is for. A
   * settled frame needs the fine survey: the full-resolution draw trusts the mask
   * to skip pixels, and a 1/81 survey undersamples filaments badly enough to black
   * out real structure. A tween frame draws at 1/9 and is gone in tens of
   * milliseconds, so it takes the cheap coarse survey. */
  args = { ...args, surveyScale: args.surveyScale ?? (lowRes ? 3 : 1) };
  // Even if lowRes isn't selected, we render in lowRes first; performance is tested & early visual feedback may be yielded
  const lowResResult = renderMandelbrot({ ...args, lowRes: true });  
  const progressCalc = imagesDone => lowRes ? 1 : Math.round(100 * (imagesDone / (scaleFactor * scaleFactor + 1))) / 100;

  /* The survey covers 1/(surveyScale*SURVEY_SCALE_STEP)^2 of the frame, so scaling
   * its measured cost up by that factor predicts what the full-resolution draw
   * would cost. Crude - the survey is unmasked and the real draw is not - but it
   * is a number about the frame. */
  const surveyCoverage = Math.pow((args.surveyScale ?? 1) * SURVEY_SCALE_STEP, 2);
  const predictedFullResMs = lowResResult.computationTime * surveyCoverage;
  const orbitMs = lowResResult.orbitMs;

  if (!lowRes && predictedFullResMs < FRAME_PROGRESSIVE_MS && ! forcePreview) { 
    // Fast enough – render final high-res version, no need to yield low res preview unless explicitly asked for
    const highResResult = renderMandelbrot({ ...args, lowRes: false, cachedObjects: lowResResult.cachedObjects });
    yield { bitmap: highResResult.bitmap, sourceRect: canvasRect, destinationRect: canvasRect, progress: 1, orbitMs };
    return;
  }

  yield { bitmap: lowResResult.bitmap, sourceRect: canvasRect, destinationRect: canvasRect, progress: progressCalc(1), orbitMs };
  if (lowRes) return;  

  const maskOnlyResult = renderMandelbrot({ ...args, maskOnlyRender: true, lowRes: false, cachedObjects: lowResResult.cachedObjects });
  // Each tile reuses the cached full image mask computed by the mask only result
  const tileRects = generateTileRects(width, height, scaleFactor, scaleFactor);
  for (const tileInfo of tileRects) {    
    const highResTileResult = renderMandelbrot({ ...args, lowRes: false, tileRect: tileInfo, cachedObjects: maskOnlyResult.cachedObjects });
    yield {
      bitmap: highResTileResult.bitmap,
      sourceRect: { x: 0, y: 0, width: tileInfo.width, height: tileInfo.height },
      destinationRect: { x: tileInfo.x, y: tileInfo.y, width: tileInfo.width, height: tileInfo.height },
      progress: progressCalc(tileRects.indexOf(tileInfo) + 2),
      orbitMs
    };
  }
}`};
```
**styles.css**

```css
 html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
  font-family: "Helvetica Neue", Arial, sans-serif;
}
#app {
  position: relative;
  width: 100vw;
  height: 100dvh;
  background: black;
}
canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  user-select: none;
}
#mainCanvas {
  z-index: 1;
  cursor: crosshair;
}
#marqueeCanvas {
  z-index: 2;
  cursor: crosshair;
}
/* A strip along the bottom rather than a box in a corner. A box cut a hole in the
   picture; a strip is one row deep and everything on it is one control wide. Every
   control carries its own background and border, so the title floats over the
   fractal with nothing behind it. */
.infoBox {
  position: fixed;
  bottom: calc(env(safe-area-inset-bottom, 0) + 18px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: calc(100vw - 24px);
  color: rgba(255, 255, 255, 0.86);
  font: 12px system-ui, sans-serif;
}
.infoBox .title {
  font-size: 1.2rem;
  color: #fff;
  text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
  margin: 0 0 8px;
  text-align: center;
  white-space: nowrap;
}
.controls {
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 100%;
}
/* Left to themselves the dropdown sizes to its text (24px) and the icon buttons
   to their glyph plus padding (40px), so the row reads as ragged. Fixing one
   height on both is steadier than tuning two paddings against each other; the
   widths are fixed for the same reason, since a flex row would otherwise let the
   longest iteration label decide how wide the strip is. */
.controls select,
.controls .controlButton {
  box-sizing: border-box;
  flex: 0 0 auto;
  height: 34px;
  padding: 0 8px;
}
/* Sized for "100K" plus the native dropdown arrow. It was 116px when the labels
   carried words ("10K Still"); with the labels down to four characters that width
   was mostly empty box sitting next to three 44px buttons. */
.controls select { width: 80px; }
.controls .iconButton { width: 44px; }
@media (max-width: 768px) {
  .infoBox .title { font-size: 1.05rem; margin-bottom: 6px; }
  .controls { gap: 6px; }
  .controls select { width: 76px; }
  .controls .iconButton { width: 38px; }
}
.title {
  font-family: math;
}
.controlButton, select {
  min-width: 0;
  padding: 10px;
  font-size: 14px;
  color: rgba(255,255,255,0.7);
  background: rgba(70,70,70,0.4);
  border: 2px solid rgba(255,255,255,0.7);
  border-radius: 4px;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.2s, color 0.2s;
}
.iconButton {
  display: flex;
  align-items: center;
  justify-content: center;
}
.icon { display: block; }
/* A mode that is on, drawn as a pushed button: inverted and inset, not merely
   brightened. Brightening is what hover already does, so an "on" state that only
   brightens is indistinguishable from the pointer resting on an off one - and
   this state has to be readable while nothing is being touched. */
.controlButton.on {
  color: rgba(18, 18, 18, 0.95);
  background: rgba(255, 255, 255, 0.86);
  border-color: rgba(255, 255, 255, 0.95);
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.4);
}
@media (hover: hover) {
  .controlButton:hover {
    color: rgba(255,255,255,1);
    border-color: rgba(255,255,255,1);
    background: rgba(70,70,70,0.8);
  }
  /* Same specificity as the rule above and declared after it, so the mode wins.
     Without this, hovering a pressed button makes it look unpressed. */
  .controlButton.on:hover {
    color: rgba(18, 18, 18, 0.95);
    background: rgba(255, 255, 255, 1);
    border-color: rgba(255, 255, 255, 1);
  }
}
select {
  text-align: center;
}
select:focus {
  background-color: #333;
}
.tipOverlay {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 1rem;
  color: #fff;
  text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
  pointer-events: none;
  z-index: 900;
  background: rgba(0, 0, 0, 0.4);
  padding: 8px 12px;
  border-radius: 4px;
  transition: opacity 0.5s ease-in-out, visibility 0.5s ease-in-out;
}
```
**index.html**

```html
<div id="app"/>
```
**config.json**

```json
{
  "dependencies": {
    "domeleon": "^0.5.2",
    "decimal.js": "^10.6.0"
  },
  "description": "A Mandelbrot exlorer with 10^300 depth. Demonstrates Web workers, WebAssembly, WebGL2, custom urls."
}
```

Markdown source · More bulbs by samples · Typebulb home