Scroll wheel zooms in/out smoothly around the center to prove readable scaling (no game logic, just view transform).


Code for Above

<div style="max-width:1500px;margin:0 auto;">
<iframe
  title="POC — Smooth Zoom-at-Mouse + Drag Pan (Fixed)"
  scrolling="no"
  style="width:1500px;height:1500px;border:0;display:block;background:#000;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.5);"
  sandbox="allow-scripts allow-same-origin"
  srcdoc='
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>POC — Smooth Zoom-at-Mouse + Drag Pan (Fixed)</title>
<style>
  html, body { margin:0; width:100%; height:100%; overflow:hidden; background:#000; }
  canvas { display:block; outline:none; }
  .hint { position:absolute; left:12px; bottom:12px; color:#aaa; font:14px system-ui; user-select:none; }
</style>
</head>
<body>
<canvas id="c" width="1500" height="1500" tabindex="0"></canvas>
<div class="hint">Wheel: zoom at mouse • Drag (hold mouse): pan • Click canvas to focus</div>

<script>
const c = document.getElementById("c");
const x = c.getContext("2d");
const W = 1500, H = 1500;

c.focus();
c.addEventListener("pointerdown", () => c.focus());
c.addEventListener("wheel", (e) => e.preventDefault(), { passive:false });

const WW = 4200, WH = 4200;
const dots = Array.from({length: 140}, () => ({
  x: Math.random()*WW,
  y: Math.random()*WH,
  r: 3 + Math.random()*9
}));

let camX = (WW - W)/2;
let camY = (WH - H)/2;

let zoom = 1.0;
let zoomTarget = 1.0;
const ZMIN = 0.25, ZMAX = 3.5;
const Z_LERP = 0.16;
const STEP = 1.28;

// mouse position in canvas
let mx = W/2, my = H/2;
function canvasPos(e){
  const r = c.getBoundingClientRect();
  mx = (e.clientX - r.left) * (c.width / r.width);
  my = (e.clientY - r.top)  * (c.height / r.height);
}
c.addEventListener("mousemove", canvasPos);

function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
function lerp(a,b,t){ return a + (b-a)*t; }

function screenToWorld(sx, sy, z=zoom){
  return { wx: camX + sx / z, wy: camY + sy / z };
}

// Drag pan (works always, not overridden)
let dragging = false;
let lastCX = 0, lastCY = 0;

c.addEventListener("mousedown", (e) => {
  dragging = true;
  lastCX = e.clientX;
  lastCY = e.clientY;
  canvasPos(e);
});
addEventListener("mouseup", () => dragging = false);

addEventListener("mousemove", (e) => {
  if (!dragging) return;
  const dx = e.clientX - lastCX;
  const dy = e.clientY - lastCY;
  lastCX = e.clientX;
  lastCY = e.clientY;

  camX -= dx / zoom;
  camY -= dy / zoom;
});

// Zoom anchor set on wheel
let anchorWX = WW/2;
let anchorWY = WH/2;
let anchorSX = W/2;
let anchorSY = H/2;

c.addEventListener("wheel", (e) => {
  canvasPos(e);

  // anchor = point under mouse at wheel time
  anchorSX = mx;
  anchorSY = my;
  const w = screenToWorld(anchorSX, anchorSY, zoom);
  anchorWX = w.wx;
  anchorWY = w.wy;

  if (e.deltaY < 0) zoomTarget = clamp(zoomTarget * STEP, ZMIN, ZMAX);
  else             zoomTarget = clamp(zoomTarget / STEP, ZMIN, ZMAX);
}, { passive:false });

function draw(){
  // smooth zoom toward target
  zoom = lerp(zoom, zoomTarget, Z_LERP);

  // ONLY while zoom is changing, keep anchor fixed
  if (Math.abs(zoom - zoomTarget) > 0.001) {
    camX = anchorWX - (anchorSX / zoom);
    camY = anchorWY - (anchorSY / zoom);
  }

  // clamp camera to world bounds for current zoom
  const viewW = W / zoom;
  const viewH = H / zoom;
  camX = clamp(camX, 0, WW - viewW);
  camY = clamp(camY, 0, WH - viewH);

  x.clearRect(0,0,W,H);

  // world->screen (translate then scale)
  x.save();
  x.translate(-camX * zoom, -camY * zoom);
  x.scale(zoom, zoom);

  // grid
  x.strokeStyle = "rgba(255,255,255,0.06)";
  x.lineWidth = 1/zoom;
  const step = 150;
  const startX = Math.floor(camX/step)*step;
  const startY = Math.floor(camY/step)*step;

  for (let gx = startX; gx <= camX + viewW; gx += step){
    x.beginPath(); x.moveTo(gx, camY); x.lineTo(gx, camY + viewH); x.stroke();
  }
  for (let gy = startY; gy <= camY + viewH; gy += step){
    x.beginPath(); x.moveTo(camX, gy); x.lineTo(camX + viewW, gy); x.stroke();
  }

  // border
  x.strokeStyle = "rgba(255,255,255,0.18)";
  x.lineWidth = 2/zoom;
  x.strokeRect(0,0,WW,WH);

  // dots
  x.fillStyle = "rgba(255,255,255,0.75)";
  for (const d of dots){
    x.beginPath();
    x.arc(d.x, d.y, d.r, 0, Math.PI*2);
    x.fill();
  }

  x.restore();

  // HUD
  x.fillStyle = "rgba(255,255,255,0.6)";
  x.font = "14px system-ui";
  x.fillText(`zoom: ${zoom.toFixed(2)} (target ${zoomTarget.toFixed(2)})  cam: ${camX.toFixed(0)}, ${camY.toFixed(0)}`, 12, 24);

  requestAnimationFrame(draw);
}

draw();
</script>
</body>
</html>
'></iframe>
</div>