当前位置:秋叶网络博客 前端编程 ◊ JS给导线描边-非样式属性

JS给导线描边-非样式属性

作者:秋叶 发表时间:2018年7月21日

最近工作中做了个关于描边的故事,对,就是给导线描边,通俗的讲就是沿着线条勾勒出它的轮廓,细的线条貌似没什么意义,但是在较粗的导线上或者是细的线条放大后,就格外有用途了,比如说用线条给导线挖孔,给导线添加线条轮廓动画等等。

这里用到了一些技术知识点:

1. 三角函数、向量关系,线段关系在javascript中的应用,主要与三角函数为主体;

2. svg的属性和操作,当然描边不局限于svg,在任何场景都可以用,本文只是用svg举例;

原理开始剖析,供学习的人阅读。(如果你只是想把它应用到你的项目中,你可以跳过原理部分,直接到文尾找完整代码

描边首先是基于坐标点来产生一些相对位置的点来勾勒出线条的走向。所以一条线段肯定有它的路径,比如svg中的polyline和path,它们的路径分别存在属性的points和d中。

类似这样:

<polyline points="80 50 100 100 60 90 88 200 188 30 200 300 250 200 300 200 300 100 350 150 600 20 500 330 600 400" stroke-linecap="round" fill="none" id="polyline" stroke-width="20" stroke="#FF0000" stroke-linejoin="round"></polyline>
<path stroke-width="15" fill="none" id="path" d="M 100 440 L 150 400 L 200 500 L 250 400 L 350 550 L 400 400" stroke="rgba(0,0,0,.5)" stroke-linejoin="round" stroke-linecap="round"></path>

我这里用圆角线段讲解,圆角比非圆角要复杂一些。

第一步就要用正则把路径的xy值成对匹配出来:

var line = document.getElementById('***');
var points = line.getAttribute('points') || line.getAttribute('d');
points = points.match(/\d+(\.\d+)*\s+\d+(\.\d+)*/g);

取得的路径将会是个数组,类似:

['43 45', '56 41', '85 67', ...]

路径坐标是关键,接下来一切操作都将围绕坐标数组操作,因为一切的线条切点都参考导线中心点坐标位置来计算的。记得任何一条导线都是有宽度的,也就是我们所说的粗细,无论它再细都有一个stroke-width,这个也是要用到计算中的参考值的。

虽然画的只有两段线条,但要计算出导线每一个点的切点,总共只有两种情况,一种是端点,一种是拐点,所以图示包含了所有情况,无论线条怎么走,怎么拐,它都遵循这两个情况。

我们先看端点,每个点的切点必定与该点所在的下一条导线走向垂直,以端点的中心点画个虚拟坐标做参考。

A1 + A2 = 90

A1 + B2 = 90

那么A2 = B2, 而A1是可以求出来的,导线与水平方向的夹角。也即当前点与下一点连线与水平方向的夹角。用直角三角函数可求出。

var xy1 = nextPoint.split(' '), x1 = xy1[0] * 1, y1 = xy1[1] * 1;
var atan = Math.atan2(y - y1, x - x1);
var deg = 180 - atan * 180 / Math.PI;

正确的角度应该是,以线段的起点为原点,终点为旋转点,与水平线相对于原点的右侧水平线逆时针夹角。
所以直角三角形B的角度B2知道了,又知道斜边长度,也即是导线宽度的一半,那么切点的坐标相对于当前点的xy偏移量可以用直角三角函数可求出。

addX = Math.sin(deg * Math.PI / 180) * halfWidth * -1;
addY = Math.cos(deg * Math.PI / 180) * halfWidth * -1;

切点坐标即:

X = addX + x; Y = addY + y;

如此该点的一侧切点就求出来了,要实现导线的描边,就意味着另一边也要求切点,不用急,先一边一边来。

有人说万一导线反着走斜度换一下,从上到下走,从下到上走,从左往右走,从右往左走,斜向上,斜向下,垂直90度走,水平0度走,不拐弯这些情况还能适用吗?
当然能,无论它怎么走,只要你计算上不乱添加正负符号,它都遵循这个计算方法。如果你去纠正一些正负符号或者纠正夹角,把95度纠正为180-95度,然后再通过其它方法去纠正xy偏移的正负值你就掉坑了,爬都爬不上来,那样的情况层出不穷,举都举不完。

以上我们计算了起点的一侧切点,那终点的切点如何计算,其实跟起点是一样的,只是计算起点要依赖下一个点来求夹角,而终点没有下一个点,如何处理,很简单,都省略计算夹角了直接取前一个点的夹角就ok:

var prePointInfo = getBorderPoint(points, index - 1, halfWidth, true);
if(!nextPoint) {
    //最后一点
    deg = prePointInfo.deg;
 }

getBorderPoint是我们封装的一个函数,具体看完整代码,夹角有了,计算方式跟起点一样。
这样下来我们起点和终点都计算好了,还有起点跟终点之间的拐点。
导线拐点切点算法
拐点比较复杂,每个拐点一侧就有两个切点,那么整个拐点需要计算的是四个切点,我们先来看一侧的切点,两个切点分别是与上一个点斜线的切点和与下一个点斜线的切点,所以要三个点的信息才能确定切点的信息,与下一个点的切点跟起点的计算是一样的。与上一个点的切点,也是同理只是计算的参考点换一下:

if(prePoint && nextPoint) {//拐点
   //求与上一点的切点
   xy_1 = prePoint.split(' ');
   x_1 = xy_1[0];
   y_1 = xy_1[1];
   trend = lineDrection({x:x_1, y:y_1}, {x:x, y:y}, {x:x1,y:y1});
   atan = Math.atan2(y_1 - y, x_1 - x);
   deg_1 = 180 - atan * 180 / Math.PI;
   addX = Math.sin(deg_1 * Math.PI / 180) * halfWidth * -1;
   addY = Math.cos(deg_1 * Math.PI / 180) * halfWidth * -1;
   X_1 = addX + x; Y_1 = addY + y;
 }

trend是这三个点的走向,通过函数lineDrection可以求出,函数实现看完整代码,这个走向是有用的,拐点描边的时候,因为拐点是圆角描边需要使用圆弧连接两个切点,就需要用到这个走向,标志是顺时针还是逆时针。

端点的切点是不分凹侧和凸侧,自创词汇,希望能理解,从上图可以看出,凹侧连接点只有一个,就是当前拐点的切点与上一个点的切点和下一个点的切点的交叉点。而凸侧连接点有两个,直接取与上一个点连线的切点和与下一个点连线的切点。我们最终取到的是描边的连接点,而非切点,如何判断是凹侧和凸侧,用切点与切点之间的连线,计算是否相交即可。有交点就是凹侧没有交点就是凸侧。凹侧连接点取交点,凸侧连接点直接取切点。

var nxtPointInfo = getBorderPoint(points, index + 1, halfWidth, true);
var intersection = getTwoSegmentsIntersection({x:prePointInfo.x, y:prePointInfo.y}, {x:X_1,y:Y_1}, {x:X,y:Y}, {x:nxtPointInfo.x_1,y:nxtPointInfo.y_1});
if(intersection) {//如果有交点
   X = intersection.x; X_1 = intersection.x;
   Y = intersection.y; Y_1 = intersection.y;
}

getTwoSegmentsIntersection函数是封装了的取两条线段交点的函数,具体看完整代码。由以上可以封装一个取导线描边连接点信息的函数:

function getBorderPoint(points,index,halfWidth,self) {
        //这是计算导线某侧的连接点信息,导线另一侧的计算得反着循环,即要把points数组顺序颠倒过来reverse()再用此函数计算,当然计算的路径也是反向的,如果想要路径同向则points顺序不需要变化,只需要halfWidth取负值就ok了
	var point = points[index], x, y;
	if(!point) {
		return;
	}
	if(typeof point === 'string') {
		var xy = point.split(' ');
		x = xy[0] * 1;
		y = xy[1] * 1;
	} else {
		x = point.x * 1;
		y = point.y * 1;
	}
	var nextPoint = points[index + 1], xy1, x1, y1,//下一个点
		prePoint = points[index - 1], xy_1, x_1, y_1,//上一个点
		atan, deg, deg_1, trend,//与下一点的斜率和角度, deg_1与上一点的夹角
		X, Y,//与下一点斜线的切点
		addX, addY,
		X_1, Y_1;//与上一点斜线的切点
	var prePointInfo = getBorderPoint(points, index - 1, halfWidth, true);
	if(nextPoint) {
		xy1 = nextPoint.split(' '); x1 = xy1[0] * 1; y1 = xy1[1] * 1;
		atan = Math.atan2(y - y1, x - x1);
		deg = 180 - atan * 180 / Math.PI;
	} else {//最后一点
		deg = prePointInfo.deg;
	}
	//求与下一点的切点
	addX = Math.sin(deg * Math.PI / 180) * halfWidth * -1;
	addY = Math.cos(deg * Math.PI / 180) * halfWidth * -1;
	X = addX + x; Y = addY + y;
	if(!nextPoint) {//最后一点,与上一点一致
		X_1 = X;
		Y_1 = Y;
	}
	createPoint(X,Y);

	if(prePoint && nextPoint) {//拐点
		//求与上一点的切点
		xy_1 = prePoint.split(' ');
		x_1 = xy_1[0];
		y_1 = xy_1[1];
		trend = lineDrection({x:x_1, y:y_1}, {x:x, y:y}, {x:x1,y:y1});
		atan = Math.atan2(y_1 - y, x_1 - x);
		deg_1 = 180 - atan * 180 / Math.PI;
		addX = Math.sin(deg_1 * Math.PI / 180) * halfWidth * -1;
		addY = Math.cos(deg_1 * Math.PI / 180) * halfWidth * -1;
		X_1 = addX + x; Y_1 = addY + y;
		createPoint(X_1,Y_1);
		createPoint(X,Y);

		if(!self) {
			var nxtPointInfo = getBorderPoint(points, index + 1, halfWidth, true);
			var intersection = getTwoSegmentsIntersection({x:prePointInfo.x, y:prePointInfo.y}, {x:X_1,y:Y_1}, {x:X,y:Y}, {x:nxtPointInfo.x_1,y:nxtPointInfo.y_1});
			if(intersection) {//如果有交点
				X = intersection.x; X_1 = intersection.x;
				Y = intersection.y; Y_1 = intersection.y;
			}
		}
	}
	return {
		x: X,//当前点与下一个点斜线的切点x
		y: Y,//当前点与下一点斜线的切点y
		x_1: X_1,//当前点与上一点斜线的切点x
		y_1: Y_1,//当前点与下一点斜线的切点x
		deg: deg,//当前点与下一点斜线的角度
		arc: intersection ? null : trend//返回该点是否是圆弧点以及圆弧的方向
	}
}

连接点这些都计算出来了,有了点之后描边当然要创建线条,只需要用线条把这些点连接起来就行了,创建连接线的函数:

function getBorderPath(points, halfWidth) {
	var d;
	function getSidePath(points) {
		var pointInfo, path = 'M', firstPoint, lastPoint;
		var nextPoint, prePoint;
		points.forEach(function(point, index) {
			nextPoint = points[index + 1];
			prePoint = points[index - 1];
			pointInfo = getBorderPoint(points, index, halfWidth);
			if(index === 0) {
				firstPoint = pointInfo;
			}
			if(index === points.length - 1) {
				lastPoint = pointInfo;
			}
			var str = '';
			if(prePoint && nextPoint) {
				if(pointInfo.arc) {//凸点要生成圆弧
					str = ' ' + pointInfo.x_1 + ' ' + pointInfo.y_1 + ' A' + halfWidth + ',' + halfWidth + (pointInfo.arc === '顺时针' ? ' 0 0 1 ' : ' 0 0 0 ') + pointInfo.x + ' ' + pointInfo.y;
				} else {
					str = ' ' + pointInfo.x + ' ' + pointInfo.y;
				}
			} else {
				str = ' ' + pointInfo.x + ' ' + pointInfo.y;
			}
			path += str + (index === points.length - 1 ? '' : ' L');
		});
		return {
			d: path,
			first: firstPoint,
			last: lastPoint
		}
	}
	//计算导线的某侧的描边路径
	var border1 =  getSidePath(points);
	//计算导线的另一边描边路径
	var border2 =  getSidePath(points.reverse());
	d = border1.d + ' A' + halfWidth + ',' + halfWidth + ' 0 0 1 ' + border2.first.x + ' ' + border2.first.y + ' ' + border2.d + ' A' + halfWidth + ',' + halfWidth + ' 0 0 1 ' + border1.first.x + ' ' + border1.first.y;
	return d;
}

此函数也是我们最终要调用的函数getBorderPath(points, halfWidth),points是导线的路径坐标数组,halfWidth顾名思义是导线宽度的一半。

var path = getBorderPath(points, line.getAttribute('stroke-width') / 2);
var borderLine = createSVGElement('path', {
	class: 'line',
	d: path,
	"stroke-width": 1,
	stroke: '#000000',
	fill: 'none'
});
document.querySelector('svg').appendChild(borderLine);

我们在计算过程中需要用到的一些附加函数:

判断两条线段是否相交以及计算交点:

function getTwoSegmentsIntersection(a, b, c, d){
    // 如果分母为0 则平行或共线, 不相交
    var denominator = (b.y - a.y) * (d.x - c.x) - (a.x - b.x) * (c.y - d.y);
    if (denominator == 0) {
        return false;
    }

    // 线段所在直线的交点坐标 (x , y)
    var x = ((b.x - a.x) * (d.x - c.x) * (c.y - a.y) + (b.y - a.y) * (d.x - c.x) * a.x - (d.y - c.y) * (b.x - a.x) * c.x) / denominator ;
    var y = -((b.y - a.y) * (d.y - c.y) * (c.x - a.x) + (b.x - a.x) * (d.y - c.y) * a.y - (d.x - c.x) * (b.y - a.y) * c.y) / denominator;

    //2 判断交点是否在两条线段上/
    if (
        // 交点在线段1上
        (x - a.x) * (x - b.x) <= 0 && (y - a.y) * (y - b.y) <= 0
        // 且交点在线段2上
        || (x - c.x) * (x - d.x) <= 0 && (y - c.y) * (y - d.y) <= 0
        ){
        // 返回交点p
        return {
            x: x,
            y: y
        }
    }
    //否则不相交
    return false
}

创建调试点用于观察我们的计算是否正确:

function createPoint(x, y) {
	if(!x | !y) {
		return;
	}
	var dot = createSVGElement('circle', {
		cx: x,
		cy: y,
		r: 2,
		class: 'dot',
		fill: 'blue'
	});
	document.querySelector('svg').appendChild(dot);
}

创建svg元素:

function createSVGElement(tag, attributes) {
	var elem = document.createElementNS('http://www.w3.org/2000/svg', tag);
	if(attributes) {
		for(var i in attributes) {
			elem.setAttributeNS(null, i, attributes[i]);
		}
	}
	return elem;
}

查看完整案例和完整代码(打开该页面右键查看源码):请访问:http://resource.mizuiren.com/getborder.html

转载请保留链接:JS给导线描边-非样式属性 | 出处:秋叶个人博客| 本文链接地址:http://www.mizuiren.com/495.html
目录: 前端编程 | 标签: , | 4327次阅读