当前位置: 首页 > news >正文

【React】动态SVG连接线实现:图片与按钮的可视化映射​

目录

  • 前言
  • 一、实现步骤
    • 1. 准备DOM引用
    • 2. 计算连接线路径
    • 3. 渲染连接线
    • 4. 添加交互效果
    • 5. 响应窗口变化和滚动
    • 6. 优化路径计算(曲线路径)
  • 二、核心知识点解析
    • 1. DOM位置获取
    • 2. SVG路径绘制
    • 3. React Refs系统
    • 4. 事件处理
    • 5. 性能优化
  • 三、完整组件示例 + 运行效果
  • 四、高级优化技巧
    • 1. 性能优化
    • 2. 动画效果
    • 3. 响应设计
    • 4. 虚拟化渲染


前言

本文基于React 18 + TypeScript环境,详细讲解如何在SVG图形和按钮之间实现动态连接线。这种连接线不是通过拖拽改变,而是基于数据配置自动生成。

核心在于根据用户在配置弹窗中的选择,动态生成并渲染图标与按钮之间的连接线。


提示:以下是本篇文章正文内容,下面案例可供参考

一、实现步骤

实现SVG连接线的关键步骤:
​​(1)准备DOM引用​​:为端口和出口创建Ref
​​(2)计算路径​​:基于元素位置计算SVG路径
​​(3)渲染连线​​:使用SVG 元素
​​(4)添加交互​​:实现悬停高亮效果
​​(5)响应变化​​:监听窗口大小和滚动事件

1. 准备DOM引用

首先需要为每个端口和出口创建DOM引用,以便获取它们的位置信息:

// 创建Refs存储端口和出口的DOM元素
const portNodeRefs = useRef<Record<string, HTMLElement | null>>({});
const exitNodeRefs = useRef<Record<string, HTMLElement | null>>({});
const containerRef = useRef<HTMLDivElement | null>(null);// 在渲染函数中绑定Ref
{ports.map((port) => (<divkey={port.name}ref={(el) => (portNodeRefs.current[port.name] = el)}// ...其他属性>{/* 端口图标 */}</div>
))}{landingOptions.map((option) => (<divkey={option.value}ref={(el) => (exitNodeRefs.current[option.value] = el)}// ...其他属性>{/* 出口按钮 */}</div>
))}

2. 计算连接线路径

这是核心步骤,需要计算每个端口到其分配出口的连线路径:

const recomputeLines = () => {const container = containerRef.current;if (!container) return;const containerRect = container.getBoundingClientRect();const newLines: { path: string; port: string; exit: string }[] = [];// 遍历所有端口和其分配的出口Object.entries(portLandingMap).forEach(([portName, exits]) => {exits?.forEach((exitVal) => {const portEl = portNodeRefs.current[portName];const exitEl = exitNodeRefs.current[exitVal];if (!portEl || !exitEl) return;// 获取端口和出口的位置const portRect = portEl.getBoundingClientRect();const exitRect = exitEl.getBoundingClientRect();// 计算相对容器的坐标const startX = portRect.left - containerRect.left + portRect.width / 2;const startY = portRect.bottom - containerRect.top;const endX = exitRect.left - containerRect.left + exitRect.width / 2;const endY = exitRect.top - containerRect.top;// 计算路径(这里使用简单的折线,实际可优化为曲线)const path = `M ${startX} ${startY} L ${endX} ${endY}`;newLines.push({ path, port: portName, exit: exitVal });});});setLines(newLines);
};

3. 渲染连接线

在组件中渲染SVG路径:

