news 2026/10/21 10:22:21

自研Web图表编辑器diagram-design:架构设计与交互实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
自研Web图表编辑器diagram-design:架构设计与交互实现

做 diagram-design 这个项目,起因其实很朴素。我负责的某内部系统需要一整套可交互的技术图表绘制能力,当时市面上开源的在线绘图方案各有各的局限:要么太重量级,要么交互深度不够,要么在移动端拖拽体验很生涩。团队权衡之后,决定自研一个以通用图编辑为核心的组件,名字就叫 diagram-design。它不是某个业务系统,而是一个面向技术场景(架构图、流程图、时序图、ER图)的 Web 图表编辑器内核,核心功能包括节点拖拽、连线绘制、画布缩放平移、框选多选、撤销重做、JSON 序列化与图片导出。

这篇文章不讲虚的,直接把这套东西从数据模型到渲染层、再到交互层和工程化细节,按我当初实现的思路拆开讲。如果你正在做类似方向的前端项目,或者打算自研一套绘图工具,这篇应该能帮你节省大量试错时间。

1. 项目定位与整体拆分:为什么自研一款图表编辑器

1.1 先想清楚边界:不是画板,是图编辑器

在动手写第一行代码之前,最应该做的不是选技术栈,而是把"图编辑器"和"画板"这两件事分清。画板的核心是自由绘制,用户拿画笔去涂抹;图编辑器的核心是结构化编辑,用户操作的是"节点"和"连线"这两个原子对象。

diagram-design 的定位非常明确:处理结构化图数据。这意味着所有交互都围绕节点和边展开,而不是像素级绘制。用户拖过来一个矩形,它就是节点,双击它编辑文本,它还是节点。用户从一个节点底部拉出一条线到另一个节点的顶部,它在内部被记录为一条 edge,而不是一串路径数据。

这个定位直接决定了后续的架构设计:

  • 数据必须可持久化、可 diff、可树状渲染;
  • 每个图元都必须有唯一标识,这样才能支撑复杂的交互;
  • 编辑器只关心"图结构",不关心图元内部长什么样——图元是外部注册进来的。

想清楚这一点之后,我再去看市面上的开源方案,感觉思路完全不一样了。它们大多偏重某一类图(比如流程图或思维导图),通用性和可扩展性都不够好。自研的核心价值在于,我们可以完全控制数据结构和交互模型,并且在业务系统里做到深度定制。

1.2 整体架构拆成四层

diagram-design 在代码层面拆成了四个清晰的分层:

  1. 数据层:负责定义 Node、Edge、Viewport、History 的数据结构,以及序列化、反序列化逻辑;
  2. 渲染层:负责把数据模型映射到 SVG 元素,包括节点外观、连线路径、锚点、选区等;
  3. 交互层:负责接收鼠标和触摸事件,将其翻译为对数据层的操作,例如拖拽节点、拉一条线、平移画布;
  4. 功能层:负责撤销重做、复制粘贴、对齐吸附、导入导出等更上层的功能。

这个分层的价值在项目后期体现得特别明显。比如我后来要给连线增加贝塞尔曲线控制点,只需要改渲染层和数据层的 edge 结构,交互层基本不受影响。再比如增加"网格对齐"功能时,交互层加一个吸附判断就完事,数据层和渲染层完全不用动。

如果你准备做同类项目,我建议你同样在前期就把边界划清楚,不要贪快把逻辑全部堆在一起。分层带来的重构成本,远低于不分层带来的维护成本。

2. 数据模型先行:图结构如何映射为可持久化的 JSON

2.1 节点与连线的数据结构设计

整个项目的核心无非两样东西:节点和连线。我在设计数据结构时踩过一些坑,最开始把节点写成宽泛的 Record<string, any>,后期发现很多通用逻辑没法写,每条节点却要重复判断字段。后来收敛成了这样:

