基础布局:
左侧:左侧红框
右侧:右侧红框
画布:绿色框
<div class='root'>
<div class='left'></div> // 左侧
<div class='right'></div> // 右侧
<svg class='canvas-box'></svg> // 画布(根据不同方案的选择不同的dom元素)
</div>
划线核心逻辑
确定开始坐标位置
确定结束坐标位置
连线
获取元素坐标
getPoint方法(通过自定义position位置,设置连线起始点和结束点的位置)
const getPoint = (args) => {
const { startDom, rootDom = containerRef.value, position = 'right' } = args || {};
const { left: x1, top: y1 } = getElementPositionRelativeToDocument(rootDom);
const { left: x2, top: y2, width, height } = getElementPositionRelativeToDocument(startDom);
let startX = x2 - x1;
let startY = y2 - y1;
if (position === 'bottom') {
startX = startX + width / 2;
startY = startY + height + lineSpacingRef.value;
}
if (['rt', 'tr'].includes(position)) {
startX = startX + width + lineSpacingRef.value;
startY = startY + lineSpacingRef.value;
}
if (position === 'right') {
startX = startX + width + lineSpacingRef.value;
startY = startY + height / 2;
}
if (['rb', 'br'].includes(position)) {
startX = startX + width + lineSpacingRef.value;
startY = startY + height + lineSpacingRef.value;
}
if (['lt', 'tl'].includes(position)) {
startX = startX - lineSpacingRef.value;
startY = startY + lineSpacingRef.value;
}
if (position === 'left') {
startX = startX - lineSpacingRef.value;
startY = startY + height / 2;
}
if (['lb', 'bl'].includes(position)) {
startX = startX - lineSpacingRef.value;
startY = startY + height + lineSpacingRef.value;
}
if (position === 'top') {
startX = startX + width / 2;
startY = startY - lineSpacingRef.value;
}
return { startX, startY };
};
getElementPositionRelativeToDocument方法
/**
* 获取目标输入参数元素所在位置
* @param element
*/
const getElementPositionRelativeToDocument = (element) => {
if (!element) return { left: 0, top: 0 };
const rect = element.getBoundingClientRect();
// 获取视口滚动位置
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// 计算元素相对于文档的位置
const leftPosition = rect.left + scrollLeft;
const topPosition = rect.top + scrollTop;
return { left: leftPosition, top: topPosition, width: rect.width, height: rect.height };
}
标题方案一、svg
设置svg的开始和结束位置坐标进行划线
<svg ref="svgRef" class="svg-box">
<g
v-for="(link, index) in links"
:key="index"
@mouseenter="isHovered = index"
@mouseleave="isHovered = null"
@click.prevent="handleDelSvgPath(link, index)"
>
<defs>
<marker :key="isHovered" :id="`arrow${index}`" v-bind="getMarkerInfo(index).marker" orient="auto-start-reverse">
<path :d="getMarkerInfo(index).d" :style="{ fill: 'none', stroke: arrowFillMap[link.type], strokeWidth: 1 }"></path>
</marker>
</defs>
<line
class="svg-line"
v-bind="getSvgInfo(link)"
:stroke="arrowFillMap[link.type]"
stroke-width="1"
:marker-end="`url(#arrow${index})`"
:stroke-dasharray="link.type === reverseCode ? '3, 3' : '0, 0'"
></line>
<!-- 用于增加热区的透明路径 -->
<line v-bind="getSvgInfo(link)" stroke="transparent" stroke-width="10" fill="none"></line>
</g>
</svg>
const getSvgInfo = (link) => {
const svgBox = svgRef.value;
const fromIndex = inputParamList.value?.findIndex(item => item.value === link.from);
const toIndex = outputParamList.value?.findIndex(item => item.value === link.to);
// 连线开始元素-startBlockRef.value[fromIndex] 连线结束元素-endBlockRef.value[toIndex]
const { startX: x2, startY: y2 } = getPoint({ startDom: endBlockRef.value[toIndex], position: 'left' });
const { startX: x1, startY: y1 } = getPoint({ startDom: startBlockRef.value[fromIndex], position: 'right' });
if (!svgBox || fromIndex === -1 || toIndex === -1) return { x1: 0, x2: 0, y1: 0, y2: 0 };
return { x1, x2, y1, y2 };
};
const getMarkerInfo = (index) => {
if (isHovered.value === index) return {
marker: {
markerWidth: 4,
markerHeight: 4,
refX: 2,
refY: 2
},
d: "M0,0 L2,2 L0,4"
}
return {
marker: {
markerWidth: 8,
markerHeight: 8,
refX: 4,
refY: 4
},
d: "M0,0 L4,4 L0,8"
}
};
确认开始节点
const handleConnectType = (value, row, index) => {
const id = row.value;
connectType.value = value;
popupVisibleMap.value[id] = false;
currentSelectedId.value = id;
svgCanvasListener();
const { startX, startY } = getPoint({ startDom: startBlockRef.value[index], position: row.position });
drawLine(startX, startY);
};
开始划线
let line = null;
const drawLine = (startX, startY) => {
line = createLine(startX, startY, startX, startY);
svgRef.value.appendChild(line);
};
划线createLine
const createLine = (x1, y1, x2, y2) => {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
line.setAttribute('stroke', arrowFillMap[connectType.value]);
line.setAttribute('stroke-width', '1');
if(connectType.value === reverseCode) line.setAttribute('stroke-dasharray', '3, 3'); // 设置虚线
if (showArrow) line.setAttribute('marker-end', 'url(#arrow)');
};
鼠标移动,跟随鼠标移动划线
const mousemove = (e) => {
if (!line) return;
const { left: targetPageX, top: targetPageY } = getElementPositionRelativeToDocument(svgRef.value);
const toX = e.pageX - targetPageX - lineSpacingRef.value;
const toY = e.pageY - targetPageY - lineSpacingRef.value;
line.setAttribute('x2', toX);
line.setAttribute('y2', toY);
};
确认结束元素
const handleEndPoint = (row) => {
// 数据处理
upData(); // 更新数据
if (line) svgRef.value.removeChild(line);
line = null;
}
标题方案二、canvas
设置canvas的开始和结束位置坐标进行划线
<canvas
ref="svgCanvasRef"
class="svg-canvas-container"
:width="root.offsetWidth"
:height="root.offsetHeight"
></canvas>
canvas划线
/**
* canvas画布划线
* @param canvasDom
* @param fromX
* @param fromY
* @param toX
* @param toY
*/
const drawCanvasLine = (ctx, fromX, fromY, toX, toY) => {
if (!svgCanvasRef.value) return;
const { arrowEnd1X, arrowEnd1Y, arrowEnd2X, arrowEnd2Y } = setArrowPath(fromX, fromY, toX, toY);
ctx.lineWidth = 1;
ctx.strokeStyle = arrowFillMap[connectType.value];
ctx.beginPath();
if (connectType.value === reverseCode) ctx.setLineDash([3, 3])
ctx.moveTo(fromX, fromY);
ctx.lineTo(toX, toY);
ctx.stroke();
if (!showArrow) return;
// 箭头
ctx.beginPath();
ctx.moveTo(toX, toY);
ctx.lineTo(arrowEnd1X, arrowEnd1Y);
ctx.moveTo(toX, toY);
ctx.lineTo(arrowEnd2X, arrowEnd2Y);
ctx.stroke();
}
/**
* 设置箭头
* @param startX
* @param startY
* @param endX
* @param endY
*/
const setArrowPath = (startX, startY, endX, endY) => {
// 箭头头部的大小和角度
const arrowLength = 5; // 箭头长度
const arrowAngle = 4; // 箭头角度
// 计算箭头头部的两个点
const angle = Math.atan2(endY - startY, endX - startX);
const arrowEnd1X = endX - arrowLength * Math.cos(angle - Math.PI / arrowAngle);
const arrowEnd1Y = endY - arrowLength * Math.sin(angle - Math.PI / arrowAngle);
const arrowEnd2X = endX - arrowLength * Math.cos(angle + Math.PI / arrowAngle);
const arrowEnd2Y = endY - arrowLength * Math.sin(angle + Math.PI / arrowAngle);
return { arrowEnd1X, arrowEnd1Y, arrowEnd2X, arrowEnd2Y };
};
鼠标移动,跟随鼠标移动划线
const mousemove = (e) => {
const { left: targetPageX, top: targetPageY } = getElementPositionRelativeToDocument(svgRef.value);
const toX = e.pageX - targetPageX - lineSpacingRef.value;
const toY = e.pageY - targetPageY - lineSpacingRef.value;
const canvas = svgRef.value.getContext('2d');
const width = svgRef.value.width;
const height = svgRef.value.height;
const ctx = canvas.clearRect(0, 0, width, height) || svgRef.value.getContext('2d');
drawCanvasLine(ctx, startX1, startY1, toX, toY)
links.value.forEach(item => {
const {x1, y1, x2, y2} = getSvgInfo(item);
drawCanvasLine(ctx, x1, y1, x2, y2);
})
};
确认数据,数据更新
const updateData = (links) => {
$emit('update:modelValue', getAnswerData(), links);
$emit('onAnswerChange', getAnswerData())
const canvas = svgRef.value.getContext('2d');
const width = svgRef.value.width;
const height = svgRef.value.height;
const ctx = canvas.clearRect(0, 0, width, height) || svgRef.value.getContext('2d')
links.forEach(item => {
const {x1, y1, x2, y2} = getSvgInfo(item);
drawCanvasLine(ctx, x1, y1, x2, y2);
})
};
标题方案三、svg(最终连线)+canvas(鼠标移动连线画布)
设置svg和canvas的开始和结束位置坐标进行划线
<svg v-if="draw" ref="svgRef" class="svg-box">
<g
v-for="(link, index) in links"
:key="index"
@mouseenter="isHovered = index"
@mouseleave="isHovered = null"
@click.prevent="handleDelSvgPath(link, index)"
>
<defs>
<marker :key="isHovered" :id="`arrow${index}`" v-bind="getMarkerInfo(index).marker" orient="auto-start-reverse">
<path :d="getMarkerInfo(index).d" :style="{ fill: 'none', stroke: arrowFillMap[link.type], strokeWidth: 1 }"></path>
</marker>
</defs>
<line
class="svg-line"
v-bind="getSvgInfo(link)"
:stroke="arrowFillMap[link.type]"
stroke-width="1"
:marker-end="`url(#arrow${index})`"
:stroke-dasharray="link.type === reverseCode ? '3, 3' : '0, 0'"
></line>
<!-- 用于增加热区的透明路径 -->
<line v-bind="getSvgInfo(link)" stroke="transparent" stroke-width="10" fill="none"></line>
</g>
</svg>
<canvas
v-if="showSvgCanvas"
ref="svgCanvasRef"
:key="canvasKey"
class="svg-canvas-container"
:width="containerRef.offsetWidth"
:height="containerRef.offsetHeight"
></canvas>
getSvgInfo、getMarkerInfo、获取开始位置、结束为止与方案一均一致
鼠标移动,跟随鼠标移动划线
const mousemove = (e) => {
if (!svgCanvasRef.value) return;
const { left: targetPageX, top: targetPageY } = getElementPositionRelativeToDocument(svgCanvasRef.value);
const { startX, startY } = startConnectLineDomPoint.value;
const toX = e.pageX - targetPageX;
const toY = (e.pageY - targetPageY);
drawCanvasLine(startX, startY, toX, toY);
};
/**
* canvas画布划线
* @param canvasDom
* @param fromX
* @param fromY
* @param toX
* @param toY
*/
const drawCanvasLine = (fromX, fromY, toX, toY) => {
if (!svgCanvasRef.value) return;
const canvas = svgCanvasRef.value.getContext('2d');
const width = svgCanvasRef.value.width;
const height = svgCanvasRef.value.height;
const ctx = canvas.clearRect(0, 0, width, height) || svgCanvasRef.value.getContext('2d');
const { arrowEnd1X, arrowEnd1Y, arrowEnd2X, arrowEnd2Y } = setArrowPath(fromX, fromY, toX, toY);
ctx.lineWidth = 1;
ctx.strokeStyle = arrowFillMap[connectType.value];
ctx.beginPath();
if (connectType.value === reverseCode) ctx.setLineDash([3, 3])
ctx.moveTo(fromX, fromY);
ctx.lineTo(toX, toY);
ctx.stroke();
if (!showArrow) return;
// 箭头
ctx.beginPath();
ctx.moveTo(toX, toY);
ctx.lineTo(arrowEnd1X, arrowEnd1Y);
ctx.moveTo(toX, toY);
ctx.lineTo(arrowEnd2X, arrowEnd2Y);
ctx.stroke();
}
/**
* 设置箭头
* @param startX
* @param startY
* @param endX
* @param endY
*/
const setArrowPath = (startX, startY, endX, endY) => {
// 箭头头部的大小和角度
const arrowLength = 5; // 箭头长度
const arrowAngle = 4; // 箭头角度
// 计算箭头头部的两个点
const angle = Math.atan2(endY - startY, endX - startX);
const arrowEnd1X = endX - arrowLength * Math.cos(angle - Math.PI / arrowAngle);
const arrowEnd1Y = endY - arrowLength * Math.sin(angle - Math.PI / arrowAngle);
const arrowEnd2X = endX - arrowLength * Math.cos(angle + Math.PI / arrowAngle);
const arrowEnd2Y = endY - arrowLength * Math.sin(angle + Math.PI / arrowAngle);
return { arrowEnd1X, arrowEnd1Y, arrowEnd2X, arrowEnd2Y };
};
标题方案四、css
通过设置两点之间的连线长度以及设置div元素的旋转角度进行划线
<div v-if="draw" ref="svgRef" class="svg-box">
<div v-for="(link, index) in links" class="line" :style="{
width: `${calculateDistance(getSvgInfo(link))}px`,
transform: `translateX(0) translateY(${getSvgInfo(link).y1}px) rotate(${calculateAngleDegrees(getSvgInfo(link))}deg)`
}">
</div>
.svg-box {
flex: 1;
position: absolute;
left: 0;
top: 0;
pointer-events: none;
.line {
width: 300px; /* 线条的长度 */
height: 0; /* 线条的宽度 */
border-top: 1px solid red;
&:after {
content: '>';
position: absolute;
top: -12px;
right: 6px;
width: 0;
height: 0;
color: red;
}
}
}
getSvgInfo、getMarkerInfo、获取开始位置、结束为止与方案一均一致
calculateAngleDegrees方法(根据终点坐标计算角度)
const calculateAngleDegrees = ({x1, y1, x2, y2}) => {
// 检查分母是否为0(即x1是否等于x2)
if (x1 === x2) {
// 如果x1等于x2,则线条是垂直的
// 这里我们假设y2 > y1表示向上,y2 < y1表示向下
return y2 > y1 ? 90 : -90; // 或者返回270而不是-90
}
// 计算斜率
let slope = (y2 - y1) / (x2 - x1);
// 使用Math.atan()计算角度(以弧度为单位)
let angleRadians = Math.atan(slope);
// 将弧度转换为度
let angleDegrees = angleRadians * (180 / Math.PI);
// 如果线条是从右向左倾斜的(x2 < x1),并且你想要一个正的角度值
// (尽管CSS的rotate函数已经是以逆时针方向为正方向的)
// if (x2 < x1) angleDegrees += 180;
// 返回角度(以度为单位)
return angleDegrees;
};
calculateDistance方法(获取两点之间的距离)
const calculateDistance = ({ x1, y1, x2, y2 }) => {
// 计算两点在x轴和y轴上的距离差
let dx = x2 - x1;
let dy = y2 - y1;
// 应用勾股定理计算斜线的长度
let distance = Math.sqrt(dx * dx + dy * dy);
return distance;
}
划线
let line = null;
const drawLine = (startX, startY) => {
line = document.createElement("div");
line.className = "line";
svgRef.value.appendChild(line);
};
鼠标移动划线
const mousemove = (e) => {
if (!line) return;
const { left: targetPageX, top: targetPageY } = getElementPositionRelativeToDocument(svgRef.value);
const toX = e.pageX - targetPageX - lineSpacingRef.value;
const toY = e.pageY - targetPageY - lineSpacingRef.value;
line.style.width = `${calculateDistance({x1, y1, x2: toX, y2: toY})}px`;
line.style.borderTop = '1px solid red';
line.style.transformOrigin = '0 0';
line.style.transform = `translateX(0) translateY(${y1}px) rotate(${calculateAngleDegrees({x1, y1, x2: toX, y2: toY})}deg)`;
};