<div ref={containerRef} style={{ position: 'relative' }}>{/* 端口和出口的渲染 */}{/* SVG容器用于绘制连接线 */}<svgstyle={{position: 'absolute',top: 0,left: 0,width: '100%',height: '100%',pointerEvents: 'none', // 避免连线拦截鼠标事件}}>{lines.map((line, index) => (<pathkey={index}d={line.path}stroke="#1f78ff" // 线条颜色strokeWidth="2"  // 线条宽度fill="none"/>))}</svg>
</div>

4. 添加交互效果

实现鼠标悬停时高亮相关连线的效果:

// 状态记录当前悬停的连线
const [hoverLineKey, setHoverLineKey] = useState<string | null>(null);// 在渲染连线时添加交互
{lines.map((line, index) => {const lineKey = `${line.port}-${line.exit}`;return (<pathkey={index}d={line.path}stroke={hoverLineKey === lineKey ? '#ff0000' : '#1f78ff'}strokeWidth={hoverLineKey === lineKey ? 3 : 2}fill="none"onMouseEnter={() => setHoverLineKey(lineKey)}onMouseLeave={() => setHoverLineKey(null)}/>);
})}

5. 响应窗口变化和滚动

确保在窗口大小变化或容器滚动时重新计算连线位置:

useEffect(() => {const handleResize = () => recomputeLines();window.addEventListener('resize', handleResize);const container = containerRef.current;if (container) {container.addEventListener('scroll', recomputeLines);}return () => {window.removeEventListener('resize', handleResize);if (container) {container.removeEventListener('scroll', recomputeLines);}};
}, []);

6. 优化路径计算(曲线路径)

更美观的贝塞尔曲线实现:

// 计算曲线路径
const controlPointY = (startY + endY) / 2;
const path = `M ${startX} ${startY} C ${startX} ${controlPointY}, ${endX} ${controlPointY}, ${endX} ${endY}`;

二、核心知识点解析

1. DOM位置获取

使用getBoundingClientRect()获取元素位置和尺寸:

// 关键计算步骤:
const containerRect = container.getBoundingClientRect();
const portRect = portEl.getBoundingClientRect();// 计算相对容器位置的坐标
const px = portRect.left - containerRect.left + portRect.width / 2;
const py = portRect.bottom - containerRect.top;

2. SVG路径绘制

SVG 元素的d属性定义路径:
M x y:移动到指定坐标
L x y:画直线到指定坐标
C x1 y1, x2 y2, x y:三次贝塞尔曲线

// SVG路径命令:
// M = moveto (起点)
// L = lineto (直线)
// C = curveto (贝塞尔曲线)
// Z = closepath (闭合路径)const path = `M ${startX} ${startY} L ${midX} ${midY} L ${endX} ${endY}`;

3. React Refs系统

三种Ref使用场景:
​​DOM元素引用​​:useRef(null)+ ref={ref}
​​存储可变值​​:替代类组件的实例变量
​​转发Refs​​:forwardRef访问子组件DOM

// 动态ref管理模式
const refs = useRef<Record<string, HTMLElement | null>>({});// 在渲染循环中绑定
{elements.map(item => (<divkey={item.id}ref={(el) => {refs.current[item.id] = el;}}/>
))}

4. 事件处理

React合成事件系统:
使用onMouseEnter/onMouseLeave代替原生事件
事件委托提高性能
使用useEffect清理事件监听器

5. 性能优化

// 1. 使用useLayoutEffect避免闪烁
useLayoutEffect(() => {recomputeLines();
}, [dependencies]);// 2. 防抖处理
const debouncedRecompute = useMemo(() => debounce(recomputeLines, 100),[]
);// 3. 条件重计算
const shouldRecompute = useMemo(() => {return JSON.stringify(portLandingMap) !== lastMappingRef.current;
}, [portLandingMap]);

三、完整组件示例 + 运行效果

📢注意:我现在给的这个套完整示例,仅作为参考,跟上面的步骤还是不太一样的,比如缺少编辑弹窗等,但是为了方便直接运行,看到连线效果,改动后如下:
在这里插入图片描述

// App.tsx
import React, { useRef, useState, useLayoutEffect, useCallback, useMemo } from 'react';interface Port {name: string;isWan?: boolean;active?: boolean;
}interface LandingOption {value: string;label: string;
}interface ConnectionLine {path: string;port: string;exit: string;
}const PortConnectionDemo: React.FC = () => {// 状态管理const [ports] = useState<Port[]>([{ name: 'eth0', isWan: true, active: true },{ name: 'eth1', isWan: false, active: true },{ name: 'eth2', isWan: false, active: false },{ name: 'eth3', isWan: false, active: true },]);const [landingOptions] = useState<LandingOption[]>([{ value: 'LA', label: 'Los Angeles' },{ value: 'SG', label: 'Singapore' },{ value: 'NY', label: 'New York' },]);const [portLandingMap] = useState<Record<string, string[]>>({eth1: ['LA', 'SG'],eth3: ['NY'],});// DOM引用const portNodeRefs = useRef<Record<string, HTMLDivElement | null>>({});const exitNodeRefs = useRef<Record<string, HTMLDivElement | null>>({});const containerRef = useRef<HTMLDivElement>(null);const linesSvgRef = useRef<SVGSVGElement>(null);// 连线状态const [lines, setLines] = useState<ConnectionLine[]>([]);const [hoverLineKey, setHoverLineKey] = useState<string | null>(null);// 核心连线计算函数const recomputeLines = useCallback(() => {const container = containerRef.current;if (!container) {if (lines.length) setLines([]);return;}const containerRect = container.getBoundingClientRect();const basePortRadius = 5;const minExitRadius = 4;const exitRadiusCache: Record<string, number> = {};const newLines: ConnectionLine[] = [];const exitBusYCache: Record<string, number> = {};const exitConnections: Record<string, string[]> = {};// 建立出口连接关系映射Object.keys(portLandingMap).forEach((portName) => {const exits = portLandingMap[portName];exits?.forEach((exitVal) => {if (!exitConnections[exitVal]) exitConnections[exitVal] = [];exitConnections[exitVal].push(portName);});});// 计算每个出口的总线Y坐标const getBusYForExit = (exitVal: string): number => {if (exitBusYCache[exitVal] !== undefined) return exitBusYCache[exitVal];const exitEl = exitNodeRefs.current[exitVal];const eyTop = exitEl ? exitEl.getBoundingClientRect().top - containerRect.top : 0;const connectedPorts = Object.keys(portLandingMap).filter((portName) =>portLandingMap[portName]?.includes(exitVal));const portBottoms: number[] = [];connectedPorts.forEach((portName) => {const portEl = portNodeRefs.current[portName];if (portEl) {portBottoms.push(portEl.getBoundingClientRect().bottom - containerRect.top);}});const avgPortBottom = portBottoms.length? portBottoms.reduce((a, b) => a + b, 0) / portBottoms.length: eyTop - 50;// 40% 靠上位置,避免总线过低const busY = Math.round(avgPortBottom + (eyTop - avgPortBottom) * 0.4);exitBusYCache[exitVal] = busY;return busY;};// 为每个端口到出口计算路径for (const portName of Object.keys(portLandingMap)) {const exits = portLandingMap[portName];if (!exits || exits.length === 0) continue;const portEl = portNodeRefs.current[portName];if (!portEl) continue;const portRect = portEl.getBoundingClientRect();const startX = portRect.left - containerRect.left + portRect.width / 2;const startY = portRect.bottom - containerRect.top;// 检查端口是否在容器可见范围内if (startX < 0 || startX > containerRect.width) continue;for (const exitVal of exits) {const exitEl = exitNodeRefs.current[exitVal];if (!exitEl) continue;const exitRect = exitEl.getBoundingClientRect();const endX = exitRect.left - containerRect.left + exitRect.width / 2;const endY = exitRect.top - containerRect.top;// 检查出口是否在容器可见范围内if (endX < 0 || endX > containerRect.width) continue;const direction = endX >= startX ? 1 : -1;const busY = getBusYForExit(exitVal);// 计算分层偏移,避免重叠const connectedPorts = exitConnections[exitVal] || [];const laneIndex = connectedPorts.indexOf(portName);const laneCount = connectedPorts.length || 1;const laneSpread = Math.min(26, Math.max(8, laneCount * 5));const laneOffset = laneCount === 1 ? 0 : (laneIndex / (laneCount - 1) - 0.5) * laneSpread;const laneBusY = busY + laneOffset;// 计算圆角半径const downSpace = laneBusY - startY;const upSpace = endY - laneBusY;let portRadius = Math.min(basePortRadius,Math.max(3, Math.floor(Math.min(downSpace, upSpace) * 0.55)));if (Math.min(downSpace, upSpace) < basePortRadius * 1.6) {portRadius = Math.min(portRadius, Math.max(2, Math.floor(Math.min(downSpace, upSpace) * 0.45)));}const canUsePortArc = downSpace > portRadius * 1.8 && portRadius >= 2;const portVertEndY = Math.max(startY + 4, laneBusY - portRadius);const sweep1 = direction > 0 ? 0 : 1;const sweep2 = direction > 0 ? 1 : 0;// 出口侧圆角计算if (exitRadiusCache[exitVal] === undefined) {const upSpaceAtExit = endY - busY;let exitRadius = Math.min(Math.max(minExitRadius, portRadius), Math.floor(upSpaceAtExit * 0.55));if (upSpaceAtExit < portRadius * 1.6) {exitRadius = Math.min(exitRadius, Math.max(minExitRadius, Math.floor(upSpaceAtExit * 0.45)));}// 协调端口和出口圆角if (Math.abs(exitRadius - portRadius) > 3) {exitRadius = exitRadius > portRadius ? portRadius + 3 : exitRadius;}exitRadiusCache[exitVal] = Math.max(minExitRadius, exitRadius);}let exitRadius = exitRadiusCache[exitVal];const exitLaneUpSpace = endY - laneBusY;if (exitLaneUpSpace < exitRadius + 6) {const availableSpace = exitLaneUpSpace - 4;exitRadius = availableSpace >= minExitRadius ? Math.min(exitRadius, availableSpace) : 0;}const canUseExitArc = exitRadius >= minExitRadius;const midStartX = startX + direction * portRadius;const midEndX = endX - direction * (canUseExitArc ? exitRadius : 0);let useSimple = false;if (Math.abs(midEndX - midStartX) < portRadius * 2.2) {useSimple = true;}let path: string;if (useSimple) {const arcY = (startY + endY) / 2;if (canUsePortArc && canUseExitArc) {path = [`M ${startX} ${startY}`,`V ${arcY - portRadius}`,`A ${portRadius} ${portRadius} 0 0 ${sweep1} ${startX + direction * portRadius} ${arcY}`,`H ${endX - direction * exitRadius}`,`A ${exitRadius} ${exitRadius} 0 0 ${sweep2} ${endX} ${arcY + exitRadius}`,`V ${endY}`,].join(' ');} else {// 简化路径:直线连接path = `M ${startX} ${startY} V ${arcY} H ${endX} V ${endY}`;}} else {// 标准路径:带圆角的折线const arc1EndY = laneBusY;if (canUsePortArc && canUseExitArc) {path = [`M ${startX} ${startY}`,`V ${portVertEndY}`,`A ${portRadius} ${portRadius} 0 0 ${sweep1} ${startX + direction * portRadius} ${arc1EndY}`,`H ${midEndX}`,`A ${exitRadius} ${exitRadius} 0 0 ${sweep2} ${endX} ${laneBusY + exitRadius}`,`V ${endY}`,].join(' ');} else {// 降级为直角折线path = `M ${startX} ${startY} V ${laneBusY} H ${endX} V ${endY}`;}}newLines.push({ path, port: portName, exit: exitVal });}}// 只有当连线确实变化时才更新状态const sameLength = newLines.length === lines.length;let sameContent = sameLength;if (sameLength) {for (let i = 0; i < newLines.length; i++) {if (newLines[i].path !== lines[i].path) {sameContent = false;break;}}}if (!sameContent) {setLines(newLines);}}, [lines, portLandingMap]);// 初始化和响应式更新useLayoutEffect(() => {recomputeLines();}, [recomputeLines]);// 窗口大小变化监听useLayoutEffect(() => {const handleResize = () => {recomputeLines();};window.addEventListener('resize', handleResize);return () => {window.removeEventListener('resize', handleResize);};}, [recomputeLines]);// 颜色映射const exitColorMap = useMemo(() => {const baseColors = ['#1f78ff', '#10b981', '#f59e0b', '#6366f1', '#ef4444'];const map: Record<string, string> = {};landingOptions.forEach((opt, idx) => {map[opt.value] = baseColors[idx % baseColors.length];});return map;}, [landingOptions]);// 处理鼠标悬停const handlePortMouseEnter = useCallback((portName: string) => {setHoverLineKey(`${portName}::ALL`);}, []);const handlePortMouseLeave = useCallback(() => {setHoverLineKey(null);}, []);const handleExitMouseEnter = useCallback((exitVal: string) => {setHoverLineKey(`ANY::${exitVal}`);}, []);const handleExitMouseLeave = useCallback(() => {setHoverLineKey(null);}, []);const handleLineMouseEnter = useCallback((port: string, exit: string) => {setHoverLineKey(`${port}::${exit}`);}, []);const handleLineMouseLeave = useCallback(() => {setHoverLineKey(null);}, []);return (<div ref={containerRef}style={{ position: 'relative', padding: '40px 20px',background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',minHeight: '500px',borderRadius: '12px',border: '1px solid #e2e8f0'}}>{/* 标题 */}<div style={{ textAlign: 'center', marginBottom: '40px' }}><h2 style={{ color: '#1e293b', margin: 0 }}>网络端口连接示意图</h2><p style={{ color: '#64748b', margin: '8px 0 0 0' }}>展示端口与出口之间的动态连接关系</p></div>{/* 端口区域 */}<div style={{ display: 'flex', justifyContent: 'center',gap: '30px',marginBottom: '120px',flexWrap: 'wrap' as const}}>{ports.map((port) => (<divkey={port.name}ref={(el) => {if (el) portNodeRefs.current[port.name] = el;}}style={{padding: '16px',border: `2px solid ${port.active ? '#1f78ff' : '#cbd5e1'}`,borderRadius: '8px',background: port.active ? '#ffffff' : '#f8fafc',boxShadow: port.active ? '0 4px 12px rgba(31, 120, 255, 0.15)' : 'none',textAlign: 'center',minWidth: '80px',transition: 'all 0.3s ease',opacity: port.active ? 1 : 0.6,cursor: 'pointer'}}onMouseEnter={() => handlePortMouseEnter(port.name)}onMouseLeave={handlePortMouseLeave}><div style={{ fontSize: '24px', marginBottom: '8px',color: port.active ? '#1f78ff' : '#94a3b8'}}>{port.isWan ? '🌐' : '🔌'}</div><div style={{ fontWeight: '600',color: port.active ? '#1e293b' : '#94a3b8'}}>{port.name}</div>{port.isWan && (<div style={{ fontSize: '12px', color: '#64748b',marginTop: '4px'}}>WAN</div>)}{!port.active && (<div style={{ fontSize: '12px', color: '#ef4444',marginTop: '4px'}}>未激活</div>)}</div>))}</div>{/* 出口区域 */}<div style={{ display: 'flex', justifyContent: 'center',gap: '25px',flexWrap: 'wrap' as const}}>{landingOptions.map((option) => (<divkey={option.value}ref={(el) => {if (el) exitNodeRefs.current[option.value] = el;}}style={{padding: '12px 24px',background: exitColorMap[option.value],color: 'white',borderRadius: '6px',fontWeight: '600',boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',cursor: 'pointer',transition: 'all 0.3s ease',minWidth: '100px',textAlign: 'center'}}onMouseEnter={() => handleExitMouseEnter(option.value)}onMouseLeave={handleExitMouseLeave}>{option.label}</div>))}</div>{/* SVG连接线容器 */}<svgref={linesSvgRef}style={{position: 'absolute',top: 0,left: 0,width: '100%',height: '100%',pointerEvents: 'none',zIndex: 1}}>{lines.map((line, index) => {const lineKey = `${line.port}::${line.exit}`;const isHovered = hoverLineKey === lineKey ||hoverLineKey === `${line.port}::ALL` ||hoverLineKey === `ANY::${line.exit}`;const baseColor = exitColorMap[line.exit] || '#1f78ff';return (<pathkey={index}d={line.path}stroke={baseColor}strokeWidth={isHovered ? 3 : 2}fill="none"strokeLinecap="round"strokeLinejoin="round"style={{opacity: isHovered ? 1 : 0.8,filter: isHovered ? 'drop-shadow(0 0 6px rgba(0,0,0,0.3))' : 'none',transition: 'all 0.3s ease',pointerEvents: 'stroke' as const}}onMouseEnter={() => handleLineMouseEnter(line.port, line.exit)}onMouseLeave={handleLineMouseLeave}/>);})}</svg>{/* 图例说明 */}<div style={{position: 'absolute',bottom: '20px',left: '20px',background: 'rgba(255, 255, 255, 0.9)',padding: '12px 16px',borderRadius: '8px',border: '1px solid #e2e8f0',fontSize: '12px',color: '#64748b'}}><div style={{ fontWeight: '600', marginBottom: '4px' }}>图例说明:</div><div>• 实线边框:端口已激活</div><div>• 虚线边框:端口未激活</div><div>• 悬停效果:高亮相关连接线</div><div>• 圆角路径:自适应圆角计算</div><div>• 分层处理:避免连线重叠</div></div></div>);
};export default PortConnectionDemo;

四、高级优化技巧

1. 性能优化

// 使用防抖避免频繁重计算
const recomputeLines = useCallback(debounce(() => {// 计算逻辑...
}, 100), [portLandingMap, landingOptions]);// 使用React.memo避免不必要重渲染
const PortIcon = React.memo(({ port }) => {// 渲染逻辑
});

2. 动画效果

// 使用react-spring添加动画
import { useSpring, animated } from 'react-spring';const AnimatedPath = animated.path;// 在组件中使用
<AnimatedPathd={line.path}style={{stroke: colorSpring,strokeWidth: widthSpring}}
/>

3. 响应设计

/* 使用CSS媒体查询适应不同屏幕 */
@media (max-width: 768px) {.port-container {flex-direction: column;}.exit-container {flex-wrap: wrap;}
}

4. 虚拟化渲染

对于大量连线的情况,使用虚拟化技术:

import { useVirtual } from 'react-virtual';const virtualizer = useVirtual({size: lines.length,parentRef: containerRef,estimateSize: useCallback(() => 20, []),
});{virtualizer.virtualItems.map((virtualRow) => (<pathkey={virtualRow.index}d={lines[virtualRow.index].path}// ...其他属性style={{position: 'absolute',top: 0,left: 0,width: '100%',height: `${virtualRow.size}px`,transform: `translateY(${virtualRow.start}px)`}}/>
))}
http://www.dtcms.com/a/470597.html

相关文章:

  • 专门做油站数据的网站游戏网页版入口
  • 【碎片化学习】SpringBoot数据库驱动介绍配置方法和代码
  • 设计模式篇之 适配器模式 Adapter
  • 小程序怎么制作自己的小程序seo长尾关键词优化
  • 网站备案号规则中核集团2023校园招聘信息
  • postman 做接口测试之学习笔记
  • 做网站要买多少服务器空间有什么做家纺的网站
  • 【编号26】青藏高原地理空间全套数据集(矢量边界、子流域、行政边界、水系等)
  • loguru 和 logging 的详细对比
  • 番禺移动网站建设百度快照投诉中心官网
  • 调试去符号化/strip 过的二进制的调试方法
  • 大连建设局网站地址怎么将自己房子投入网站做民宿
  • 新河网站旅游网站策划方案
  • 建网站备案好麻烦长春市建设工程造价管理协会网站
  • 东莞设计网站建设方案南京网站建设排名
  • Dirty COW容器逃逸漏洞渗透实战:原理+复现 (CVE-2016-5195)
  • 2010 866数据结构 算法设计题——链表,二叉树
  • 对海尔网站建设水平的评价长沙网站备案
  • Codeforces Round 1057 (Div. 2)(A-D)
  • 微信网站备案wordpress step2
  • XSS 漏洞全解析:从原理到实战
  • 傻瓜式 建网站软件外包公司人数
  • Nestjs service 对应token的作用范围
  • Google 智能体设计模式:评估与监控
  • 如何屏蔽网站ipwordpress 商城模板下载
  • OpenMM 8 安装与上手指南
  • 网站建设跟网站开发有什么区别吗832网络销售平台
  • 力扣热题100道49字母异位词分组
  • sql优化进阶
  • 网站灰色建设网销怎么找客户资源