<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>__GGNEXT_TITLE__</title>
<style>
  /* Self-contained: no CDN, no external CSS, no web fonts. */
  body { margin: 0; font-family: Helvetica, Arial, sans-serif; }
  #ggnext-wrap { position: relative; display: inline-block; }
  #ggnext-tooltip {
    position: absolute;
    display: none;
    pointer-events: none;
    background: rgba(30, 30, 36, 0.92);
    color: #fff;
    padding: 4px 8px;
    border-radius: 4px;
    font-size: 12px;
    white-space: pre;
    z-index: 10;
  }
  #ggnext-canvas { display: block; cursor: crosshair; }
  #ggnext-player {
    display: none;
    align-items: center;
    gap: 10px;
    padding: 8px 10px;
    font-size: 13px;
  }
  #ggnext-player button {
    font: inherit;
    padding: 3px 12px;
    cursor: pointer;
    border: 1px solid #B8B8C4;
    border-radius: 4px;
    background: #FFF;
  }
  #ggnext-player input[type=range] { flex: 1; }
  #ggnext-frame-label { min-width: 90px; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id="ggnext-wrap">
  <canvas id="ggnext-canvas"></canvas>
  <div id="ggnext-tooltip"></div>
  <div id="ggnext-player">
    <button id="ggnext-play" type="button">Play</button>
    <input id="ggnext-scrub" type="range" min="0" max="0" step="1" value="0">
    <span id="ggnext-frame-label"></span>
  </div>
</div>
<script>
"use strict";
// Computed-geometry buffer, serialized from R. This is the SAME buffer the
// static SVG writer consumes: normalized [0,1] positions, hex colors, pixel
// radii, panel rectangles, axis domains, theme, and legend. No geometry is
// recomputed here — only presentation (device pixels, the current zoom
// window, tick relabeling while zoomed, and animation frame swapping).
var spec = __GGNEXT_SPEC__;

var canvas = document.getElementById("ggnext-canvas");
var tooltip = document.getElementById("ggnext-tooltip");
var ctx = canvas.getContext("2d");
var theme = spec.theme;

// Render at devicePixelRatio for crisp output on high-DPI screens.
var dpr = window.devicePixelRatio || 1;
canvas.width = spec.width * dpr;
canvas.height = spec.height * dpr;
canvas.style.width = spec.width + "px";
canvas.style.height = spec.height + "px";
ctx.scale(dpr, dpr);

// Each panel owns a zoom window in normalized coordinates: {x0:0, x1:1,
// y0:0, y1:1} shows the full expanded domain. Zooming acts on the panel
// under the cursor, so facets stay independently explorable.
var views = spec.panels.map(function () {
  return { x0: 0, x1: 1, y0: 0, y1: 1 };
});

// Active brush rectangle in device px while dragging; null otherwise.
var brushActive = null;
var brushPanel = -1;

// ---- coordinate helpers ----------------------------------------------------

function panelAt(mx, my) {
  for (var i = 0; i < spec.panels.length; i++) {
    var r = spec.panels[i].rect;
    if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) return i;
  }
  return -1;
}

// Normalized (y-up) -> device px (y-down), through a panel's zoom window.
function toDeviceX(pi, nx) {
  var r = spec.panels[pi].rect, v = views[pi];
  return r.x + ((nx - v.x0) / (v.x1 - v.x0)) * r.w;
}
function toDeviceY(pi, ny) {
  var r = spec.panels[pi].rect, v = views[pi];
  return r.y + r.h - ((ny - v.y0) / (v.y1 - v.y0)) * r.h;
}

// ---- tick math (same nice-numbers algorithm as the R scale module) ---------

function niceNumber(x, round) {
  var exponent = Math.floor(Math.log10(x));
  var fraction = x / Math.pow(10, exponent);
  var nf;
  if (round) {
    nf = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10;
  } else {
    nf = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10;
  }
  return nf * Math.pow(10, exponent);
}

