以鼠标为锚点缩放
核心数学原理
缩放前后,鼠标指向的那个世界坐标点必须保持在同一屏幕位置。
已知:旧缩放 oldZoom、新缩放 newZoom、鼠标锚点屏幕坐标 (ax, ay)、当前偏移 (panX, panY)
步骤1:算出旧缩放下鼠标指向的世界坐标
wx = (ax - panX) / oldZoom
wy = (ay - panY) / oldZoom
步骤2:新缩放后反解新偏移,让 wx/wy 仍落在 (ax, ay)
newPanX = ax - wx * newZoom
newPanY = ay - wy * newZoom关键代码:缩放函数
lua-- 校园地图.lua 版本(参考实现,手感好) local function ApplyZoom(delta, anchorX, anchorY) local newZoom = zoomLevel_ + delta newZoom = math.max(ZOOM_MIN, math.min(ZOOM_MAX, newZoom)) if newZoom == zoomLevel_ then return end -- 锚点:鼠标缓存坐标,无则视口中心 local ax = anchorX local ay = anchorY if not ax then local viewRect = UI.GetVisualRect(mapView) ax = viewRect.w * 0.5 ay = viewRect.h * 0.5 end local oldZoom = zoomLevel_ local wx = (ax - worldOffsetX_) / oldZoom local wy = (ay - worldOffsetY_) / oldZoom zoomLevel_ = newZoom worldOffsetX_ = ax - wx * newZoom worldOffsetY_ = ay - wy * newZoom ApplyTransformInPlace() end
鼠标坐标缓存(OnWheel 不带坐标,必须缓存)
lua-- 在 onPointerMove 中缓存(每帧鼠标移动时更新) onPointerMove = function(event, widget) if event and event.x and event.y then lastMouseX_ = event.x -- 已是 widget 本地坐标 lastMouseY_ = event.y end -- ... 其他逻辑 end, -- OnWheel 回调读缓存 function mapView:OnWheel(dx, dy) if dy == 0 then return end local step = dy > 0 and ZOOM_STEP or -ZOOM_STEP ApplyZoom(step, lastMouseX_, lastMouseY_) end
坐标空间注意事项(地图编辑器踩的坑)
关键:缓存的坐标和 pan 偏移必须在同一坐标空间!
| 场景 | event.x 坐标空间 | panX 坐标空间 | 是否一致 |
| 直接在 viewport 上缓存 | viewport 本地坐标 | viewport 本地 | 直接用 |
| 在 transform 子节点上缓存 | 子节点本地坐标(已逆变换) | viewport 本地 | 需转换 |
子节点上的转换公式:
lua-- mapSurface 有 scale+left/top transform,event.x 是 surface 本地坐标 -- 需要转回 viewport 坐标才能和 panX 同空间 cs.lastMouseX = event.x * cs.zoom + cs.panX cs.lastMouseY = event.y * cs.zoom + cs.panY
偏移应用方式:Yoga left/top 优于 CSS transform
lua-- ✅ 推荐:Yoga 布局属性,更稳定 local function ApplyTransformInPlace() worldLayerRef_:SetStyle({ scale = zoomLevel_, left = worldOffsetX_, top = worldOffsetY_, }) end -- ❌ 避免:直接写 props.translateX/translateY(CSS transform 属性,在 Yoga 中不稳定) self.mapSurface.props.translateX = cs.panX self.mapSurface.props.translateY = cs.panY
构造时必须声明 transformOrigin:
luaUI.Panel { position = "absolute", left = 0, top = 0, transformOrigin = "top-left", -- 必须!scale 从左上角扩散,与 left/top 公式一致 -- ... }
线性步进 vs 乘法缩放
lua-- ✅ 线性加法:全程手感一致,步长均匀 local ZOOM_STEP = 0.03 -- 校园地图 local CANVAS_ZOOM_STEP = 0.05 -- 编辑器(稍大) newZoom = oldZoom + step -- ❌ 乘法指数:zoom 小时步长太小(几乎不动),zoom 大时步长太大(跳变) local zoomFactor = deltaY > 0 and 1.12 or 0.89 newZoom = oldZoom * zoomFactor
完整常量配置参考
lua-- 校园地图(运行时) local ZOOM_MIN = 0.05 local ZOOM_MAX = 0.6 local ZOOM_STEP = 0.03 local zoomLevel_ = 0.13 -- 默认 -- 地图编辑器 local CANVAS_MIN_ZOOM = 0.05 local CANVAS_MAX_ZOOM = 0.8 local CANVAS_ZOOM_STEP = 0.05 local CANVAS_DEFAULT_ZOOM = 0.18 -- 默认
避坑清单
- OnWheel 不带坐标 — 必须在 onPointerMove/onPointerDown 中缓存 event.x/event.y
- 坐标空间必须一致 — event.x 是 widget 本地坐标,pan 偏移也必须是同一父容器的本地坐标;在 transform 子节点上缓存要乘 zoom+加 pan 转换
- transformOrigin = "top-left" — scale 默认从中心缩放会导致锚点公式完全错误
- 用 SetStyle({left, top, scale}) — 不要直接写 props.translateX/Y
- 线性步进 — 乘法缩放手感不均匀
- 锚点回退视口中心 — +/− 按钮不传锚点时,用 viewRect.w/h × 0.5 作为中心锚点

