react 无限画布难点和实现

实现本质

超大固定画布 + 可移动窗口 = 模拟无限画布

方法

首先先了解渲染过程

复制代码
JavaScript -> style -> layout -> paint -> composition

(一般来说做这个都是为了业务开发)

  1. ❌ 动态更新position ------ 更新 x y(也就是容器的left top)
    • 修改拖拽触发reflow
      • 如使用 cahrts.forEach(c => {c.style.top = `${chart.originTop + deltaX}px`})会导致所有图表重新渲染
      • 同上,需要维护每个图标的原始位置,更新的话同时也要更新所有图的位置(即使其他图标并没有被拖拽)
    • 需要管理状态 Drag(图) Resize(图) Pan(幕布) Resize(幕布)
    • 从style开始重新排布,渲染效率低下
  2. ✅(建议)CSStransformation ------ 移动整个容器
    • 仅仅移动画布本身,chart作为子元素跟随,仅仅触发composite层渲染
    • 此时仅针对Canvas管理 offset,zoom两个变量
    • React可以状态控制UI <div style-{````{ translate : `translate(${x}px, ${y}px) scale(${zoom})`}}>
    • 包含了chart本身的容器可以继续塞入新的dom元素(如toolbar)
  3. ❌ Canvas 像素级别重新绘制
    • 像素绘制没有dom结构,所有操作都需要手动计算 x y
    • 且需要手动判断(重写所有组件)不支持第三方库,开发成本高且难以维护
    • 合适游戏、百万级数据可视化,不合适业务交互

架构设计

基本属性

js 复制代码
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层次

js 复制代码
<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代码

js 复制代码
const handleCanvasPanStart = (e) = {
	//1. only invoke pan when clicking empty room
	if(e.target == containerRef.current || e.target == canvasRef.current) {
	// canvas initial states
	const startX = e.clientX // mouse X
	const startY = e.clentY // mouse Y
	const startOffsetX = canvasOffset.x 
	const startOffsetY = canvasOffset.y
	
	//2. handle move event
	const handlePanMove=(moveE) => {
		const deltaX = moveE.clientX - startX;
		const deltaY = moveE.clientY - startY;
		setCanvasOffset({
			x: startOffsetX + deltaX,
			y: startOffsetY + deltaY
		})
	}
	//3. handle dragging end
	const handlePanEnd = ()=>{
		setIsPanning(false)
		document.removeEventListener('mousemove', handlePanMove)
		document.removeEventListener('mouseup' handlePanEnd)
	}
	// bind up listeners to documents instead of elements
	document.addEventListener('mousemove', handlePanMove)
	document.addEventListener('mouseup' handlePanEnd)



	}
}

流程

  1. DOM 绑定 start 函数;
  2. 点击时触发 start,start 给 document 绑定 move 和 end 的监听;
  3. 移动时,浏览器自动传事件参数(含坐标)给 move;
  4. 松开鼠标时,end 解绑监听。

滚动放缩

js 复制代码
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放到像素上
  • 保存成百分比坐标到数据库

转换公式

js 复制代码
// 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;}

函数实现

js 复制代码
const handleResizeMove = (e) => {
	//..calculate delta
	...
	// init new position
	// new的初始化就是start各个top left width height

	if(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重新渲染
相关推荐
Cxiaomu4 小时前
React Native 项目中 WebSocket 的完整实现方案
websocket·react native·react.js
im_AMBER4 小时前
React 02
前端·笔记·学习·react.js·前端框架
浩男孩4 小时前
🍀我实现了个摸鱼聊天室🚀
前端
玲小珑4 小时前
LangChain.js 完全开发手册(十六)实战综合项目二:AI 驱动的代码助手
前端·langchain·ai编程
井柏然4 小时前
从 Monorepo 重温 ESM 的模块化机制
前端·javascript·前端工程化
晓得迷路了4 小时前
栗子前端技术周刊第 102 期 - Vite+ 正式发布、React Native 0.82、Nitro v3 alpha 版...
前端·javascript·vite
XXX-X-XXJ4 小时前
Vue Router完全指南 —— 从基础配置到权限控制
前端·javascript·vue.js
云和数据.ChenGuang4 小时前
vue钩子函数调用问题
前端·javascript·vue.js
鹏多多4 小时前
React动画方案对比:CSS动画和Framer Motion和React Spring
前端·javascript·react.js