// 节点数据模型 interface DiagramNode { id: string; // 全局唯一 type: string; // 图元类型:"rect" | "diamond" | "ellipse" | "text" | "custom-*" x: number; // 画布坐标,左上角 y: number; width: number; height: number; style: NodeStyle; // 颜色、圆角、边框、阴影、字体等 data: Record<string, unknown>; // 业务字段,例如文本内容、图标链接 zIndex: number; // 层级 } // 连线数据模型 interface DiagramEdge { id: string; source: string; // 源节点 id target: string; // 目标节点 id sourceHandle: string; // 源节点上连线的锚点方向:"bottom"、"right" 等 targetHandle: string; // 目标节点上连线的锚点方向:"top"、"left" 等 type: 'bezier' | 'straight' | 'step'; // 连线样式 waypoints: Array<{ x: number; y: number }>; // 手动添加的中间拐点 style: EdgeStyle; // 颜色、线宽、箭头、虚线等 }

有人可能会问,为什么连线的 source 和 target 要保存"节点 id 加锚点方向"而不是直接保存一端端点的坐标?

因为一旦目标节点被拖动,连线必须跟着动。如果保存的是端点坐标,那节点移动后,连线不会自动更新,需要额外做一轮坐标同步;而保存锚点方向,渲染层每次都可以从节点的新位置重新计算端点,天然保持一致。这是图编辑器最基础的"关联"思想。

对于 waypoints 拐点,我一开始也是存全局坐标,后来发现节点移动时拐点不动,会显得很奇怪。最后的处理方式有两种:要么把拐点和两端节点做相对位置关联,要么干脆在移动节点时对拐点做同步平移。前者更完善但复杂度高,我为了先跑通主流程选了后者,实测够用。

2.2 序列化与版本兼容

图数据要保存到后端,就涉及序列化的问题。diagram-design 导出的是一个完整的 JSON 结构,大致长这样:

{ "version": 1, "viewport": { "x": -120, "y": -80, "scale": 1 }, "nodes": [ /* DiagramNode 数组 */ ], "edges": [ /* DiagramEdge 数组 */ ] }

我强烈建议把 version 字段放进去。

为什么?因为数据模型一定会演化。比如后来我要增加"分组"功能,给节点加一个 groupId 字段,如果没有版本号,旧数据直接读取时缺字段,渲染逻辑要做一堆兼容判断。有了 version,可以在导入时做数据迁移,一句"旧格式升级到新格式",非常省心。

我实际做的迁移机制也很简单,就是一个按版本递增的迁移函数数组:

const migrations = [ (data: any) => { /* v1 -> v2:给所有 node 补充默认 style */ }, (data: any) => { /* v2 -> v3:给 edge 补充 waypoints 空数组 */ } ]; function migrate(rawData: any) { let data = rawData; for (let v = rawData.version; v < CURRENT_VERSION; v++) { data = migrations[v - 1](data); } return data; }

这里的核心原则是:永远不要直接改用户的旧数据,而是通过迁移函数生成新数据,保证每次打开文件都是幂等操作。

2.3 历史记录不是快照,是操作日志

回到撤销重做这一块。最朴素的做法是每次操作完都存一个全量快照,但图一大(几千个节点)之后,内存和性能都会爆炸。我实际采用的是命令模式:每个操作都实现为包含 execute 和 undo 两个方法的对象,比如添加节点、移动节点、删除连线各自有对应的命令。

移动节点这种高频操作,如果每个 mousemove 都生成一条命令,撤销列表会塞满无意义的中间态。这里的处理技巧是"合并连续操作":拖动过程中不记录历史,只在 mouseup 时记录一条"从位置 A 移动到位置 B"的命令,撤销时直接回到 A,而不是一步一步倒退。