function niceTicks(lo, hi, n) {
  if (lo === hi) return [lo];
  var step = niceNumber((hi - lo) / (n - 1), true);
  var t0 = Math.floor(lo / step) * step;
  var t1 = Math.ceil(hi / step) * step;
  var ticks = [];
  for (var v = t0; v <= t1 + step * 1e-9; v += step) {
    var snapped = Math.round(v / step) * step;
    if (snapped >= lo - step * 1e-9 && snapped <= hi + step * 1e-9) {
      ticks.push(snapped);
    }
  }
  return ticks;
}

function formatTick(v) {
  return String(parseFloat(v.toPrecision(10)));
}

function visibleDomain(domain, v0, v1) {
  var span = domain[1] - domain[0];
  return [domain[0] + v0 * span, domain[0] + v1 * span];
}

// Ticks for a panel axis: the buffer's own ticks when unzoomed (so labels
// match the static render exactly, including custom labels and transformed
// axes), recomputed nice numbers once the user zooms in.
function axisTicks(pi, which) {
  var panel = spec.panels[pi];
  var axis = which === "x" ? panel.x : panel.y;
  var v = views[pi];
  var untouched = which === "x"
    ? (v.x0 === 0 && v.x1 === 1)
    : (v.y0 === 0 && v.y1 === 1);
  if (untouched || panel.discrete_axis === which) {
    return axis.ticks.norm.map(function (n, i) {
      return { norm: n, label: axis.ticks.labels[i] };
    });
  }
  var d = which === "x"
    ? visibleDomain(axis.domain, v.x0, v.x1)
    : visibleDomain(axis.domain, v.y0, v.y1);
  return niceTicks(d[0], d[1], 5).map(function (t) {
    return {
      norm: (t - axis.domain[0]) / (axis.domain[1] - axis.domain[0]),
      label: formatTick(t)
    };
  });
}

// ---- drawing ---------------------------------------------------------------

function drawMark(pi, m) {
  ctx.globalAlpha = m.alpha;
  if (m.type === "circle") {
    ctx.fillStyle = m.fill;
    ctx.beginPath();
    ctx.arc(toDeviceX(pi, m.x), toDeviceY(pi, m.y), m.r, 0, Math.PI * 2);
    ctx.fill();
  } else if (m.type === "line") {
    ctx.strokeStyle = m.stroke;
    ctx.lineWidth = m.width;
    ctx.lineJoin = "round";
    ctx.lineCap = "round";
    ctx.setLineDash(m.dash ? m.dash.split(",").map(Number) : []);
    ctx.beginPath();
    for (var i = 0; i < m.xs.length; i++) {
      var lx = toDeviceX(pi, m.xs[i]);
      var ly = toDeviceY(pi, m.ys[i]);
      if (i === 0) ctx.moveTo(lx, ly); else ctx.lineTo(lx, ly);
    }
    ctx.stroke();
    ctx.setLineDash([]);
  } else if (m.type === "rect") {
    var rx0 = toDeviceX(pi, m.x0), rx1 = toDeviceX(pi, m.x1);
    var ry0 = toDeviceY(pi, m.y0), ry1 = toDeviceY(pi, m.y1);
    var rx = Math.min(rx0, rx1), ry = Math.min(ry0, ry1);
    var rw = Math.abs(rx1 - rx0), rh = Math.abs(ry1 - ry0);
    ctx.fillStyle = m.fill;
    ctx.fillRect(rx, ry, rw, rh);
    if (m.stroke) {
      ctx.strokeStyle = m.stroke;
      ctx.lineWidth = m.stroke_width;
      ctx.strokeRect(rx, ry, rw, rh);
    }
  } else if (m.type === "polygon") {
    ctx.beginPath();
    for (var j = 0; j < m.xs.length; j++) {
      var vx = toDeviceX(pi, m.xs[j]);
      var vy = toDeviceY(pi, m.ys[j]);
      if (j === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy);
    }
    ctx.closePath();
    ctx.fillStyle = m.fill;
    ctx.fill();
    if (m.stroke) {
      ctx.strokeStyle = m.stroke;
      ctx.lineWidth = m.stroke_width;
      ctx.stroke();
    }
  } else if (m.type === "text") {
    ctx.fillStyle = m.color;
    ctx.font = m.size + "px " + theme.font;
    ctx.textAlign = m.anchor === "middle" ? "center"
                  : m.anchor === "start" ? "left" : "right";
    ctx.textBaseline = "middle";
    ctx.fillText(m.text, toDeviceX(pi, m.x), toDeviceY(pi, m.y));
  }
  ctx.globalAlpha = 1;
}

