react 无限画布难点和实现
实现本质
超大固定画布 + 可移动窗口 = 模拟无限画布
方法
首先先了解渲染过程
JavaScript -> style -> layout -> paint -> composition
(一般来说做这个都是为了业务开发)
- ❌ 动态更新position —— 更新 x y(也就是容器的left top)
- 修改拖拽触发reflow
- 如使用
cahrts.forEach(c => {c.style.top = `${chart.originTop + deltaX}px`})
会导致所有图表重新渲染 - 同上,需要维护每个图标的原始位置,更新的话同时也要更新所有图的位置(即使其他图标并没有被拖拽)
- 如使用
- 需要管理状态 Drag(图) Resize(图) Pan(幕布) Resize(幕布)
- 从style开始重新排布,渲染效率低下
- 修改拖拽触发reflow
- ✅(建议)CSStransformation —— 移动整个容器
- 仅仅移动画布本身,chart作为子元素跟随,仅仅触发composite层渲染
- 此时仅针对Canvas管理 offset,zoom两个变量
- React可以状态控制UI
<div style-{{ translate : `translate(${x}px, ${y}px) scale(${zoom})`}}>
- 包含了chart本身的容器可以继续塞入新的dom元素(如toolbar)
- ❌ Canvas 像素级别重新绘制
- 像素绘制没有dom结构,所有操作都需要手动计算 x y
- 且需要手动判断(重写所有组件)不支持第三方库,开发成本高且难以维护
- 合适游戏、百万级数据可视化,不合适业务交互
架构设计
基本属性
const canvsSize = {width:2000,height:1500// rechart位置百分比 * 固定尺寸 = 不变的像素位置
}const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({x:0, y:0}) // pi
const [isPanning, setIsPanning] = useState(false) // drag
不要搞响应式画布width:window.innerWidth * 2
,不然画布大小改变会导致chart自动放缩
dom层次
<div class='canvas'>// 头部工具栏<div class='canvas-header'></div>// main body<div class='canvas-body'ref={containerRef}onMouseDown={handleCanvasPanStart} //capture dragging events><div class = 'canvans-container'ref={canvasRef}style={{transform:`translate(${canvasOffset.x}px, ${canvasOffset.y}px, scale(${canvasZoom})`}}// internal canvas for applying transform><ChartCard chart={chart1}/><ChartCard chart={chart2}/></div></div><div>
body 负责滚轮缩放拖拽
container接受transform的变换
chartcard 内部的chart absolute定位在画布内部
步骤实现
mousedown(空白位置记录起始位置) -> mousemove 拖拽 -> moseup 结束监听器
Pan代码
const handleCanvasPanStart = (e) = {//1. only invoke pan when clicking empty roomif(e.target == containerRef.current || e.target == canvasRef.current) {// canvas initial statesconst startX = e.clientX // mouse Xconst startY = e.clentY // mouse Yconst startOffsetX = canvasOffset.x const startOffsetY = canvasOffset.y//2. handle move eventconst handlePanMove=(moveE) => {const deltaX = moveE.clientX - startX;const deltaY = moveE.clientY - startY;setCanvasOffset({x: startOffsetX + deltaX,y: startOffsetY + deltaY})}//3. handle dragging endconst handlePanEnd = ()=>{setIsPanning(false)document.removeEventListener('mousemove', handlePanMove)document.removeEventListener('mouseup' handlePanEnd)}// bind up listeners to documents instead of elementsdocument.addEventListener('mousemove', handlePanMove)document.addEventListener('mouseup' handlePanEnd)}
}
流程
- DOM 绑定 start 函数;
- 点击时触发 start,start 给 document 绑定 move 和 end 的监听;
- 移动时,浏览器自动传事件参数(含坐标)给 move;
- 松开鼠标时,end 解绑监听。
滚动放缩
const handleWheel = (e)=>{if(e.shiftKey){e.preventDefault();//阻止默认的wheel 滚动const delta = -e.deltaY * 0.001 // 滚轮增量变为缩放增量setCanvasZoom(prev => {return Math.max(0.1, Math.min(3, prev + delta) //限制0.1 - 3})}
}
transform应用
{
transform:`translate(${canvasOffset.x}px, ${canvasOffset.y}px, scale(${canvasZoom}),
transformOrigin:'0 0',
}
基于左上角进行x y的点位放缩+平移
逻辑顺序 1. 先移动 2. 后缩放
双坐标
存储层:百分比坐标
渲染层: 转换成像素坐标
- 屏幕坐标
- 鼠标事件 client x y
- 相对窗口左上角
- 画布坐标
- transform放到像素上
- 保存成百分比坐标到数据库
转换公式
// render percentage -> pixels
const left = chart.position_x / 100 * containerWidth
const top = chart.position_y/100 * containerHeight
const width = chart.position_x/100 * containerWidth
const height = chart.position_y/100 * containerHeight//storage delta pixel -> delta percentage
const deltaX = moveE.clentX - startX
const deltaPercentageX = deltaX / canvasWidth//update
onUpdate({...chart,position_x:startPosX + deltaPercentX,position_y:startPosY + deltaPercentY
})
resize handle (四个角四个边)
八方向
nw -- n -- ne
| |
w e
| |
sw -- s -- se
边缘手柄css
resize-n{top: 0; left 50%; width:8px; height 4px; cursor ns-resize;}
resize-ne{top: 0; left 0; width:8px; height 4px; cursor nesw-resize;}
函数实现
const handleResizeMove = (e) => {//..calculate delta...// init new position// new的初始化就是start各个top left width heightif(direction.includes('e'){newWidth = Math.max(100, startWidth + deltaX)}...if(direction.includes('w)){newWidth = Math.max(100, startWidth - deltaX)newLeft = startLeft + (startWidth - newWidth)}
}// storage逻辑
newX = (newLeft/containerWidht)*100
newW = (newWidth/containerWidth)*100
//同时调用api更新到后端
//此时update完毕 rechart重新渲染