inertial pan과 smooth zoom
infinite canvas는 navigation 감각이 중요합니다. pan과 zoom이 갑자기 멈추거나 cursor anchor가 깨지면 사용자는 위치를 잃습니다.
cursor anchor를 유지하는 smooth zoom 코드
smooth zoom을 하더라도 cursor 아래 world point가 밀리면 안 됩니다.
function zoomAt(camera, screenPoint, nextZoom) {
const before = screenToWorld(screenPoint, camera);
const next = { ...camera, zoom: nextZoom };
const after = screenToWorld(screenPoint, next);
return {
...next,
x: next.x + before.x - after.x,
y: next.y + before.y - after.y
};
}
function stepInertialPan(camera, velocity, dt) {
const friction = Math.pow(0.002, dt);
return {
camera: {
...camera,
x: camera.x - velocity.x * dt,
y: camera.y - velocity.y * dt
},
velocity: {
x: velocity.x * friction,
y: velocity.y * friction
}
};
}
camera motion
velocity *= friction;
camera.x += velocity.x * delta;
camera.y += velocity.y * delta;
smooth zoom도 cursor anchored zoom 원칙을 유지해야 합니다.
wheel 입력을 camera 목표값으로 누적한다
function onWheel(event, cameraMotion) {
event.preventDefault();
if (event.ctrlKey || event.metaKey) {
const zoomDelta = Math.exp(-event.deltaY * 0.002);
cameraMotion.target = zoomAt(
cameraMotion.target,
{ x: event.offsetX, y: event.offsetY },
cameraMotion.target.zoom * zoomDelta
);
return;
}
cameraMotion.target.x += event.deltaX / cameraMotion.target.zoom;
cameraMotion.target.y += event.deltaY / cameraMotion.target.zoom;
}
function stepCameraMotion(motion, dt) {
motion.current = {
...motion.current,
x: damp(motion.current.x, motion.target.x, 18, dt),
y: damp(motion.current.y, motion.target.y, 18, dt),
zoom: damp(motion.current.zoom, motion.target.zoom, 18, dt)
};
}
입력 이벤트에서 곧바로 화면을 그리기보다 camera target을 갱신하고 render loop에서 보간합니다. 이렇게 하면 wheel 이벤트 빈도와 frame rate가 달라도 움직임이 안정적입니다.
오늘의 핵심
모션은 장식이 아니라 조작 감각입니다. 예측 가능성이 화려함보다 중요합니다.