function drawPolarGrid(pi) {
  var panel = spec.panels[pi], pol = panel.polar, r = panel.rect;
  if (!theme.grid_color) return;
  var cx = r.x + r.w / 2, cy = r.y + r.h / 2;
  ctx.strokeStyle = theme.grid_color;
  ctx.lineWidth = 1;
  pol.rings.forEach(function (rn, i) {
    var rr = (pol.inner + (1 - pol.inner) * rn) * 0.5;
    ctx.beginPath();
    ctx.ellipse(cx, cy, rr * r.w, rr * r.h, 0, 0, Math.PI * 2);
    ctx.stroke();
    ctx.fillStyle = theme.label_color;
    ctx.font = theme.tick_font_size + "px " + theme.font;
    ctx.textAlign = "left";
    ctx.textBaseline = "bottom";
    ctx.fillText(pol.ring_labels[i], cx + 3, cy - rr * r.h - 2);
  });
  pol.spokes.forEach(function (sn, i) {
    var ang = pol.start + pol.direction * 2 * Math.PI * sn;
    ctx.strokeStyle = theme.grid_color;
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(cx + 0.5 * r.w * Math.sin(ang), cy - 0.5 * r.h * Math.cos(ang));
    ctx.stroke();
    ctx.fillStyle = theme.label_color;
    ctx.textAlign = Math.abs(Math.sin(ang)) < 0.2 ? "center"
                  : Math.sin(ang) > 0 ? "left" : "right";
    ctx.textBaseline = "middle";
    ctx.fillText(pol.spoke_labels[i],
                 cx + 0.56 * r.w * Math.sin(ang),
                 cy - 0.56 * r.h * Math.cos(ang));
  });
}

