very smooth pan+zoom logic in react+canvas
- worldToScreen()
- screenToWorld()
import { useEffect, useRef } from "react"; | |
class Point { | |
constructor(public x: number = 0, public y: number = 0) {} | |
} | |
export default function App() { | |
const canvasRef = useRef<HTMLCanvasElement>(null); | |
useEffect(() => { | |
const canvas = canvasRef.current!!; | |
const ctx = canvas.getContext("2d")!!; | |
let isDragging = false; | |
let startDrag = new Point(); | |
let offset = new Point(); | |
let currentZoom = 1; | |
const minZoom = 0.1; // Minimum zoom level | |
const maxZoom = 8; // Maximum zoom level | |
canvas.addEventListener("pointerdown", (e) => { | |
const down = getMousePoint(e); | |
console.log("offset", offset, "down", down); | |
isDragging = true; | |
startDrag.x = down.x - offset.x; | |
startDrag.y = down.y - offset.y; | |
canvas.setPointerCapture(e.pointerId); | |
}); | |
canvas.addEventListener("pointerup", (e) => { | |
isDragging = false; | |
const up = getMousePoint(e); | |
canvas.releasePointerCapture(e.pointerId); | |
}); | |
canvas.addEventListener("pointermove", (e) => { | |
const move = getMousePoint(e); | |
if (isDragging) { | |
offset.x = move.x - startDrag.x; | |
offset.y = move.y - startDrag.y; | |
onDraw(); | |
} else { | |
const world = screenToWorld(move); | |
const screen=worldToScreen(world) | |
console.log("world", world,"screen",screen); | |
} | |
}); | |
canvas.addEventListener("wheel", (e) => { | |
const point = getMousePoint(e as any); | |
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; | |
const newZoom = currentZoom * zoomFactor; | |
if (newZoom >= minZoom && newZoom <= maxZoom) { | |
// Calculate the translation needed to keep the mouse cursor fixed | |
const scale = newZoom / currentZoom; | |
const dx = (point.x - offset.x) * (1 - scale); | |
const dy = (point.y - offset.y) * (1 - scale); | |
offset.x += dx; | |
offset.y += dy; | |
currentZoom = newZoom; | |
onDraw(); | |
} | |
}); | |
function onDraw() { | |
ctx.clearRect(0, 0, 600, 400); | |
ctx.save(); | |
ctx.translate(offset.x, offset.y); | |
ctx.scale(currentZoom, currentZoom); | |
drawLines([new Point(0, 1000), new Point(0, 0), new Point(1000, 0)]); | |
ctx.fillRect(70, 70, 100, 100); | |
ctx.fillRect(200, 70, 100, 50); | |
ctx.restore(); | |
} | |
onDraw(); | |
//utils | |
function getMousePoint(e: PointerEvent) { | |
const rect = canvas.getBoundingClientRect(); | |
return { | |
x: e.clientX - rect.left, | |
y: e.clientY - rect.top, | |
} as Point; | |
} | |
function drawLines(points: Point[]) { | |
if (points.length < 2) { | |
return; | |
} | |
ctx.beginPath(); | |
ctx.moveTo(points[0].x, points[0].y); | |
for (let i = 1; i < points.length; i++) { | |
ctx.lineTo(points[i].x, points[i].y); | |
} | |
ctx.stroke(); | |
} | |
function screenToWorld(screen: Point) { | |
const worldX = (screen.x - offset.x) / currentZoom; | |
const worldY = (screen.y - offset.y) / currentZoom; | |
return new Point(worldX, worldY); | |
} | |
function worldToScreen(world: Point) { | |
const screenX = world.x * currentZoom + offset.x; | |
const screenY = world.y * currentZoom + offset.y; | |
return new Point(screenX, screenY); | |
} | |
}, []); | |
return ( | |
<div> | |
<canvas | |
ref={canvasRef} | |
width={"600px"} | |
height={"400px"} | |
style={{ | |
border: "black solid 1px", | |
}} | |
></canvas> | |
</div> | |
); | |
} |