无限画布技术总览
### 无限画布 = 两个坐标系 + 一台相机 无限画布的本质不是"一张无限大的画布",而是**两个坐标系之间的一套映射**: - **世界坐标(world)**:对象的"真实"位置,范围无限,不随视野变化。所有数据(卡片、分镜框)都以世界坐标存储。 - **屏幕坐标(screen)**:画布像素,左上角 (0,0),大小有限。 **相机(Camera)就是连接两者的全部状态,只有三个值**:`scale`(缩放)、`offsetX`、`offsetY`(屏幕左上角对准的世界点)。 ```js // 世界 → 屏幕 Sx = (Wx - offsetX) * scale // 屏幕 → 世界(逆变换,交互命中用) Wx = Sx / scale + offsetX ``` 绘制时把这个映射交给 Canvas 变换矩阵,之后**全部代码只用世界坐标**,不用关心视野: ```js ctx.setTransform(scale, 0, 0, scale, -offsetX * scale, -offsetY * scale); ctx.fillRect(card.x, card.y, card.w, card.h); // 世界坐标直接画 ``` **zoom-to-cursor(以鼠标为中心缩放)**是唯一需要推导的公式。要求:缩放前后,鼠标下方的世界点钉死在原像素位置: ```js const w = screenToWorld(mouse); // 1. 先记下鼠标处的世界点 scale = newScale; // 2. 改缩放 offset = w - mouse / newScale; // 3. 反推新 offset,使 (w-offset)*newScale = mouse ``` 平移更简单:鼠标拖动 dx 像素,相机反向移动 `dx / scale` 个世界单位。有了这两件法宝,加上 requestAnimationFrame 渲染循环,无限画布就成立了。
### 渲染层三选一(或混合) | 方案 | 元素上限 | 特点 | 代表产品 | |------|---------|------|---------| | SVG DOM | ~几千 | 每个图元是 DOM 节点,事件/样式方便;多了就卡 | 简单原型 | | Canvas 2D | ~几万 | 立即模式重绘,无 DOM 开销;命中测试要自己算 | Excalidraw、tldraw | | WebGL / WebGPU | 十万级+ | GPU 并行,适合海量图元与图像合成;开发成本高 | Figma(wasm)、PixiJS | **选型决策**: - 编辑器/白板类(分镜板、素材排布)→ **Canvas 2D 起步**,配场景图库 Konva;性能不够再换 PixiJS(WebGL)。 - 数据可视化类(关系图、流程图)→ d3-zoom + Canvas 就够(见下一 Tab)。 - 动漫级合成(序列帧、滤镜特效)→ PixiJS / WebGL;3D 场景布置 → three.js。 **分层混合**是工业界常态:背景网格走 Canvas/WebGL,文本编辑、工具栏等复杂交互控件走 HTML DOM 浮层(tldraw/Figma 都这么做)。
### d3.js:只解得开 10% 的题 **d3-zoom 能提供什么**:平移/缩放手势(滚轮、捏合、拖拽)+ 变换对象 `transform.k / x / y` + 事件节流。也就是无限画布的"相机零件"。 ```js const zoom = d3.zoom().scaleExtent([0.1, 8]).on("zoom", (e) => { ctx.setTransform(e.transform.k, 0, 0, e.transform.k, e.transform.x, e.transform.y); render(); }); d3.select(canvas).call(zoom); ``` **它没有的一切**(做动漫画布时全要自己补):对象模型、图层系统、选择/拖拽/变形工具、undo/redo、空间索引、LOD、瓦片、图像合成、时间轴。 **结论**: - 目标是**数据可视化画布**(图谱浏览、思维导图)→ d3-zoom + Canvas 渲染是合理轻量方案。 - 目标是**产品级无限画布**(AI 短剧分镜这种)→ 不要从 d3 起步。用 Konva(场景图)或 PixiJS(性能),或参考 tldraw 架构自研。 - 我们 01 模块手写相机(200 行不到)就是为了证明:相机这层不难,d3-zoom 的价值远低于它的名声。
### 让一万个对象也流畅的四板斧 1. **视口剔除(viewport culling)**:绘制前算可见世界矩形,界外对象直接跳过。01 模块实测:5030 张卡片,每帧实绘仅 40 张,稳定 60+ FPS。 ```js const vis = camera.visibleWorldRect(W, H); for (const c of cards) { if (c.x > vis.x1 || c.x + c.w < vis.x0 || c.y > vis.y1 || c.y + c.h < vis.y0) continue; // 剔除 drawCard(c); } ``` 2. **按需渲染**:dirty 标记驱动 rAF,没有交互/动画就不重绘,CPU 占用归零。 3. **LOD(细节层次)**:缩得太小时降级渲染——01 模块里 scale < 0.35 就不画卡片文字;大图可换缩略图。 4. **空间索引**:对象上千后,命中测试和剔除从线性扫描升级为四叉树 / R-tree。 再往上(十万级)才是 WebGL 的世界:离屏缓存静态层、超大背景图切瓦片(Deep Zoom 思路)、Worker 里算布局。
01 纯 Canvas 无限画布 PoC(xypoc/01-canvas-poc)
### 定位:零依赖手写,吃透原理 不引入任何库,用约 300 行原生 JS 实现一个功能完整的无限画布,把"相机、网格、剔除、命中"这些被框架藏起来的概念全部手写一遍——这是看懂 Konva/tldraw 源码的前提。 **已实现能力**: - 滚轮以鼠标为中心缩放(8% ~ 800%),拖空白平移 - 自适应网格:线距随缩放按 2 的幂升降级,主线每 4 格加粗,世界原点红十字锚点 - 卡片对象:拖动弹匣(后画的在上层,倒序命中)、选中高亮、双击空白新建 - HUD 实时显示:缩放比、相机 offset、鼠标世界坐标、FPS、剔除/实绘数量 - 压力测试:一键生成 5000 张散布在 ±10000 世界单位的卡片,验证视口剔除 **架构骨架**(所有无限画布通用): ```text 场景数据 cards[](世界坐标)──► 相机 Camera ──► 渲染循环(dirty 才画)──► Canvas 交互事件 ──► 改相机或数据 ──► 置脏 dirty=true ──► 下一帧重绘 ```
### 文件结构(01-canvas-poc/) ```text 01-canvas-poc/ ├── index.html # 画布元素 + HUD + 工具栏 + 三个 script 引入 ├── camera.js # Camera 类:scale/offset、worldToScreen/screenToWorld、 │ # zoomAt(zoom-to-cursor)、panBy、applyTo(复合 DPR)、 │ # visibleWorldRect(剔除边界) ├── grid.js # drawGrid:自适应步长 step = 2^round(log2(40/scale)), │ # 起点按 step 取模对齐(无限画布没有"边缘"); │ # 线宽 1/scale 补偿,恒为 1 屏幕像素 └── main.js # 场景数据 + 按需渲染循环 + 视口剔除 + 指针交互 + HUD + 压力测试 ``` 职责切分原则:**相机不懂渲染,渲染不懂交互**。camera.js 只做坐标数学,grid.js 只做背景,main.js 编排一切。以后换 WebGL 渲染器,camera.js 一行都不用改。
### 四个真实踩过的坑(2026-09-04 实测) **坑 1:let 的 TDZ 死区让整个脚本静默死亡**。main.js 顶部初始化循环调用 `addCard()`,而 `addCard` 内部写 `dirty = true`;`dirty` 用 `let` 声明在文件后段——声明前的临时死区(TDZ)里赋值直接抛 `ReferenceError`,后续代码(包括启动渲染循环)全部不执行,页面纯黑且无报错提示。修复:渲染状态变量(dirty/fps/culled)**必须先于使用它们的初始化代码声明**。 **坑 2:DPR 与相机变换互相覆盖**。高分屏适配要 `setTransform(DPR,...)`,相机又要 `setTransform(scale,...)`,后者直接覆盖前者 → 画布模糊。修复:`applyTo(ctx, dpr)` 把两者复合成一个矩阵 `s = scale * dpr`。 **坑 3:滚轮 deltaY 跨设备不一致**。鼠标滚轮一格 deltaY=100(deltaMode=0),触控板是连续小值,Firefox 按行滚动(deltaMode=1)。用指数归一:`factor = Math.exp(-deltaY * (deltaMode === 1 ? 0.05 : 0.0015))`,天然带平滑。 **坑 4:网格线宽和文字随缩放失真**。`ctx.lineWidth` 是世界单位,缩到 0.1 倍时 1px 线变成 0.1px 消失。所有线宽写 `1 / camera.scale` 做补偿;文字则在 scale < 0.35 时直接不画(LOD 降级)。
### 运行方式 ```bash cd D:\wks\xypoc python serve.py # 或双击 start.bat,端口 18090 # 浏览器打开 http://localhost:18090/01-canvas-poc/ ``` **2026-09-04 浏览器实测记录**(ZCode IAB,1280×720): | 测试项 | 结果 | |--------|------| | 初始渲染 | 30 张卡片 + 自适应网格 + 原点十字,HUD 显示 FPS 64 | | 压力测试 | 点"生成 5000 压力卡"→ 总数 5030,**剔除 4990 / 实绘 40**,帧率不掉 | | 清理 | "清掉压力卡"→ 回到 30 张 | | 选中/拖拽 | 点选高亮、拖卡移动、拖空白平移均正常 | > 测试环境备注:IAB 自动化的 `cua.scroll` 滚轮事件触发桥接层 bug(response id mismatch),滚轮缩放未能自动化回放;zoom 数学与已验证的平移/剔除共用同一套坐标变换,且 HUD 坐标读数正确。
02 AI短剧分镜板 Demo(xypoc/02-konva-storyboard)
### 定位:贴 AI 短剧工作流的分镜编排板 用 Konva 9(已 vendored 到 `xypoc/vendor/konva.min.js`,离线可用)实现分镜板:镜头卡片在无限画布上自由排布,每张卡就是一个"镜头"的全部信息。 **镜头卡数据模型**: | 字段 | 含义 | 备注 | |------|------|------| | no | 镜号 | 删除后自动重排 | | size | 景别 | 大远景/远景/全景/中景/近景/特写/大特写 | | move | 运镜 | 固定/推/拉/摇/移/跟/环绕/升降 | | dur | 时长(秒) | 底部汇总总时长 | | desc | 画面描述 | AI 生图的核心依据 | | dlg | 台词/旁白 | | | status | 状态 | 待生成/已生成/已采用/废弃,对应左侧色条 | | prompt | AI 提示词 | 由 景别+运镜+画面描述+风格后缀 组合生成,一键复制 | **工作流闭环**:编排分镜 → 逐镜生成 AI 提示词(已复制到剪贴板)→ 喂给生图/生视频模型 → 回填状态。数据 localStorage 持久化,支持 JSON 导出/导入(可交给后端做版本管理)。
### 文件结构与界面布局 ```text 02-konva-storyboard/ ├── index.html # 三栏布局:工具栏(左上) + Konva 画布(左) + 属性表单(右侧 320px) └── storyboard.js # 数据层 shots[] ⇄ 视图层 Konva 节点,双向同步 ``` **界面分区**: - **左上工具栏**:新增镜头 / 删除选中 / 生成AI绘图提示词 / 导出JSON / 导入JSON / 视图复位 - **画布区**:无限平移缩放;卡片左上为镜号+景别+运镜+时长+状态,中部画面描述与台词,底部提示词预览 - **右侧属性面板**:点选卡片后编辑全部字段,输入即回写数据层并重绘卡片 - **左下元信息**:镜头总数 · 总时长 · 当前缩放比 **演示数据**:内置"雨夜天台"三镜短剧开场(大远景推镜 → 特写手机消息 → 中景跟镜冲入雨里),覆盖三种状态色条。
### Konva 实现的三个关键难点 **难点 1:Stage 即相机——平移缩放不用再写相机类**。Konva 的 Stage 自带 `draggable` 与 `scale/position`,与 01 手写的 Camera 是同一套数学: ```js // Konva 版 zoom-to-cursor(对照 01 的 Camera.zoomAt) const worldPoint = { x: (pointer.x - stage.x()) / oldScale, ... }; stage.scale({ x: newScale, y: newScale }); stage.position({ x: pointer.x - worldPoint.x * newScale, ... }); ``` Stage 的 `position` 就是 `-offset * scale`。**点空白拖 Stage = 平移画布;点卡片拖 Group = 移动卡片**——Konva 的命中会自动找最深的 draggable 节点,两种拖拽零冲突,这在手写时代要自己区分。 **难点 2:数据与视图分离**。shots[] 数组是唯一事实源,Konva 节点只是投影:编辑表单 → 改 shots → `refreshCard()` 重绘;拖卡片 → `dragend` 只回写 x/y。绝不反向从节点读业务数据。 **难点 3:事件冒泡要显式拦截**。卡片 Group 的 click 必须 `e.cancelBubble = true`,否则冒泡到 Stage 触发"点空白取消选中",选中态刚设上就没了。点状态色条循环切换状态也是靠 `e.target.name()` 区分子目标。
### 2026-09-04 浏览器实测记录 | 测试项 | 结果 | |--------|------| | 初始渲染 | 三张镜头卡正常,状态色条正确(已采用=绿、已生成=蓝、待生成=棕) | | 点选卡片 | 右侧面板从空态切换为表单,字段正确回填 | | 生成提示词 | 输出"特写,固定,手机屏幕亮起…电影感,雨夜霓虹,赛博朋克色调,8k,杰作"并写剪贴板 | | 持久化 | localStorage 键 `xypoc_storyboard_v1`,刷新不丢 | **访问**:`http://localhost:18090/02-konva-storyboard/` **后续接入方向**:提示词目前写死风格后缀,可接 xyv1 的 llm_client 做智能扩写;"生成画面"按钮可对生图 API,回写缩略图贴进卡片——分镜板就成了 AI 短剧的中枢。
03/04 白板嵌入方案(tldraw / Excalidraw)
### importmap + esm.sh:不装 npm 也能嵌 React 白板 tldraw / Excalidraw 都是 React 组件库,官方都只讲 npm + 打包器。但浏览器原生支持 ESM 与 importmap,配合 esm.sh(把 npm 包实时转成 ESM)可以**零构建直接嵌入**: ```html <script type="importmap"> { "imports": { "react": "https://esm.sh/react@18.3.1", "react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime", "react-dom": "https://esm.sh/react-dom@18.3.1", "react-dom/client": "https://esm.sh/react-dom@18.3.1/client", "tldraw": "https://esm.sh/tldraw@3?external=react,react-dom" } } </script> <script type="module"> import React from "react"; import { createRoot } from "react-dom/client"; import { Tldraw } from "tldraw"; createRoot(document.getElementById("app")) .render(React.createElement(Tldraw, { persistenceKey: "demo" })); </script> ``` 三个要点:① `?external=react,react-dom` 让 tldraw 复用 importmap 里的 React,避免两份 React 共存崩溃;② 浏览器里没有编译器,**不能用 JSX**,一律 `React.createElement`;③ 首次加载要拉几百个 ESM 子模块(需联网),生产环境应改 npm 构建。
### tldraw 嵌入(xypoc/03-tldraw-embed) **结构**:单文件 `index.html` = 顶部宿主工具栏 + `#app` 挂载点 + importmap + module 脚本。 **已验证能力**(2026-09-04 实测): - 白板完整加载:选择/画笔/箭头/文本/便笺/矩形工具、颜色/填充/大小样式面板、缩放与小地图 - `persistenceKey` 自动持久化到 IndexedDB,刷新内容不丢 - **编程控制**(嵌入的核心价值):通过 `onMount` 拿到 editor 实例,宿主按钮"插入分镜框"调 `editor.createShapes` 在视口中心生成矩形+文字 - 暗色切换:`editor.user.setUserPreferences({ colorScheme: "dark" })` ```js onMount: (editor) => { editorRef = editor; // 之后宿主代码可随时 editor.createShapes([...]) 往画布塞内容 } ``` **对比结论**:tldraw 功能全、API 现代(v3)、TypeScript 友好,适合"站在成熟白板上做二次开发"的路线。
### Excalidraw 嵌入(xypoc/04-excalidraw-embed) **结构**:同样单文件,importmap 指向 `@excalidraw/excalidraw@0.18.0?external=react,react-dom`,样式随包注入无需引 CSS。 **已验证能力**(2026-09-04 实测): - 白板完整加载,手绘风格渲染正常(欢迎页与工具栏齐全) - **编程控制**:`excalidrawAPI` 回调拿到 API 实例,宿主按钮"导出场景 JSON"调 `serializeAsJSON(apiRef.getSceneElements(), apiRef.getAppState(), apiRef.getFiles(), "local")` 触发浏览器下载——场景数据可回传后端存档 **与 tldraw 的取舍**: | 维度 | tldraw | Excalidraw | |------|--------|-----------| | 风格 | 规整工程风 | 手绘涂鸦风 | | API | editor 响应式 store | excalidrawAPI 命令式 | | 体积/生态 | 较大、插件体系新 | 较轻、VSCode 插件成熟 | | 适合 | 产品化二开 | 快速嵌入、草图场景 |
### 三个真实踩坑(2026-09-04 实测)+ 生产建议 **坑 1:importmap 必须补子路径**。tldraw 经 esm.sh 外置 React 后内部 `import "react/jsx-runtime"`,importmap 只写了 `"react"` → 报 `Failed to resolve module specifier "react/jsx-runtime"`。子路径要逐条登记(`react/jsx-runtime`、`react-dom/client`)。 **坑 2:tldraw v3 文本形状不再有 props.text**。旧教程写 `props: { text: "..." }`,v3 抛 `ValidationError: At shape(type = text).props.text: Unexpected property` 并使整个白板崩进错误边界。正确姿势:`import { toRichText } from "tldraw"`,`props: { richText: toRichText("...") }`。**跟 v2 教程抄代码必踩**。 **坑 3:宿主工具栏被白板的 fixed 全屏层盖住**。tldraw/Excalidraw 内部有覆盖整个视口的层,宿主 UI(顶栏)不设 `position: relative; z-index: 10000` 时"看得见点不中"——自动化点击在动作性检查阶段超时。宿主 chrome 必须显式声明高 z-index。 **生产建议**:CDN 方案适合评估与内部工具;正式产品用 npm + Vite 构建(锁版本、离线可用、首屏快),React 18/19 均可。两者都支持自托管素材字体以脱离外网。
xy_canvas 智能体(xypoc 项目的知识管家)
### 创建与注册(两步) **第一步:建智能体实体**。`.config/agent/xy_canvas/AIDEV.md`,按 xyvn 体系固定结构:frontmatter(name + 触发场景 description)→ 何时使用 → 模块地图 → 知识点 → 踩坑记录 → 通用方法 → 测试入口 → 在 xyvn 体系中的定位。 **第二步:登记注册表**。`.config/skills/xyvn/SKILL.md` 三处联动: 1. frontmatter description 追加触发关键词组("无限画布/infinite canvas/相机变换/Konva/分镜板/tldraw/Excalidraw/xypoc") 2. §二注册表追加一行:意图关键词 → `xy_canvas` → 入口说明 3. 智能体计数 15 → 16;`README.md` 架构树同步补一行 之后凡涉及无限画布/分镜板/白板嵌入的需求,xyvn 总控自动路由到 xy_canvas。
### 知识沉淀了什么 **知识点**(AIDEV.md §三): - 相机数学:坐标映射、zoom-to-cursor 推导、DPR 复合 - 渲染选型决策表:SVG / Canvas2D / WebGL 上限与适用场景,d3.js 边界结论 - 性能三板斧:视口剔除、按需渲染、空间索引升级时机 - Konva 分层拖拽模型:Stage 即相机、命中最深的 draggable 节点 - React 白板免构建嵌入:importmap + esm.sh + external + 无 JSX **踩坑记录**(只增不改):TDZ 死区、DPR 覆盖、deltaY 归一、线宽补偿、importmap 子路径、tldraw v3 richText、宿主 UI z-index——每条含"问题/根因/修复/复发检查点",下次遇到直接命中。 **通用方法**:新增画布模块 checklist(免构建、自包含、门户登记、浏览器实测、文档同步五步)。
### 与 AI 短剧工作流的接驳路线 ```text 现状(已验证) 下一步 终态 ───────────────────────────────────────────────────────────────── 01 手写相机原理 ──► 相机层替换为 PixiJS(WebGL) ──► 海量序列帧/图层的动漫工作台 02 Konva 分镜板 ──► 接 xyv1 llm_client 扩写提示词 ──► 分镜→生图→生视频全自动流水线 03/04 白板嵌入评估 ──► 选定二开底座(tldraw 优先) ──► 嵌入 xyv1 成为新模块(走 xy_core 骨架) ``` 当画布能力要并入 xyv1 主工程时,按 xy_core 的"新增模块骨架"流程落地,xy_canvas 里的知识点与踩坑记录直接随行。这就是 xyvn 体系的用法:**小项目里验证,agent 里沉淀,成熟后并入主工程**。
相机原理概述
### offsetX / offsetY 究竟是什么 **精确答案:它是"屏幕左上角 (0,0) 对准的那个世界点"的世界坐标。** 把相机想象成架在世界上方的一台摄像机,offset 就是取景框左上角对准的世界位置——说它是"相机的位置"也没错。 由 `screenToWorld` 的定义直接验证这条锚定关系: ```js screenToWorld(0, 0) = { x: 0/scale + offsetX, y: 0/scale + offsetY } = (offsetX, offsetY) // 屏幕(0,0) ↔ 世界(offsetX, offsetY) ``` 两个坐标系的关系图(scale = 1 时最直观): ```text 世界坐标系(无限) 屏幕(有限,如 1280×720) ┌───────────────────────────┐ │ ┌─ ── ── ── ─┐ │ ┌───────────────┐ │ │ 取景框 │ │ ──► │ │ │ │ (可见区) │ │ │ 画布像素 │ │ └─ ── ── ── ─┘ │ └───────────────┘ │ ▲ 取景框左上角 = offset │ 屏幕(0,0) 显示的就是 offset 点 └───────────────────────────┘ ``` offset 变化 → 取景框在世界中平移 → 屏幕上的内容就"动"了。**数据永远不动,动的只有相机**——这是无限画布最重要的世界观:卡片、分镜框的世界坐标从创建到删除都不变,平移缩放改的只是相机的三个值。
### 为什么相机只要 x,y 两个值,就能锚定 (0,0) 和任意 (x,y) 换一种问法:为什么知道"屏幕 (0,0) 对应世界哪个点"之后,屏幕上任意点 (x,y) 的归属就被唯一确定了? **因为这个映射是"平移 + 等比缩放"——没有旋转、没有扭曲。** 把公式拆开看结构: ```text S = (W − offset) · scale = scale · W + (−scale · offset) └── 缩放 ┘ └──── 平移量 t ────┘ ``` - **平移恰好 2 个自由度**:2D 平面上的平移只需要 (tx, ty) 两个数就能完全描述; - **一个点对应 = 两个方程**:offset 的定义本身就是"屏幕原点 (0,0) 的世界落点",x 方向一个方程、y 方向一个方程,恰好解出 offsetX、offsetY 两个未知数; - **全平面刚性共享同一对 offset**:所有点一起整体移动,所以锚住 (0,0) 这一个点,就等于锚住了所有点。 (0,0) 锚定之后,任意屏幕点 (x,y) 的世界坐标被公式锁定: ```text Wx = x / scale + offsetX ① 先除以 scale:把像素"除回"世界尺寸 Wy = y / scale + offsetY ② 再加上 offset:取景框在世界中的位置 ``` **scale 是第三个自由度**:offsetX/offsetY 管"看哪里"(平移),scale 管"看多细"(缩放)。平移 2 个 + 缩放 1 个 = 3 个自由度封顶;只有允许旋转才需要第 4 个值 angle——绝大多数画布不需要旋转。
### 三值相机速查 | 相机的值 | 几何意义 | 对应操作 | 改变它的方法 | |---------|---------|---------|-------------| | offsetX | 屏幕左边线对准的世界 x | 左右拖拽平移 | panBy(dx, 0) | | offsetY | 屏幕顶边线对准的世界 y | 上下拖拽平移 | panBy(0, dy) | | scale | 1 个世界单位 = 多少屏幕像素 | 滚轮缩放 | zoomAt(sx, sy, f) | 两个锚点的对应关系: | 屏幕点 | 世界点 | 依据 | |--------|--------|------| | (0, 0) 左上角 | (offsetX, offsetY) | offset 的定义本身 | | (x, y) 任意点 | (x/scale + offsetX, y/scale + offsetY) | 由定义线性推出,无需新增信息 | 口诀:**数据不动,相机动;offset 定位置,scale 定远近。**
相机代码解析(camera.js 第 42-52 行)
### panBy(42-46 行):为什么鼠标拖动 = 相机反向移动 ```js /** 平移:鼠标拖动了 dx,dy 屏幕像素,等价于相机反方向移动 */ panBy(dx, dy) { this.offsetX -= dx / this.scale; // 注意是减号 this.offsetY -= dy / this.scale; } ``` **推导**:用户把内容往右拖 dx 像素,期望效果是"同一个世界点 W,原来显示在 S,现在显示在 S+dx"。代入映射公式: ```text 原来:S = (W − offset) · scale 现在:S + dx = (W − offset′) · scale 两式相减:dx = (offset − offset′) · scale 解出:offset′ = offset − dx / scale ← 正是代码里的减号 ``` **直觉类比**:地图 App 里把地图往右拖,看到的景象往左走——内容(世界)没动,动的是相机;**内容往右 = 相机往左**,所以 offset 要减。 **为什么除以 scale**:dx 是屏幕像素,offset 是世界单位,两者的汇率就是 scale。缩放 200% 时拖 1 像素只相当于 0.5 个世界单位——除以 scale 把"像素"换成"世界单位"。(`visibleWorldRect` 里的 `x / scale` 是同一汇率的反向使用。)
### applyTo(48-52 行):setTransform 干了什么 ```js applyTo(ctx, dpr = 1) { const s = this.scale * dpr; ctx.setTransform(s, 0, 0, s, -this.offsetX * s, -this.offsetY * s); } ``` `ctx.setTransform(a, b, c, d, e, f)` 把画布的当前变换矩阵**整体替换**为: ```text │ a c e │ 之后绘制的任意坐标 (x, y),实际落点是: │ b d f │ x′ = a·x + c·y + e │ 0 0 1 │ y′ = b·x + d·y + f ``` 代入我们的六个参数 a=s, b=0, c=0, d=s, e=−offsetX·s, f=−offsetY·s: ```text x′ = s·x − offsetX·s = s·(x − offsetX) y′ = s·y − offsetY·s = s·(y − offsetY) ``` **这正是相机公式 S = (W − offset) · scale**。也就是说:setTransform 调用一次之后,Canvas 渲染管线会自动对之后每一个绘图坐标执行相机映射——业务代码从此只用世界坐标(如 `fillRect(card.x, card.y, ...)`),变换交给底层。 **s = scale · dpr 的含义**:dpr = devicePixelRatio(Retina 屏通常为 2),Canvas 物理分辨率是 CSS 像素的 dpr 倍。把 dpr 乘进同一个矩阵,一次变换同时完成"世界→屏幕"与"CSS 像素→物理像素"两级映射,高分屏不糊。这正是踩坑记录第 2 条:先 setTransform(DPR) 再 setTransform(scale) 会被**整体替换**覆盖,必须复合成一个矩阵。
### setTransform 六参数速查表 | 参数 | 含义 | 本项目取值 | 作用 | |------|------|-----------|------| | a | x 方向缩放 | s = scale·dpr | 横向缩放(含高分屏补偿) | | b | y 轴错切(旋转分量) | 0 | 不旋转、不错切 | | c | x 轴错切(旋转分量) | 0 | 不旋转、不错切 | | d | y 方向缩放 | s = scale·dpr | 纵向缩放(与横向同值 = 等比) | | e | x 方向平移 | −offsetX·s | 把取景框的世界 x 平移出去 | | f | y 方向平移 | −offsetY·s | 把取景框的世界 y 平移出去 | **三个易混点**: 1. **替换而非叠加**:setTransform 直接换掉当前矩阵;想叠加要用 `ctx.transform(...)`(右乘当前矩阵)。01 模块渲染开头先清屏再 setTransform,正是利用"替换"语义保证每帧从干净矩阵开始。 2. **b、c 不是角度**:它们是矩阵元素。旋转 θ 时 a=cosθ·k、b=sinθ·k、c=−sinθ·k、d=cosθ·k;本项目 b=c=0 即无旋转——无限画布 99% 的场景不需要旋转。 3. **e、f 的负号不是约定、是公式**:要让"世界点 offset 显示在屏幕 0 点",整个世界必须向负方向平移 offset——负号直接来自 S = (W − offset) · scale 本身。
参考
    tldraw 官方文档
    Excalidraw 官方文档
    Konva 官方文档
    PixiJS 官网
    d3-zoom 文档
    esm.sh —— npm 包的 ESM CDN