function drawPanel(pi) {
  var panel = spec.panels[pi];
  var r = panel.rect;

  ctx.fillStyle = theme.panel_fill;
  ctx.fillRect(r.x, r.y, r.w, r.h);

  if (panel.strip !== null && panel.strip !== undefined) {
    var sh = theme.strip_font_size + 12;
    ctx.fillStyle = theme.strip_fill;
    ctx.fillRect(r.x, r.y - sh, r.w, sh);
    ctx.fillStyle = theme.strip_color;
    ctx.font = theme.strip_font_size + "px " + theme.font;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(panel.strip, r.x + r.w / 2, r.y - sh / 2);
  }

  var xTicks = axisTicks(pi, "x");
  var yTicks = axisTicks(pi, "y");

  if (panel.polar) {
    drawPolarGrid(pi);
  } else if (theme.grid_color) {
    // Minor gridlines first, so the majors draw over them.
    if (theme.grid_color_minor) {
      ctx.strokeStyle = theme.grid_color_minor;
      ctx.lineWidth = 0.5;
      if (theme.grid_major_x && panel.x.ticks.minor) {
        panel.x.ticks.minor.forEach(function (n) {
          var gx = toDeviceX(pi, n);
          ctx.beginPath();
          ctx.moveTo(gx, r.y);
          ctx.lineTo(gx, r.y + r.h);
          ctx.stroke();
        });
      }
      if (theme.grid_major_y && panel.y.ticks.minor) {
        panel.y.ticks.minor.forEach(function (n) {
          var gy = toDeviceY(pi, n);
          ctx.beginPath();
          ctx.moveTo(r.x, gy);
          ctx.lineTo(r.x + r.w, gy);
          ctx.stroke();
        });
      }
    }
    ctx.strokeStyle = theme.grid_color;
    ctx.lineWidth = 1;
    if (theme.grid_major_x) {
      xTicks.forEach(function (t) {
        var gx = toDeviceX(pi, t.norm);
        ctx.beginPath();
        ctx.moveTo(gx, r.y);
        ctx.lineTo(gx, r.y + r.h);
        ctx.stroke();
      });
    }
    if (theme.grid_major_y) {
      yTicks.forEach(function (t) {
        var gy = toDeviceY(pi, t.norm);
        ctx.beginPath();
        ctx.moveTo(r.x, gy);
        ctx.lineTo(r.x + r.w, gy);
        ctx.stroke();
      });
    }
  }

  // Data marks, clipped to the panel. One case per mark type — the same
  // vocabulary as the SVG writer's render_svg_mark().
  ctx.save();
  ctx.beginPath();
  ctx.rect(r.x, r.y, r.w, r.h);
  ctx.clip();
  currentLayers(pi).forEach(function (layer) {
    layer.marks.forEach(function (m) { drawMark(pi, m); });
  });
  ctx.restore();

  if (theme.panel_border) {
    ctx.strokeStyle = theme.panel_border;
    ctx.lineWidth = 1;
    ctx.strokeRect(r.x, r.y, r.w, r.h);
  }
  if (panel.polar) return;

  ctx.strokeStyle = theme.axis_color;
  ctx.lineWidth = 1;
  ctx.beginPath();
  if (theme.axis_line_x) {
    ctx.moveTo(r.x, r.y + r.h);
    ctx.lineTo(r.x + r.w, r.y + r.h);
  }
  if (theme.axis_line_y) {
    ctx.moveTo(r.x, r.y);
    ctx.lineTo(r.x, r.y + r.h);
  }
  ctx.stroke();

  ctx.fillStyle = theme.label_color;
  ctx.font = theme.tick_font_size + "px " + theme.font;
  if (panel.show_x_labels && theme.axis_text_x) {
    ctx.textAlign = "center";
    ctx.textBaseline = "top";
    xTicks.forEach(function (t) {
      var tx = toDeviceX(pi, t.norm);
      if (tx < r.x - 1 || tx > r.x + r.w + 1) return;
      if (theme.ticks_x) {
        ctx.beginPath();
        ctx.moveTo(tx, r.y + r.h);
        ctx.lineTo(tx, r.y + r.h + theme.tick_len);
        ctx.stroke();
      }
      ctx.fillText(t.label, tx, r.y + r.h + theme.tick_len + 2);
    });
  }
  if (panel.show_y_labels && theme.axis_text_y) {
    ctx.textAlign = "right";
    ctx.textBaseline = "middle";
    yTicks.forEach(function (t) {
      var ty = toDeviceY(pi, t.norm);
      if (ty < r.y - 1 || ty > r.y + r.h + 1) return;
      if (theme.ticks_y) {
        ctx.beginPath();
        ctx.moveTo(r.x - theme.tick_len, ty);
        ctx.lineTo(r.x, ty);
        ctx.stroke();
      }
      ctx.fillText(t.label, r.x - theme.tick_len - 3, ty);
    });
  }
}