interface Command { label: string; // 用于显示操作名 execute(): void; undo(): void; }

这个设计跑了一年多,稳定得让我很放心。它比快照方案的优势在于:撤销粒度完全可控、内存占用固定、可以单独对某个操作做定制(比如从特定节点创建连线时,自动附带对节点样式的修改)。

3. 渲染方案选型:SVG 还是 Canvas,我为什么做了组合

3.1 两条路线的优缺点对比

这是 diagram-design 技术选型中最关键的一个决定:用 SVG 渲染节点,还是用 Canvas 整体绘制?

我把两边的权衡列在下面,这也是我调研后真实感受到的差异。

维度SVGCanvas
节点数量轻量场景表现优秀,几百个没问题适合几千上万个节点,无 DOM 压力
交互命中检测原生 DOM 事件,天然定位到元素需要自己做坐标反查和图形算法
样式控制CSS 直接控制,动画容易每次重绘要手动操作样式属性
导出与嵌入直接拿到矢量图形,转 HTML 也很方便需要额外序列化到图片或 SVG
文本编辑可以在节点内直接放输入框文本块渲染和光标处理较麻烦
开发成本低,几乎不用写图形绘制代码高,需要自己实现很多底层逻辑

对于 diagram-design 这种以"编辑"为核心目标的工具,交互的精准度和开发效率是第一位的。我最终选择 SVG 作为主要渲染方案,理由很简单:节点和连线数量在大多数技术场景下是几百量级,这个区间内 SVG 性能完全能打,而它带来的"原生事件 + 天然矢量导出"优势在编辑器场景里太值钱了。

3.2 图元注册机制:渲染层与业务层解耦

SVG 方案有了,接下来要考虑的是:如何在不让渲染层变成大杂烩的前提下支持各种节点类型?

我实现了一个图元注册机制,本质就是一个从 type 字符串到渲染函数(或组件)的映射表:

// 图元注册表:把节点的 type 映射到具体的渲染实现 const shapeRenderers: Record<string, (node: DiagramNode) => string> = { rect: renderRect, diamond: renderDiamond, ellipse: renderEllipse, image: renderImage, text: renderText }; function registerShape(type: string, renderer: (node: DiagramNode) => string) { shapeRenderers[type] = renderer; }

业务系统里自定义一个"数据库表节点"或者"服务图标节点",只需要调用 registerShape 注册一个渲染函数即可,核心编辑器代码一行都不用改。这样渲染层只负责遍历 nodes 数组,根据 node.type 查表拿渲染结果,再挂上统一的拖拽、选中事件。

这种解耦让我后面加功能非常爽。有一次业务方要一个折线型的节点,我写了一个 renderPolyline 组件,注册进去就完事,数据模型和交互模型完全没动。

3.3 连线的贝塞尔曲线怎么画的

连线不是简单画一条直线。diagram-design 默认的连线类型是贝塞尔曲线,因为曲线在架构图里看起来最柔和,不刺眼。

实现思路是这样的:已知起点和终点坐标,以及两个节点上的锚点方向。根据方向,计算出两个控制点,然后构造三次贝塞尔曲线路径:

// 根据锚点方向生成控制点偏移 function getControlOffset(handle: string, len = 40) { if (handle === 'bottom') return { x: 0, y: len }; if (handle === 'top') return { x: 0, y: -len }; if (handle === 'right') return { x: len, y: 0 }; if (handle === 'left') return { x: -len, y: 0 }; return { x: 0, y: -len }; // 默认按顶部方向 } // 构造三次贝塞尔路径 function buildBezierPath( start: Point, end: Point, startHandle: string, endHandle: string ) { const c1 = { x: start.x + getControlOffset(startHandle).x, y: start.y + getControlOffset(startHandle).y }; const c2 = { x: end.x + getControlOffset(endHandle).x, y: end.y + getControlOffset(endHandle).y }; return `M ${start.x} ${start.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${end.x} ${end.y}`; }

这种方案的优势是控制点数量少且规则稳定。后续如果想加"自动避让节点"或"正交折线",只需要替换 buildBezierPath 的实现,渲染层不需要改动。我是在做跨多个泳道的连线时,才发现固定长度控制点在长连线下曲线弧度不够自然。后来把 control offset 改成了按两端距离的比例动态计算,曲线才真正做到不管多远都优雅。

4. 交互层实操:拖拽、缩放、连线这些高频操作怎么落地

4.1 坐标系变换:缩放后拖拽不出错的数学公式

交互层最大的坑是坐标系混乱。SVG 的坐标是画布坐标,而鼠标事件给出的坐标是屏幕坐标。画布缩放之后,这两者之间就出现了差异,如果直接拿屏幕坐标设置节点位置,会出现"鼠标抓不住节点"的漂移现象。

我封装了一个视口(Viewport)对象,统一处理坐标换算:

interface Viewport { offsetX: number; // 画布在屏幕上的偏移 offsetY: number; scale: number; // 缩放比例 } // 屏幕坐标 -> 画布坐标 function clientToCanvas(clientX: number, clientY: number, vp: Viewport) { return { x: (clientX - vp.offsetX) / vp.scale, y: (clientY - vp.offsetY) / vp.scale }; } // 画布坐标 -> 屏幕坐标 function canvasToClient(canvasX: number, canvasY: number, vp: Viewport) { return { x: canvasX * vp.scale + vp.offsetX, y: canvasY * vp.scale + vp.offsetY }; }

这两个函数是整个交互层的基石。节点拖拽时,每一次 mousemove 都执行 clientToCanvas,拿到画布坐标后再写回 node 的 x 和 y。这样就保证了"鼠标在节点上的落点,移动过程中始终不离开"。

缩放实现用了滚轮监听,以鼠标位置为缩放中心。这一步也有个细节:缩放时,要保证鼠标指向的那个画布坐标点在缩放前后都处于同一屏幕位置。公式是:

// 以鼠标位置 (mouseX, mouseY) 为中心缩放 function zoomAt(mouseX: number, mouseY: number, nextScale: number, vp: Viewport) { const before = clientToCanvas(mouseX, mouseY, vp); vp.scale = nextScale; vp.offsetX = mouseX - before.x * vp.scale; vp.offsetY = mouseY - before.y * vp.scale; }

我最初省略了这一步,直接缩放 offset 和 scale,结果每次放大后鼠标附近的节点都会小幅度跑位,看着不严重但很影响手感。补上以鼠标为中心的计算后,体验立刻正常了。

4.2 连线交互:从锚点拖出,到目标节点校验

连线交互是最容易写崩的一部分。diagram-design 的交互流程是:

  1. 鼠标按下某个节点上的锚点(比如底部圆点);
  2. 拖动时,屏幕上出现一条临时连线,终点跟随鼠标;
  3. 鼠标悬停在另一个节点的锚点附近时,该锚点高亮,提示可以连接;
  4. 松开鼠标,如果命中了合法锚点,生成一条新连线,否则取消。

在这个过程中,最核心的是"命中检测"。因为 SVG 天然支持事件,我可以在每个锚点上挂 mouseenter 和 mouseleave 事件来高亮,但连接合法性判断必须放在数据层。我定义了一套规则:

  • 不能连接自己到自己的同一锚点;
  • 同一个 source 和 target 之间,如果已存在同方向连线,默认不允许重复建立;
  • 某些业务场景下,不同类型的节点不允许互相连接(这个通过数据层的 validator 注册钩子实现)。
function validateConnection( sourceNode: DiagramNode, sourceHandle: string, targetNode: DiagramNode, targetHandle: string ): boolean { // 禁连自己 if (sourceNode.id === targetNode.id) return false; // 禁连已有的同向连线 // 注册的业务校验 // ... return true; }

我把这些校验单独抽成 validateConnection 函数,而不是散落在各个事件回调里,原因很简单:后续测试、复用、以及做成表单时校验都会用到同一个入口。

关于锚点鼠标事件,有一个细节值得说:锚点的尺寸在视觉上很小,鼠标不容易点中。我把锚点的 SVG 热区做成了比视觉尺寸大一圈的透明圆形,这样用户稍微歪一点也能命中,交互友好度提升非常明显。

4.3 对齐吸附:让图看起来"专业"的一招

架构图或流程图如果节点随意摆放,看上去会非常业余。diagram-design 做了两层辅助:

  • 网格吸附:节点拖拽时,按设定的网格大小(默认 10px)对齐坐标;
  • 智能参考线:当拖动节点的边缘或中心点接近另一个节点的边缘或中心时,自动显示一条红色参考线,并在误差范围内吸附。

智能参考线的实现不算复杂,核心是实时计算"当前被拖动的节点的关键坐标集合"和"画布上其他节点的关键坐标集合",求差值,当差值小于吸附阈值时进行对齐修正。

坐标集合包括每个节点的:左边 x、水平中心 x、右边 x、顶部 y、垂直中心 y、底部 y。被拖动节点的每一条参考坐标,都和其余节点的每一条参考坐标做比较,找到最小差值的那个,如果差值小于 5px 就吸附。

注意这里只取可见范围内的节点做碰撞计算,否则节点上千后,每次拖动都要做几千次比较,会比较卡。我实测加入这一层优化后,性能开销可以忽略不计。

4.4 框选与多选:橡皮筋模式的实现

框选(也叫橡皮筋选择)是用一个矩形选择区域来批量选中节点。实现上是监听画布空白区域的 mousedown,记录起点,然后 mousemove 时绘制一个半透明蓝色矩形,最后 mouseup 时检测哪些节点的边界与选择区域相交,把它们加入选中集合。

判断相交我用的是最直接的矩形交集检测:

function rectsIntersect(r1: Rect, r2: Rect) { return !( r1.x + r1.width < r2.x || r2.x + r2.width < r1.x || r1.y + r1.height < r2.y || r2.y + r2.height < r1.y ); }

框选矩形本身要用一个独立的 SVG 图层来渲染,这个图层只接收一次鼠标按下事件,绘制过程中不干扰节点。这里有一个很容易犯的错:在框选矩形上叠加了 pointer-events: none,否则框选矩形会拦截后续的鼠标事件,导致拖拽框选时无法继续和其他元素交互。

多选之后,移动节点时要保证所有被选中的节点都同步偏移。实现方式是对每个选中节点保存移动前的起点坐标,每次 mousemove 时计算相对起点的偏移量,再统一应用。

5. 工程化细节:状态管理、撤销重做与导出

5.1 状态管理:尽量保持数据层"纯净"

diagram-design 没有引入特别复杂的状态管理方案,因为图数据结构天然是统一根节点对象,适合集中管理。我用了类似"单 Store + 操作函数"的方式:所有图数据变更都通过 dispatch(action) 形式进入 Store,然后触发重新渲染。

最关键的设计约束是:数据层必须是纯数据操作,不直接操作 DOM,不持有任何渲染引用。这样能保证单测可以绕开 DOM 直接对图操作,比如"添加节点、移动节点、撤销、断言位置"这些测试跑得飞快。

class GraphStore { private nodes: DiagramNode[] = []; private edges: DiagramEdge[] = []; private viewport: Viewport = { offsetX: 0, offsetY: 0, scale: 1 }; addNode(node: DiagramNode): void; moveNode(id: string, deltaX: number, deltaY: number): void; removeNode(id: string): void; // ... }

我也考虑过用发布订阅模式给视图层提供更新通知,做法是 store.subscribe(callback),每次数据变更后统一通知外部重新渲染。这套方案简单可靠,后续做协同编辑时还可以无缝把它接入 WebSocket 同步。

5.2 复制粘贴与跨画布操作

复制粘贴在图表编辑器里属于高频操作。实现时注意一个容易忽略的点:复制时要同时复制节点和它们之间的连线,而且复制出来的节点都要重新生成 id,否则和原节点会冲突。

function cloneSelection(selectedIds: Set<string>, nodes: DiagramNode[], edges: DiagramEdge[]) { const idMap = new Map<string, string>(); const newNodeList: DiagramNode[] = []; const newEdgeList: DiagramEdge[] = []; for (const node of nodes) { if (!selectedIds.has(node.id)) continue; const newId = generateId(); idMap.set(node.id, newId); newNodeList.push({ ...node, id: newId, x: node.x + 20, y: node.y + 20 }); } for (const edge of edges) { if (!selectedIds.has(edge.source) || !selectedIds.has(edge.target)) continue; newEdgeList.push({ ...edge, id: generateId(), source: idMap.get(edge.source)!, target: idMap.get(edge.target)! }); } return { nodes: newNodeList, edges: newEdgeList }; }

粘贴时默认把新节点往右下角偏移 20px,这样用户能直观地看到粘贴结果。这个偏移值我测试过,太小了会叠在一起根本看不清,太大了又脱离鼠标摆放的直觉,20px 是折中经验值。

5.3 导出能力:SVG 原生导出与 PNG 兜底

因为渲染层选择的是 SVG,导出矢量图基本是白送的:直接拿 SVG DOM 序列化字符串,就能交给用户保存为 .svg 文件。

但实际环境中,用户更常要的是 PNG 图片。实现思路不复杂:把 SVG 塞进 Image,绘制到 Canvas 上,最后用 toDataURL 导出。

function exportPNG(svgElement: SVGElement, scale = 2) { const svgData = new XMLSerializer().serializeToString(svgElement); const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = svgElement.clientWidth * scale; canvas.height = svgElement.clientHeight * scale; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); // 触发下载 }; img.src = url; }

这里有两个坑我必须提一下:第一个是 SVG 里的 CSS 样式(比如样式表控制的 fill、stroke),在转图片时可能丢失,所以导出前要把样式内联到每个元素上;第二个是字体问题,如果节点文本使用了浏览器特殊字体,导出到图片时会回退成默认字体,所以图编辑器最好只依赖少量 web 安全字体。

我后来还在导出前加了一步"缩放画布以适配所有节点",也就是自动计算所有节点的包围盒,然后设置一个合适的 viewport 范围,保证导出内容不缺失也不留白太多。这个细节用户非常买账,因为很多人导出图片后发现只导出一小部分内容,体验很差。

6. 高频问题实录与排查思路

6.1 高频问题速查表

现象可能原因排查与解决
拖节点时鼠标"脱手",节点乱跑坐标系没换算,直接用了屏幕坐标检查 clientToCanvas 是否在 mousemove 中被调用,且不要修改源坐标
连线断开,节点移动后端点不跟edge 存了端点坐标而非锚点方向改存 source/target + handle,每次渲染时重新计算端点
缩放后节点文字模糊SVG 在浏览器里做了位缩放,或导出的图片分辨率低导出时提高 scale 参数,建议 2x 或 3x
撤销回到中间态拖动过程中记录过多历史合并操作,只在 mouseup 时记录一条命令
框选后拖动选不中节点框选矩形拦截了鼠标事件给临时矩形设置 pointer-events: none
锚点很难点中热区太小用透明的大圆作为热区绑定事件
数据导入后样式错乱缺少版本迁移直接读取旧数据增加 version 字段,按版本递增做迁移

我这个表是根据自己的踩坑记录整理的,每一行背后都有真实的排查过程,参考价值比单纯的架构描述要高得多。

6.2 SVG 事件流造成的一个隐蔽 bug

有一个 bug 极具隐蔽性,值得单独拿出来说。SVG 的事件会沿着嵌套结构向上冒泡,节点元素自身也设置了 mousedown 事件。当用户从节点内部拖动开始,mousedown 命中了节点内部的某个子元素(比如文本块),再冒泡到节点上,导致节点上的拖拽处理器被触发。

这本意是好的,但也引入了一个额外的联动问题:用户从空白处开始拖动框选时,如果起点正好落在某个节点下方,框选和节点拖拽会同时触发。我最后的处理方式是在空白处绑定 mousedown,并在内部检查 event.target 的所属层级,只有点到画布底层时才启动框选模式。

这个 20 行的判断加进去之后,用户误触的情况少了很多,交互干净了很多。

6.3 大数据量下 SVG 的优化手法

虽然 SVG 在几百节点的场景下很流畅,但一旦达到上千节点,会明显感觉到卡顿。我做过一轮专门的性能优化,效果比较明显的有三点:

  1. 只渲染视口范围内的节点和连线:通过视口坐标计算可见区域,渲染时过滤掉完全不可见的节点;
  2. 节流渲染:连续拖动时,用 requestAnimationFrame 合并渲染帧,避免同一帧里多次更新 DOM;
  3. 连线缓存路径字符串:同一个 edge 的路径属性只有在相关节点移动时才重新计算,否则复用缓存。

第一点需要数据层配合:渲染层遍历 nodes 数组时,通过 node 的 x/y/width/height 快速计算是否在视口内。这个过滤逻辑加在渲染层外层,数据层不动,也是我坚持分层之后得到的红利。

优化之后,即使画 2000 个节点,拖动缩放也能稳定在 40 帧以上,这在白板类工具里已经算比较可用的水平了。

6.4 一个关于"初始视口"的经验

最后讲一个容易忽略但体验影响很大的小点:新画布初始视口。我一开始默认视口偏移是 (0, 0),scale 是 1。但用户第一次打开时,如果画布上的节点都位于左上角,离可视区域边缘很近,视觉上很局促。后来我改成了"首次加载时自动计算所有节点的包围盒,居中显示",也就是通常所说的 fit to view。

function fitToView(nodes: DiagramNode[], svgWidth: number, svgHeight: number, padding = 40) { const bounds = getNodesBounds(nodes); // 计算包围盒 const scale = Math.min( (svgWidth - padding * 2) / bounds.width, (svgHeight - padding * 2) / bounds.height, 1 ); const offsetX = (svgWidth - bounds.width * scale) / 2 - bounds.x * scale; const offsetY = (svgHeight - bounds.height * scale) / 2 - bounds.y * scale; return { offsetX, offsetY, scale }; }

这个功能不值得做的时候你可能觉得没必要,但每次打开都看到一片空白画布,还得手动拖过去找节点,那个体验差距是非常直观的。diagram-design 上线后,我第一轮收集用户反馈时,这个"自动居中显示已有内容"被频繁提到是最想要的体验改进之一。

模块拆到这里,整个 diagram-design 的骨架已经比较完整了。从最初的数据结构定义,到 SVG 渲染选型,再到拖拽连线的交互细节,以及最后那层状态管理、撤销重做和导出能力,每个阶段踩过的坑我基本都写到了。最后再分享一个小技巧:图编辑器这类项目最难的往往不是某个炫酷功能,而是把基础交互手感打磨好,尤其是拖拽不漂移、缩放以鼠标为中心、锚点热区足够宽容这三件事。这三件事做好了,用户给你的第一句反馈一定不是"功能好强",而是"操作好顺"——对我来说,这句话比任何设计模式都值钱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 10:22:19

Python多进程爬虫实战:抓取研招网招生目录的全流程解析

做数据整理和批量查询的时候&#xff0c;基于多进程爬虫去抓研招网上的公开信息&#xff0c;基本是这个圈子里绕不开的入门实战项目。招生专业目录、专业学位点、院系代码这类公开数据&#xff0c;一页页复制粘帖会让人崩溃&#xff0c;手动查又太低效&#xff0c;写脚本去抓是…

作者头像 李华
网站建设 2026/10/21 10:22:18

12工位自动化装配线:S7-1200 PLC与库卡机器人集成实战指南

去年交付了一条自动化装配线&#xff0c;核心配置是西门子S7-1200 PLC加库卡机器人&#xff0c;整整排了12个工位。从方案评审到现场48小时老化跑完&#xff0c;整套系统算是彻底定下来了。这类配置在中小型自动化产线里非常常见&#xff0c;无非是上料、清洗、压装、拧紧、检测…

作者头像 李华
网站建设 2026/10/21 10:22:07

图表设计方法论:从信息分层到视觉编码的工程实践

1. 从“diagram-design”这个标题说起&#xff1a;它到底在解决什么问题第一次看到“diagram-design”这个标题&#xff0c;很多人会下意识觉得它是个画图工具&#xff0c;或者某个设计软件的插件。但如果你真正做过技术方案评审、系统架构梳理、或者给非技术同事讲清楚一个复杂…

作者头像 李华
网站建设 2026/10/21 10:21:45

永磁同步电机转动惯量在线辨识:基于MRAS的Simulink仿真实现

调试伺服速度环的时候&#xff0c;最磨人的不是电流环&#xff0c;而是那个写在驱动参数表里的“惯量比”。同一台永磁同步电机&#xff0c;空载跑得稳&#xff0c;装上负载之后超调、低频振荡全跑出来了。机械臂抓不同重量的工件&#xff0c;卷绕机构料卷直径不断变化&#xf…

作者头像 李华
网站建设 2026/10/21 10:21:24

温度补偿电路设计实战:温漂误差计算、NTC线性化网络与三温标定

简介&#xff1a;这是一份基于AD590感温器的温度补偿电路设计论文参考资料&#xff0c;面向电子测量、嵌入式系统与超声波测距方向的工程师和学习者&#xff0c;重点解决温度变化引起声速漂移、进而影响测距精度的问题。资源包内共1个Word格式文档&#xff0c;容量约17KB&#…

作者头像 李华
网站建设 2026/10/21 10:21:13

自由能不是能量?从熵到吉布斯自由能,看反应方向的热力学钥匙

1. 自由能不是一种“能”&#xff1a;先解决名字带来的误导1.1 能量守恒不回答方向问题我至今还记得第一次学到“自由能”时的感觉&#xff1a;能量守恒定律刚刚建立起来&#xff0c;所有变化都得满足总能量不变&#xff0c;怎么突然又冒出一个“自由能”&#xff1f;更让人困惑…

作者头像 李华