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重新渲染
相关推荐
花间相见9 分钟前
【终端效率工具01】—— Yazi:Rust 编写的现代化终端文件管理器,告别繁琐操作
前端·ide·git·rust·极限编程
|晴 天|19 分钟前
我如何用Vue 3打造一个现代化个人博客系统(性能提升52%)
前端·javascript·vue.js
风止何安啊27 分钟前
网页都知道要双向握手才加载!从 URL 到页面渲染,单向喜欢连 DNS 都解析不通
前端·javascript·面试
太极OS33 分钟前
给 AI Skill 做 CI/CD:GitHub + ClawHub + Xiaping 同步发布实战
前端
你_好33 分钟前
Chrome 内置了 AI 工具协议?WebMCP 抢先体验 + 开源 DevTools 全解析
前端·mcp
GISer_Jing33 分钟前
LangChain.js + LangGraph.js 前端AI开发实战指南
前端·javascript·langchain
正在发育ing__37 分钟前
从源码看vue的key和状态错乱的patch
前端
黄林晴1 小时前
第一次听到 Tauri 这个词,去学习一下
前端
可可爱爱的你吖1 小时前
蜂鸟云地图简单实现
前端
布局呆星1 小时前
Vue3 :生命周期、DOM 操作与自定义组合式函数
前端·javascript·vue.js