function drawTitles() {
  var lb = spec.labels;
  var y = 8;
  var indent = lb.tag ? 34 : 10;
  ctx.textAlign = "left";
  ctx.textBaseline = "alphabetic";
  if (lb.tag) {
    ctx.fillStyle = theme.title_color;
    ctx.font = "bold " + theme.plot_title_size + "px " + theme.title_font;
    ctx.fillText(lb.tag, 10, y + theme.plot_title_size);
  }
  if (lb.title) {
    y += theme.plot_title_size;
    ctx.fillStyle = theme.title_color;
    ctx.font = (theme.title_face === "bold" ? "bold " : "") +
               theme.plot_title_size + "px " + theme.title_font;
    ctx.fillText(lb.title, indent, y);
    y += 6;
  }
  if (lb.subtitle) {
    y += theme.plot_subtitle_size;
    ctx.fillStyle = theme.subtitle_color;
    ctx.font = theme.plot_subtitle_size + "px " + theme.font;
    ctx.fillText(lb.subtitle, indent, y);
  }
  if (lb.caption) {
    ctx.fillStyle = theme.subtitle_color;
    ctx.font = theme.caption_size + "px " + theme.font;
    ctx.textAlign = "right";
    ctx.fillText(lb.caption, spec.width - 10, spec.height - 8);
  }
}

function drawAxisTitles() {
  var first = spec.panels[0];
  var last = spec.panels[spec.panels.length - 1];
  var capPad = spec.labels.caption ? theme.caption_size + 8 : 0;
  var legPad = spec.legend_position === "bottom" ? 34 : 0;
  ctx.fillStyle = theme.label_color;
  ctx.font = theme.title_font_size + "px " + theme.font;
  ctx.textAlign = "center";
  ctx.textBaseline = "alphabetic";
  if (theme.axis_title_x && first.x.title) {
    ctx.fillText(first.x.title,
                 (first.rect.x + last.rect.x + last.rect.w) / 2,
                 spec.height - 10 - capPad - legPad);
  }
  if (theme.axis_title_y && first.y.title) {
    ctx.save();
    ctx.translate(15, (first.rect.y + last.rect.y + last.rect.h) / 2);
    ctx.rotate(-Math.PI / 2);
    ctx.fillText(first.y.title, 0, 0);
    ctx.restore();
  }
}

function drawLegend() {
  var lg = spec.legend;
  if (!lg) return;
  var first = spec.panels[0];
  var last = spec.panels[spec.panels.length - 1];
  ctx.textBaseline = "middle";

  if (spec.legend_position === "bottom") {
    var yb = spec.height - 18 - (spec.labels.caption ? theme.caption_size + 8 : 0);
    var n = lg.labels.length, itemW = 90;
    var x0 = (spec.width - n * itemW) / 2;
    ctx.font = theme.legend_font_size + "px " + theme.font;
    for (var k = 0; k < n; k++) {
      var xi = x0 + k * itemW;
      ctx.fillStyle = lg.type === "gradient" ? lg.high : lg.colors[k];
      ctx.fillRect(xi, yb - 6, 12, 12);
      ctx.fillStyle = theme.legend_text_color;
      ctx.textAlign = "left";
      ctx.fillText(lg.labels[k], xi + 18, yb);
    }
    return;
  }

  var lx = last.rect.x + last.rect.w + 14;
  var ly = first.rect.y + 8;
  ctx.textAlign = "left";
  if (lg.title) {
    ctx.fillStyle = theme.label_color;
    ctx.font = theme.title_font_size + "px " + theme.font;
    ctx.fillText(lg.title, lx, ly);
  }
  ctx.font = theme.legend_font_size + "px " + theme.font;
  if (lg.type === "gradient") {
    var barH = 120;
    var grad = ctx.createLinearGradient(0, ly + 10 + barH, 0, ly + 10);
    grad.addColorStop(0, lg.low);
    grad.addColorStop(1, lg.high);
    ctx.fillStyle = grad;
    ctx.fillRect(lx, ly + 10, 14, barH);
    for (var g = 0; g < lg.labels.length; g++) {
      var yy = ly + 10 + barH - (g / Math.max(1, lg.labels.length - 1)) * barH;
      ctx.fillStyle = theme.legend_text_color;
      ctx.fillText(lg.labels[g], lx + 20, yy);
    }
    return;
  }
  for (var i = 0; i < lg.labels.length; i++) {
    var sy = ly + 16 + i * 20;
    ctx.fillStyle = lg.colors[i];
    ctx.fillRect(lx, sy - 6, 12, 12);
    ctx.fillStyle = theme.legend_text_color;
    ctx.fillText(lg.labels[i], lx + 18, sy);
  }
}

