这是纯前端手搓虚拟世界第五篇。
高瞻远瞩
前端佬们,上一篇我们基本实现了"一个无限空间"的canvas,好虽然是好,但这种方式,你的视野被锁死了。这就好比你在看地图时,却被禁用了"缩放"功能一样。
这完全丧失了那种高瞻远瞩的感觉。

本篇要给加这套逻辑,核心交互是:鼠标滚轮滚动时,以鼠标当前位置为中心,进行放大或缩小。
别小看这个"以鼠标为中心",很多初学者写出来的缩放,一滚轮下去,画面直接飞到九霄云外去了(通常是缩放到 (0,0) 原点去了)。我们的目标,是丝般顺滑的专业级缩放。
战略思考
还是老规矩,怎么去实现这玩意呢??
应该有前端佬的第一想法,是canvas的缩放 scale 功能。
bingo!!优秀如你!
就是它。

战术规划
-
第一步:数学原理修正。引入缩放(Scale)后,屏幕上的 100px 可能只代表世界里的 50px(放大 2 倍)。之前的公式要改。
-
第二步:实现"定点缩放" 。
- 原理:在缩放发生前 ,记下鼠标指向的世界坐标
P1。 - 执行缩放(改变
zoom值)。 - 在缩放发生后 ,计算鼠标现在指向的世界坐标
P2。 - 计算偏移量
delta = P2 - P1,并把它加回到视口的offset里。 - 一句话解释:如果画面因为缩放跑偏了,我们就把它拽回来。
- 原理:在缩放发生前 ,记下鼠标指向的世界坐标
-
第三步:渲染层应用 。
translate和scale的顺序至关重要。
上代码
更改Viewport类
去 src/view/viewport.js 动大手术。
注意: 这里我们要修正一下上一篇的 getMouse 逻辑。标准的图形学做法是:放大 = 视野变小 = 坐标除以缩放系数。
JavaScript
kotlin
// src/view/viewport.js
import Point2D from "../primitives/point2D.js";
export default class Viewport {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.zoom = 1; // 1 = 100%, 2 = 200%
this.center = new Point2D(canvas.width / 2, canvas.height / 2);
// offset 表示摄像机的平移位置
this.offset = new Point2D(0, 0);
this.drag = {
start: new Point2D(0, 0),
end: new Point2D(0, 0),
offset: new Point2D(0, 0),
active: false
};
this.#addEventListeners();
}
// 【核心数学升级】
// 屏幕坐标 -> 世界坐标
getMouse(evt, subtractDragOffset = false) {
// 1. 减去屏幕中心(把原点挪到屏幕中间)
// 2. 除以缩放(反向映射:屏幕像素 / 缩放 = 世界距离)
// 3. 减去摄像机偏移
const p = new Point2D(
(evt.offsetX - this.center.x) / this.zoom - this.offset.x,
(evt.offsetY - this.center.y) / this.zoom - this.offset.y
);
return p;
}
// 获取计算后的偏移量(给 Canvas 渲染用)
getOffset() {
return new Point2D(
this.offset.x + this.center.x,
this.offset.y + this.center.y
);
}
#addEventListeners() {
// ... (保留上一篇的空格键监听代码) ...
// ... (保留上一篇的 mousedown 监听代码) ...
// ... (保留上一篇的 mouseup 监听代码) ...
// 【新增】鼠标滚轮监听
this.canvas.addEventListener("wheel", (evt) => this.#handleWheel(evt));
// ... (保留 mousemove,但要注意里面 getMouse 的调用) ...
// 稍微修正一下 mousemove 里的逻辑,确保它是基于最新的 zoom 计算的
this.canvas.addEventListener("mousemove", (evt) => {
if (this.drag.active) {
this.drag.end = this.getMouse(evt);
this.drag.offset = new Point2D(
this.drag.end.x - this.drag.start.x,
this.drag.end.y - this.drag.start.y
);
// 累加
this.offset.x += this.drag.offset.x;
this.offset.y += this.drag.offset.y;
// 重置 start
this.drag.start = this.getMouse(evt);
}
});
}
// 【新增】处理滚轮缩放
#handleWheel(evt) {
// 1. 判断滚轮方向 (向上滚是负数-放大,向下滚是正数-缩小)
const dir = Math.sign(evt.deltaY);
// 2. 定义缩放步长 (每次滚大概变 10%)
const step = 0.1;
// 此处应用"定点缩放"策略:
// 第一步:缩放前,鼠标在世界里的哪里?
// (注意:这里直接透传 evt 进去算,不需要 subtractDragOffset)
const mouseBefore = this.getMouse(evt);
// 第二步:执行缩放
this.zoom += dir * step;
// 限制一下缩放范围,别缩没了或者放太大
this.zoom = Math.max(1, Math.min(5, this.zoom));
// 第三步:缩放后,鼠标在世界里的哪里?
const mouseAfter = this.getMouse(evt);
// 第四步:计算偏差,修正 offset
// 既然鼠标不应该动,那如果 mouseAfter 变了,说明世界"滑"走了
// 我们要把世界拽回来
this.offset.x += (mouseAfter.x - mouseBefore.x);
this.offset.y += (mouseAfter.y - mouseBefore.y);
// 阻止浏览器默认的滚动页面行为
evt.preventDefault();
}
}
补充说明 :你可能注意到
mousemove里我没怎么改。因为getMouse内部已经应用了this.zoom。只要getMouse公式是对的,拖拽平移逻辑就能自动适配缩放(比如放大后,拖拽会变慢,这是符合物理直觉的,因为你视野变小了)。
Again And Again
现在数据层对了,但现在运行代码,会发现画面没有任何变化。
憋急,因为 Canvas 还不知道要缩放。
在src/index.js,需要调整 ctx 的变换顺序。
脑子里记着:先平移到中心,再缩放,再平移回视口偏移。
JavaScript
kotlin
// src/index.js
// ... imports
export default class World {
// ... constructor 不变
animate() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.save();
// 1. 把坐标原点移到屏幕中心
// 为什么要这么做?因为 scale 默认是基于 (0,0) 左上角缩放的。
// 我们希望基于屏幕中心缩放,这样更符合直觉。
this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
// 2. 应用缩放
this.ctx.scale(this.viewport.zoom, this.viewport.zoom);
// 3. 应用视口偏移 (Viewport Offset)
// 这里的 offset 已经在 Viewport 类里处理得很好了
const offset = this.viewport.getOffset();
// 这里的 translate 是为了让 (0,0) 回到它该去的地方
// 注意:因为上面已经把原点移到了中心,这里要减去中心吗?
// 不,因为 Viewport.getOffset 已经包含了 center 的逻辑?
// 让我们看一眼 Viewport.js 的 getOffset: return this.offset + this.center;
// 等等,这里的数学有点绕。让我们用最简单的推导:
// 我们希望 point.x 最终渲染在: (point.x + offset.x) * zoom + center.x
// Canvas 的变换栈是反向生效的(或者说矩阵乘法):
// 修正后的渲染逻辑:
// 我们在 Viewport.js 的 getMouse 里用的公式是: (Screen - Center) / Zoom - Offset
// 逆推回 Screen: Screen = (World + Offset) * Zoom + Center
// 所以 Canvas 代码应该是:
// Translate(Center) -> Scale(Zoom) -> Translate(Offset)
// 这里的 offset 只需要 viewport.offset,不需要加 center
// 让我们回去微调一下 Viewport.js 的 getOffset 方法,让它只返回纯粹的 offset
const viewportOffset = this.viewport.getOffset();
this.ctx.translate(this.viewport.offset.x, this.viewport.offset.y);
// 画它!
this.editor.display();
this.ctx.restore();
requestAnimationFrame(() => this.animate());
}
}
为了配合上面 World 的清晰逻辑,我把 src/view/viewport.js 里的 getOffset 删掉,或者直接访问 this.viewport.offset。
为了代码整洁,在 Viewport 里加个 getter,只返回 offset 属性即可,不需要加 center。
JavaScript
javascript
// 修改 src/view/viewport.js
getOffset() {
return this.offset;
}
完美
现在运行代码,你会发现:
- 对着某个点放大,那个点会稳稳地停在鼠标指尖下,周围的世界向外扩散。
- 线条会随着放大变粗(因为
ctx.lineWidth也被放大了)。这其实挺好的,细节看得更清楚。如果你不希望线变粗,那就是另外一个进阶话题(逆缩放线宽)。
现在虚拟世界已经初具规模,已经实现类的如下:
- 原子层:点、线(Point2D/Segment)。
- 数据层:图结构(Graph)。
- 交互层:编辑器(Editor)。
- 视觉层:无限画布 + 自由缩放(Viewport)。
完全可以毫不吹水的说,我们已经构建了一个类似于 AutoCAD 或 Google Maps 的基础引擎。
嘎嘎~
另,这个专栏打算放一放了,好像没啥人关注,实在没啥动力了~