function draw() {
  ctx.clearRect(0, 0, spec.width, spec.height);
  ctx.fillStyle = theme.background;
  ctx.fillRect(0, 0, spec.width, spec.height);
  drawTitles();
  for (var i = 0; i < spec.panels.length; i++) drawPanel(i);
  drawAxisTitles();
  drawLegend();
}

// ---- animation -------------------------------------------------------------
// Frames are complete per-panel mark sets computed in R by the same
// pipeline as the static render — the player only swaps which set is drawn.

var frameIndex = 0;
var playing = false;
var playTimer = null;

function currentLayers(pi) {
  if (!spec.animation) return spec.panels[pi].layers;
  var fr = spec.animation.frames[frameIndex];
  return fr[pi] || [];
}

if (spec.animation) {
  var player = document.getElementById("ggnext-player");
  var scrub = document.getElementById("ggnext-scrub");
  var playBtn = document.getElementById("ggnext-play");
  var frameLabel = document.getElementById("ggnext-frame-label");
  player.style.display = "flex";
  scrub.max = String(spec.animation.labels.length - 1);

  function showFrame(i) {
    frameIndex = i;
    scrub.value = String(i);
    frameLabel.textContent = spec.animation.var + ": " + spec.animation.labels[i];
    draw();
  }
  function step() {
    var next = frameIndex + 1;
    if (next >= spec.animation.labels.length) {
      if (!spec.animation.loop) { stopPlaying(); return; }
      next = 0;
    }
    showFrame(next);
  }
  function startPlaying() {
    playing = true;
    playBtn.textContent = "Pause";
    playTimer = setInterval(step, spec.animation.duration);
  }
  function stopPlaying() {
    playing = false;
    playBtn.textContent = "Play";
    if (playTimer) clearInterval(playTimer);
    playTimer = null;
  }
  playBtn.addEventListener("click", function () {
    if (playing) stopPlaying(); else startPlaying();
  });
  scrub.addEventListener("input", function () {
    stopPlaying();
    showFrame(Number(scrub.value));
  });
  showFrame(0);
}

// ---- hover tooltip ---------------------------------------------------------
// Wired only when the plot's interact() spec asks for it.

if (spec.interaction.tooltip) {

canvas.addEventListener("mousemove", function (e) {
  if (brushActive) { tooltip.style.display = "none"; return; }
  var rect = canvas.getBoundingClientRect();
  var mx = e.clientX - rect.left;
  var my = e.clientY - rect.top;
  var pi = panelAt(mx, my);
  if (pi < 0) { tooltip.style.display = "none"; return; }

  var best = null, bestDist = Infinity;
  currentLayers(pi).forEach(function (layer) {
    layer.marks.forEach(function (m, i) {
      if (m.type !== "circle") return;
      var dx = toDeviceX(pi, m.x) - mx;
      var dy = toDeviceY(pi, m.y) - my;
      var d = Math.sqrt(dx * dx + dy * dy);
      if (d < bestDist) {
        bestDist = d;
        best = { mark: m, tip: layer.tips[i], pi: pi };
      }
    });
  });
  if (best && best.tip && bestDist <= Math.max(best.mark.r + 3, 8)) {
    tooltip.textContent = best.tip;
    tooltip.style.left = (toDeviceX(best.pi, best.mark.x) + 12) + "px";
    tooltip.style.top = (toDeviceY(best.pi, best.mark.y) - 10) + "px";
    tooltip.style.display = "block";
  } else {
    tooltip.style.display = "none";
  }
});

canvas.addEventListener("mouseleave", function () {
  tooltip.style.display = "none";
});

}

// ---- scroll-to-zoom --------------------------------------------------------

if (spec.interaction.zoom) {

canvas.addEventListener("wheel", function (e) {
  var rect = canvas.getBoundingClientRect();
  var mx = e.clientX - rect.left;
  var my = e.clientY - rect.top;
  var pi = panelAt(mx, my);
  if (pi < 0) return;
  e.preventDefault();
  var r = spec.panels[pi].rect, v = views[pi];
  // Mouse position in normalized coordinates: the zoom anchor.
  var fx = v.x0 + ((mx - r.x) / r.w) * (v.x1 - v.x0);
  var fy = v.y0 + ((r.y + r.h - my) / r.h) * (v.y1 - v.y0);
  var factor = e.deltaY < 0 ? 0.85 : 1 / 0.85;
  var nx0 = fx + (v.x0 - fx) * factor, nx1 = fx + (v.x1 - fx) * factor;
  var ny0 = fy + (v.y0 - fy) * factor, ny1 = fy + (v.y1 - fy) * factor;
  if (nx1 - nx0 > 1e-6 && nx1 - nx0 < 100) {
    views[pi] = { x0: nx0, x1: nx1, y0: ny0, y1: ny1 };
    draw();
  }
}, { passive: false });

}

// ---- brush-to-zoom ---------------------------------------------------------
// Drag a rectangle; on release the panel zooms to it. Double-click resets.

if (spec.interaction.brush) {

canvas.addEventListener("mousedown", function (e) {
  var rect = canvas.getBoundingClientRect();
  var mx = e.clientX - rect.left, my = e.clientY - rect.top;
  var pi = panelAt(mx, my);
  if (pi < 0) return;
  brushPanel = pi;
  brushActive = { x0: mx, y0: my, x1: mx, y1: my };
});

canvas.addEventListener("mousemove", function (e) {
  if (!brushActive) return;
  var rect = canvas.getBoundingClientRect();
  brushActive.x1 = e.clientX - rect.left;
  brushActive.y1 = e.clientY - rect.top;
  draw();
  ctx.save();
  ctx.strokeStyle = "#4A6DB5";
  ctx.fillStyle = "rgba(74, 109, 181, 0.15)";
  ctx.lineWidth = 1;
  var bx = Math.min(brushActive.x0, brushActive.x1);
  var by = Math.min(brushActive.y0, brushActive.y1);
  var bw = Math.abs(brushActive.x1 - brushActive.x0);
  var bh = Math.abs(brushActive.y1 - brushActive.y0);
  ctx.fillRect(bx, by, bw, bh);
  ctx.strokeRect(bx, by, bw, bh);
  ctx.restore();
});

window.addEventListener("mouseup", function () {
  if (!brushActive) return;
  var b = brushActive, pi = brushPanel;
  brushActive = null;
  brushPanel = -1;
  // Ignore accidental tiny drags (they are clicks, not brushes).
  if (Math.abs(b.x1 - b.x0) > 8 && Math.abs(b.y1 - b.y0) > 8) {
    var r = spec.panels[pi].rect, v = views[pi];
    var toNX = function (px) { return v.x0 + ((px - r.x) / r.w) * (v.x1 - v.x0); };
    var toNY = function (py) { return v.y0 + ((r.y + r.h - py) / r.h) * (v.y1 - v.y0); };
    views[pi] = {
      x0: toNX(Math.min(b.x0, b.x1)), x1: toNX(Math.max(b.x0, b.x1)),
      y0: toNY(Math.max(b.y0, b.y1)), y1: toNY(Math.min(b.y0, b.y1))
    };
  }
  draw();
});

}

// Double-click resets every panel to the full domain.
canvas.addEventListener("dblclick", function () {
  views = spec.panels.map(function () {
    return { x0: 0, x1: 1, y0: 0, y1: 1 };
  });
  draw();
});

draw();
</script>
</body>
</html>
