Web编程技术营地
研究、演示、创新

Paths

撰写时间:2025-04-20

修订时间:2026-01-28

pathgeometry properties几何属性)只有一个d属性,其值中又含有各个非常灵活的子命令,可绘制直线及丰富多彩的圆滑图形。

借用正则表达式,下面列出path的所有子命令:

/[MZLHVCSQTA]/i

在需要分别应对各个子命令的场合,可借助上面的代码来检测是否遗漏了特定的子命令。

d属性值的问题

SVG 2.0规范中,pathd属性值可为none(初始值)或字符串。当其值为none时,各浏览器支持不一致,ChromeSafari将抛出解析异常。因此,如果需要将该属性值设置为一个空值时,最稳妥的作法是将其值设置为空字符串。

svg { border: 1px solid #444; }

绘制直线

svg { border: 1px solid #444; }

M意为move,用以移动当前光标位置。L命令意为line to,指定一个点的位置后,将在当前光标位置与该点绘制一条直线。Z命令用以闭合图形。

因为可能需要多次调用L命令,因此多条连续的L命令可合并为1L命令,其后跟着一系列的点的坐标。

svg { border: 1px solid #444; }

上面,原来的L 100 400 L 600 400合并为L 100 400, 600 400

多数命令,如上面的M命令与L命令,有大小写之分,大写的命令使用绝对坐标值,小写的命令使用相对坐标值。

svg { border: 1px solid #444; }

使用小写命令l,用以指定偏移值,可清楚地看到,三角形的高为300px,底边为500px

L命令后面的数据必须至少两个以上的分别代表(x, y)坐标轴的数值,不够简练。我们可以使用Hh命令来绘制水平直线,使用Vv命令来绘制垂直直线。这两个命令,因为已经明确了方向,因此只需跟有一个对应轴向上的偏移数值就行了。

svg { border: 1px solid #444; }

可见,使用小写的hv命令,可让我们十分便捷地完成绘图任务。它具有以下优点:

  1. 直观
  2. 便于添加众多顶点
  3. 方便修改

绘制圆弧

使用Aa命令来绘制一条圆弧。

综述

绘制圆弧的含义是,以当前点作为起点,绘制一条弧线到指定的终点。

当绘制一个圆或椭圆时,最直观的方法是根据圆心及半径来绘制。而Aa命令不是,相反,它会根据起点及终点,及其他相应信息,帮我们自动计算出圆心的位置,从而确定圆弧。但由于我们不能直观地推算出圆心的准确位置,因此,对程序员来说,绘制圆弧的命令不够直观。

下面这段代码,根据A命令的参数,反求出所对应的ellipse参数,然后以灰底虚线绘制一个未经旋转的椭圆作为背景,以在视觉上比较两种图像的异同。

let startX = 200; let startY = 400; let endX = 600; let endY = 400; let rx = 20; let ry = 10; let zRotateDegree = 0; let largeArcFlag = 1; let sweepFlag = 1; let markerR = 10; let ellipseParams = arcToEllipseParams( startX, startY, endX, endY, rx, ry, zRotateDegree, largeArcFlag, sweepFlag ); console.log(ellipseParams); let svgStr = ` <ellipse cx="${ellipseParams.cx}" cy="${ellipseParams.cy}" rx="${ellipseParams.actualRx}" ry="${ellipseParams.actualRy}" fill="#555" stroke="white" stroke-dasharray="10" /> <line x1="${ellipseParams.cx}" y1="${ellipseParams.cy}" x2="${ellipseParams.cx + ellipseParams.actualRx}" y2="${ellipseParams.cy}" stroke="gray" stroke-width="1" /> <line x1="${ellipseParams.cx}" y1="${ellipseParams.cy}" x2="${ellipseParams.cx}" y2="${ellipseParams.cy - ellipseParams.actualRy}" stroke="gray" stroke-width="1" /> <path d=" M ${startX} ${startY} A ${rx} ${ry}, ${zRotateDegree}, ${largeArcFlag}, ${sweepFlag} ${endX} ${endY} " fill="none" stroke="orange" /> <circle cx="${startX}" cy="${startY}" r="${markerR}" fill="red" stroke="white"; /> <circle cx="${endX}" cy="${endY}" r="${markerR}" fill="green" stroke="white"; /> `; document.body.innerHTML = svgStr;
function arcToEllipseParams(x1, y1, x2, y2, rx, ry, phi, fa, fs) { const radPhi = phi * Math.PI / 180; const cosPhi = Math.cos(radPhi); const sinPhi = Math.sin(radPhi); rx = Math.abs(rx); ry = Math.abs(ry); const x1p = cosPhi * (x1 - x2) / 2 + sinPhi * (y1 - y2) / 2; const y1p = -sinPhi * (x1 - x2) / 2 + cosPhi * (y1 - y2) / 2; const rx2 = rx * rx; const ry2 = ry * ry; const x1p2 = x1p * x1p; const y1p2 = y1p * y1p; const lambda = (x1p2 / rx2) + (y1p2 / ry2); let actualRx = rx; let actualRy = ry; if (lambda > 1) { const scale = Math.sqrt(lambda); actualRx = rx * scale; actualRy = ry * scale; } const actualRx2 = actualRx * actualRx; const actualRy2 = actualRy * actualRy; const t1 = (actualRx2 * y1p2) + (actualRy2 * x1p2); let t2 = Math.sqrt(Math.max(0, (actualRx2 * actualRy2 - t1) / t1)); if (fa === fs) { t2 = -t2; } const cxp = t2 * actualRx * y1p / actualRy; const cyp = -t2 * actualRy * x1p / actualRx; const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2; const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2; return { cx: cx, cy: cy, actualRx: actualRx, actualRy: actualRy, phi: phi, startAngle: 0, endAngle: 0 }; }

红点是A命令的起点,绿点是A命令的终点,橙色是A命令所绘制的圆弧。

运行后,橙色圆弧正好压在椭圆的上半部。

逐个修改相应的参数,观察两个图形的变化,从而理解A命令各参数的含义。

例如,若只将zRotateDegree的值设置为25,则可看到圆弧与椭圆不再吻合,圆弧围绕Z轴进行了旋转,且椭圆的大小也发生了改变。这是因为圆弧起点与终点的位置未改变,为满足圆弧的其他参数要求,只能将椭圆的两个轴向上的半径按比例增大,导致相对应的椭圆的尺寸增大。

SVG规范中,变量zRotateDegree对应于参数x-axis-rotation,其含义应是指X轴围绕Z轴旋转,但实际上,Y轴也同时围绕Z轴进行了旋转,因此更确切的说法是,XY平面围绕Z轴进行了旋转,故此这里使用zRotateDegree作为变量名。

变量rxrx分别为圆弧的X轴半径及Y轴半径,但在一开始运行时,在Console面板中可看到,所对应的椭圆的两个半径分别为100px50px,比原值20px10px均要大,这是因为SVG规范规定:如果圆弧的起点和终点距离太远,无法用所指定的rxry连接,SVG引擎应自动按比例缩放半径以满足几何约束。

修改变量largeArcFlagsweepFlag的不同组合,观察圆弧的朝向。再结合修改终点的坐标值,观察圆弧的朝向。可以看出,参数largeArcFlag代表是否绘制长圆弧的含义很明确,但sweepFlag并非指我们原本认为的按顺时针还是按逆时针的方向的意思。该参数的具体含义,下面一节详细谈到。

通过上面的代码可以看出,SVG绘制圆弧的算法很复杂,只对数学家友好,但对程序员并不友好。

更多细节

由于需要以声明式的方式来绘制一个相对较为复杂的圆弧,SVG中绘制圆弧的算法比较独特。

arc

上图中,我们需要从点Arc start绘制一条圆弧至点Arc end

可以看出,这两个点为两个形状相同的椭圆的交点,或者说,这两个点来自两个椭圆。而若给定椭圆的长半径与短半径,从点Arc start绘制一条圆弧至点Arc end,共有4种绘制方法,根据这4种方法所绘制出来的圆弧在图中均已用红色标出。

4种结果依赖于需要绘制长圆弧还是短圆弧,以及是按逆时针还是按顺时针的方向来绘制。

假设我们需要绘制短圆弧。则图中第1行的第2列及第3列均为符合条件的短圆弧。第2列为按逆时针方向所绘制的短圆弧,它取自右上角这个椭圆的一小段圆弧。第3列为按顺时针方向所绘制的短圆弧,它取自左下角这个椭圆的一小段圆弧。

在这里,由于起点与终点已经确定下来,而从起点按逆时针方向绘制圆弧至终点,以及从起点按顺时针方向绘制圆弧至终点,将导致绘制出完全不同的两条圆弧。

现在,假设我们需要绘制长圆弧。则图中第2行的第2列及第3列均为符合条件的长圆弧。第2列为按逆时针方向所绘制的长圆弧,它取自左下角这个椭圆的一大段圆弧。第3列为按顺时针方向所绘制的长圆弧,它取自右上角这个椭圆的一大段圆弧。

换句话说,给定圆弧的起点与终点、长半径与短半径,这段圆弧可来自两个椭圆的相应部位。随着我们再接着确定长短圆弧及绘制方向的具体需求,这段圆弧也就随之确定下来。

我们注意到,尽管圆弧来自两个椭圆,但我们无需理会这两个椭圆如何绘制、它们的圆心各在哪里,以及所绘制的圆弧如何取自哪一个椭圆的哪一部位。所有这些,SVG的内部算法会自动帮我们确定。

可见,确定了圆弧的起点与终点,长半径与短半径,长弧或短弧,顺时针还是逆时针这几种关键因素后,这条圆弧也随之确定下来了。这就是SVG中使用A命令或a命令绘制圆弧的参数的由来,其原型如下:

voidA | a
  • floatrx, ry
  • numberx-axis-rotation
  • booleanlarge-arc-flag
  • booleansweep-flag
  • floatx, y

Aa命令从当前点的位置,绘制一条X轴半径及Y轴半径分别为rxry的圆弧至(x, y)。

参数x-axis-rotation是椭圆的旋转值,以角度制 (degree) 为单位。

参数large-arc-flag指定是否需要绘制长圆弧。当值为0时,绘制短圆弧;当值为1时,绘制长圆弧。

参数sweep-flag指定是按负角度方向 (negative-angle direction) 还是按正角度方向 (positive-angle direction) 来绘制。当值为0时,按负角度方向绘制;当值为1时,按正角度方向绘制。

所谓正负方向,实际上是指椭代表椭圆公式的函数中的参数theta的变化方向:

x = cx + rx * cos(theta); y = cy + ry * sin(theta);

当为负角度时,参数theta的值将从起点的角度减小至终点的角度;当为正角度时,参数theta的值将从起点的角度增大至终点的角度。

实际上,sweep-flag这个参数完全从数学角度来考量,但对于程序员来讲,很不直观,它不能直接地推测出圆弧的方向是顺时针还是逆时针。它需要结合参数large-arc-flag才能确定这一点。

绘制圆弧

下面是绘制圆弧的代码:

svg { border: 1px solid #444; }

上面的代码,先从画布的中心(450, 300)绘制一条直线到(600, 300),则当前位置位于(600, 300)A命令从该位置绘制了一条圆弧至(450, 150),圆弧的长短半径各为200150。图中起点为红色,终点为绿色。

分别将代码中的large-arc-flag参数及sweep-flag参数的值改为1,尝试不同的组合,即可看到不同的效果。检查运行结果是否与上面图片中的效果一致。

绘制完整的圆或椭圆的问题

绘制圆弧时,注意圆弧的起点与终点不能相同。看下面代码。

我们准备从视口的中心点(400, 300),逆时针绘制一条长圆弧至它自己(400, 300),希望绘制一个完整的圆弧。但上面代码圆弧的终点先临时设置为(400, 299),则圆弧出现。

如果将终点坐标改为与起点坐标完全一致的(400, 300),圆弧不见了!

原因是,起点和终点相同的圆弧可以对应无限多个可能的圆弧(整圆),SVG渲染器无法确定具体要绘制哪一个。因此,SVG规范明确规定,当起点和终点相同时,圆弧命令应该被当作没有圆弧来处理。

解决方法,使用circleellipse来绘制一个完全的圆或椭圆。如果必须使用path来绘制一个完整的圆或椭圆,可将起点的坐标改为一个差距很小的浮点值,如,将起点(400, 300)改为终点(400, 300.0001)

另一种解决方法是分别绘制两个半径相等、但方向相反的半圆,从而合成一个完整的圆。下面先使用两个不同颜色的path来绘制两个半圆。

new SVGGrids('my-svg', 100, true);

先绘制下面的半圆,再绘制上面的半圆。下面代码合成一个path

new SVGGrids('my-svg', 100, true);

使用相对格式的a命令时,参数中只有x-axis-rotationx, y才使用偏移值,而其它参数,如rx, ry, large-arc-flag, sweep-flag等, 均不会受偏移值影响。

绘制曲线

发明了Bézier曲线绘制方法的科学家真是太给力了。对于任意的一条线段,仅额外加入2个、甚至仅加入1个控制点,直线立即转变为千变万化的、姿态曼妙的弧线。计算机绘图从未如此轻松、如此优雅。

Cubic Bézier曲线

C, c命令

C命令是一个三阶的贝塞尔曲线,使用两个端点及两个控制点来绘制曲线。

svg { border: 1px solid #444; width: 100vw; height: 200px; & path { fill: none; stroke: white; } }

图像虽然绘制出来了,但很难看出4个端点与所生成图像的内在关系。下面通过拖动节点的方式来交互式地生成图像。

let selectedElement = null; let dataWrapper = { ptStart: {x:130, y:188}, cp1: {x:168, y:44}, cp2: {x:308, y:107}, ptEnd: {x:209, y:158} }; init(); function init() { let svg = document.querySelector('svg'); svg.style.cursor = 'default'; svg.addEventListener('mousemove', onSVGMouseMove); svg.addEventListener('mouseup', onSVGMouseUp); let circles = document.querySelectorAll('svg > circle'); circles.forEach(circle => { circle.addEventListener('mousedown', onCircleMouseDown); }); updateView(); } function updateView() { let entries = Object.entries(dataWrapper); for (let [id, point] of entries) { let element = document.querySelector(`#${id}`); element.setAttributeNS(null, 'cx', point.x); element.setAttributeNS(null, 'cy', point.y); } let path = document.querySelector(`#bezier-path`); const {ptStart, cp1, cp2, ptEnd} = dataWrapper; path.setAttributeNS(null, 'd', `M ${ptStart.x},${ptStart.y} C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ptEnd.x},${ptEnd.y}`); let line1 = document.querySelector(`#line1`); line1.setAttributeNS(null, 'x1', ptStart.x); line1.setAttributeNS(null, 'y1', ptStart.y); line1.setAttributeNS(null, 'x2', cp1.x); line1.setAttributeNS(null, 'y2', cp1.y); let line2 = document.querySelector(`#line2`); line2.setAttributeNS(null, 'x1', ptEnd.x); line2.setAttributeNS(null, 'y1', ptEnd.y); line2.setAttributeNS(null, 'x2', cp2.x); line2.setAttributeNS(null, 'y2', cp2.y); let output = document.querySelector('#output'); output.innerHTML = `d = "M ${ptStart.x},${ptStart.y} C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ptEnd.x},${ptEnd.y}"`; } function onSVGMouseMove(evt) { if (!selectedElement) { return; } dataWrapper[selectedElement.id].x = evt.offsetX; dataWrapper[selectedElement.id].y = evt.offsetY; updateView(); } function onSVGMouseUp(evt) { selectedElement = null; } function onCircleMouseDown(evt) { selectedElement = evt.target; }

svg { display: block; width: 100vw; height: 250px; border: 1px solid #444; & circle { fill: teal; stroke: #CCC; } #cp1, #cp2 { fill: #444; stroke: gray; } & path { fill: none; stroke: white; } & line { stroke: #FFF3; } }

拖动4个节点,曲线随之而变化,下面的文本则输出其d属性值。

代码使用了MVC模式,dataWrapper为数据模型,修改该模型的值,可改变曲线的初始状态。

可以看出,由于SVG内置支持其各个元素的事件响应,因此在SVG中编写drag and drop事件的代码非常轻松。

利用这个小工具,拖出我们想要的曲线后,复制其d属性值,再粘帖进其他的SVG代码,可方便地进行后续编辑。

这个功能可是PhotoShop, C4D等大腕软件的镇店之宝,而我们却仅用不到百行的代码就轻松地实现了此超酷的功能。

S, s命令

S命令属于一个连续绘制Cubic Bézier曲线的命令,在调用它之前,通常已有一个绘制Cubic Bézier曲线的命令。

svg { border: 1px solid #444; width: 100vw; height: 250px; & path { fill: none; stroke: white; } }

上面代码,有两个绘制Cubic Bézier曲线的命令,第一个是C命令,第二个是S命令。

S命令的参数较简单,只有一个终止端点的控制节点(303, 216),以及一个终止端点(312, 120)

实际上,S命令照样使用两个端点及两个控制点,但起始端点取自于当前点(204, 119)(也即S命令之前的C命令中的终止端点),起始端点的控制节点取自于上条曲线的第二个控制节点(260, 47)相对于当前点(204, 119)的镜像。

理解起来很费劲,但在交互式的图形应用中,这些概念立即变得清晰起来。

let selectedElement = null; let dataWrapper = { ptStart: {x:106, y:118}, cp1: {x:39, y:68}, cp2: {x:260, y:47}, ptEnd: {x:204, y:119}, s_cp1: {x: 0, y: 0}, s_cp2: {x: 303, y:216}, s_ptEnd: {x: 312, y:120} }; init(); function init() { let svg = document.querySelector('svg'); svg.style.cursor = 'default'; svg.addEventListener('mousemove', onSVGMouseMove); svg.addEventListener('mouseup', onSVGMouseUp); let circles = document.querySelectorAll('svg > circle'); circles.forEach(circle => { circle.addEventListener('mousedown', onCircleMouseDown); }); updateView(); } function updateView() { let offsetX = -(dataWrapper.cp2.x - dataWrapper.ptEnd.x); let offsetY = -(dataWrapper.cp2.y - dataWrapper.ptEnd.y); dataWrapper.s_cp1 = {x: dataWrapper.ptEnd.x + offsetX, y: dataWrapper.ptEnd.y + offsetY}; let entries = Object.entries(dataWrapper); for (let [id, point] of entries) { let element = document.querySelector(`#${id}`); element.setAttributeNS(null, 'cx', point.x); element.setAttributeNS(null, 'cy', point.y); } let path = document.querySelector(`#bezier-path`); const {ptStart, cp1, cp2, ptEnd, s_cp1, s_cp2, s_ptEnd} = dataWrapper; path.setAttributeNS(null, 'd', `M ${ptStart.x},${ptStart.y} C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ptEnd.x},${ptEnd.y} S ${s_cp2.x},${s_cp2.y} ${s_ptEnd.x},${s_ptEnd.y}`); let line1 = document.querySelector(`#line1`); line1.setAttributeNS(null, 'x1', ptStart.x); line1.setAttributeNS(null, 'y1', ptStart.y); line1.setAttributeNS(null, 'x2', cp1.x); line1.setAttributeNS(null, 'y2', cp1.y); let line2 = document.querySelector(`#line2`); line2.setAttributeNS(null, 'x1', ptEnd.x); line2.setAttributeNS(null, 'y1', ptEnd.y); line2.setAttributeNS(null, 'x2', cp2.x); line2.setAttributeNS(null, 'y2', cp2.y); let line3 = document.querySelector(`#line3`); line3.setAttributeNS(null, 'x1', ptEnd.x); line3.setAttributeNS(null, 'y1', ptEnd.y); line3.setAttributeNS(null, 'x2', s_cp1.x); line3.setAttributeNS(null, 'y2', s_cp1.y); let line4 = document.querySelector(`#line4`); line4.setAttributeNS(null, 'x1', s_ptEnd.x); line4.setAttributeNS(null, 'y1', s_ptEnd.y); line4.setAttributeNS(null, 'x2', s_cp2.x); line4.setAttributeNS(null, 'y2', s_cp2.y); let output = document.querySelector('#output'); output.innerHTML = `d = "M ${ptStart.x},${ptStart.y} C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ptEnd.x},${ptEnd.y} S ${s_cp2.x},${s_cp2.y} ${s_ptEnd.x},${s_ptEnd.y}"`; } function onSVGMouseMove(evt) { if (!selectedElement) { return; } dataWrapper[selectedElement.id].x = evt.offsetX; dataWrapper[selectedElement.id].y = evt.offsetY; updateView(); } function onSVGMouseUp(evt) { selectedElement = null; } function onCircleMouseDown(evt) { selectedElement = evt.target; }

svg { display: block; width: 100vw; height: 250px; border: 1px solid #444; & circle { fill: teal; stroke: #CCC; } #cp2 { fill: hsl(230, 50%, 50%); stroke: gray; } #ptEnd { fill: orange; } #cp1, #s_cp2 { fill: #444; stroke: gray; } #s_cp1 { fill: #222; stroke: gray; } & path { fill: none; stroke: white; } & line { stroke: #FFF3; } }

橙色的点,既是第一条C命令的终止端点,也成为第二条S命令的起始端点。

而黑色虚框的圆点,是S命令的起始端点的控制节点,它是C命令的终止控制节点(蓝色)相对于C命令终止端点(橙色)的镜像。由于它是SVG引擎自动求出的,因此在交互界面中不能拖动。相反,拖动蓝色控制节点,则可看到黑色控制节点的即时反应。

没有平滑曲线时的区别

let selectedElement = null; let dataWrapper = { ptStart: {x:106, y:118}, cp1: {x:39, y:68}, cp2: {x:260, y:47}, ptEnd: {x:204, y:119}, s_cp1: {x: 215, y: 203}, s_cp2: {x: 303, y:216}, s_ptEnd: {x: 312, y:120} }; init(); function init() { let svg = document.querySelector('svg'); svg.style.cursor = 'default'; svg.addEventListener('mousemove', onSVGMouseMove); svg.addEventListener('mouseup', onSVGMouseUp); let circles = document.querySelectorAll('svg > circle'); circles.forEach(circle => { circle.addEventListener('mousedown', onCircleMouseDown); }); updateView(); } function updateView() { let entries = Object.entries(dataWrapper); for (let [id, point] of entries) { let element = document.querySelector(`#${id}`); element.setAttributeNS(null, 'cx', point.x); element.setAttributeNS(null, 'cy', point.y); } let path = document.querySelector(`#bezier-path`); const {ptStart, cp1, cp2, ptEnd, s_cp1, s_cp2, s_ptEnd} = dataWrapper; path.setAttributeNS(null, 'd', `M ${ptStart.x},${ptStart.y} C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ptEnd.x},${ptEnd.y} C ${s_cp1.x},${s_cp1.y} ${s_cp2.x},${s_cp2.y} ${s_ptEnd.x},${s_ptEnd.y}`); let line1 = document.querySelector(`#line1`); line1.setAttributeNS(null, 'x1', ptStart.x); line1.setAttributeNS(null, 'y1', ptStart.y); line1.setAttributeNS(null, 'x2', cp1.x); line1.setAttributeNS(null, 'y2', cp1.y); let line2 = document.querySelector(`#line2`); line2.setAttributeNS(null, 'x1', ptEnd.x); line2.setAttributeNS(null, 'y1', ptEnd.y); line2.setAttributeNS(null, 'x2', cp2.x); line2.setAttributeNS(null, 'y2', cp2.y); let line3 = document.querySelector(`#line3`); line3.setAttributeNS(null, 'x1', ptEnd.x); line3.setAttributeNS(null, 'y1', ptEnd.y); line3.setAttributeNS(null, 'x2', s_cp1.x); line3.setAttributeNS(null, 'y2', s_cp1.y); let line4 = document.querySelector(`#line4`); line4.setAttributeNS(null, 'x1', s_ptEnd.x); line4.setAttributeNS(null, 'y1', s_ptEnd.y); line4.setAttributeNS(null, 'x2', s_cp2.x); line4.setAttributeNS(null, 'y2', s_cp2.y); let output = document.querySelector('#output'); output.innerHTML = `d = "M ${ptStart.x},${ptStart.y} C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ptEnd.x},${ptEnd.y} C ${s_cp1.x},${s_cp1.y} ${s_cp2.x},${s_cp2.y} ${s_ptEnd.x},${s_ptEnd.y}"`; } function onSVGMouseMove(evt) { if (!selectedElement) { return; } dataWrapper[selectedElement.id].x = evt.offsetX; dataWrapper[selectedElement.id].y = evt.offsetY; updateView(); } function onSVGMouseUp(evt) { selectedElement = null; } function onCircleMouseDown(evt) { selectedElement = evt.target; }

svg { display: block; width: 100vw; height: 250px; border: 1px solid #444; & circle { fill: teal; stroke: #CCC; } #cp2 { fill: hsl(230, 50%, 50%); stroke: gray; } #ptEnd { fill: orange; } #cp1, #s_cp2 { fill: #444; stroke: gray; } #s_cp1 { fill: #222; stroke: gray; } & path { fill: none; stroke: white; } & line { stroke: #FFF3; } }

可以看出,当第2个节点不使用S命令,而使用C命令时,则可以独立地控制2个控制点;移动其中一个控制点,另一条曲线不受任何影响。

两个控制点与黄色端点分别组成了两条直线。当这两条直线的夹角为180°时,曲线的弧度过渡得最为平滑。因此使用S命令所绘制出来的曲线,也称为平滑曲线smooth curve)。

当S命令前面无C或S命令时

S命令的前面没有C, c, S, 或s命令时, 则将S命令前面的当前点用作起始端点,S命令中唯一的控制点用以同时控制起始端点及终止端点。此时的效果,等同于二阶的Q命令(详见下节)。

let selectedElement = null; let dataWrapper = { ptStart: {x:106, y:118}, s_cp2: {x: 303, y:216}, s_ptEnd: {x: 312, y:120} }; init(); function init() { let svg = document.querySelector('svg'); svg.style.cursor = 'default'; svg.addEventListener('mousemove', onSVGMouseMove); svg.addEventListener('mouseup', onSVGMouseUp); let circles = document.querySelectorAll('svg > circle'); circles.forEach(circle => { circle.addEventListener('mousedown', onCircleMouseDown); }); updateView(); } function updateView() { let entries = Object.entries(dataWrapper); for (let [id, point] of entries) { let element = document.querySelector(`#${id}`); element.setAttributeNS(null, 'cx', point.x); element.setAttributeNS(null, 'cy', point.y); } let path = document.querySelector(`#bezier-path`); const {ptStart, s_cp2, s_ptEnd} = dataWrapper; path.setAttributeNS(null, 'd', `M ${ptStart.x},${ptStart.y} S ${s_cp2.x},${s_cp2.y} ${s_ptEnd.x},${s_ptEnd.y}`); let line1 = document.querySelector(`#line1`); line1.setAttributeNS(null, 'x1', ptStart.x); line1.setAttributeNS(null, 'y1', ptStart.y); line1.setAttributeNS(null, 'x2', s_cp2.x); line1.setAttributeNS(null, 'y2', s_cp2.y); let line2 = document.querySelector(`#line2`); line2.setAttributeNS(null, 'x1', s_ptEnd.x); line2.setAttributeNS(null, 'y1', s_ptEnd.y); line2.setAttributeNS(null, 'x2', s_cp2.x); line2.setAttributeNS(null, 'y2', s_cp2.y); let output = document.querySelector('#output'); output.innerHTML = `d = "M ${ptStart.x},${ptStart.y} S ${s_cp2.x},${s_cp2.y} ${s_ptEnd.x},${s_ptEnd.y}"`; } function onSVGMouseMove(evt) { if (!selectedElement) { return; } dataWrapper[selectedElement.id].x = evt.offsetX; dataWrapper[selectedElement.id].y = evt.offsetY; updateView(); } function onSVGMouseUp(evt) { selectedElement = null; } function onCircleMouseDown(evt) { selectedElement = evt.target; }

svg { display: block; width: 100vw; height: 250px; border: 1px solid #444; & circle { fill: teal; stroke: #CCC; } #s_cp2 { fill: #444; stroke: gray; } & path { fill: none; stroke: white; } & line { stroke: #FFF3; stroke-dasharray: 10, 5; } }

注,SVG规范将此问题表述为:在此情况下照样存在两个控制点,但第一个控制点移到当前点的位置,即两个点塌陷为一个点,故看不到第一个控制点到当前点的连线。

两种表述,视觉效果均完全一样。

使用c命令绘制半圆

除了使用a命令绘制一个半圆之外,也可使用c命令来逼近一个半圆圆弧。

下面代码,使用c命令来绘制一个从(50, 100)(150, 100)的上半圆弧。

从视觉上看,红色圆弧精准地压住了灰色圆圈的上半圆弧。

c命令的两个控制点,其X轴坐标值分别对应于圆弧的起始点与终点。难点在于如何根据半径r来求出它们的Y轴坐标值。

对于任意半径r,使用单个三次贝塞尔曲线来逼近上半圆的公式是:

c 0,-(4/3 * r) (2*r),-(4/3 * r) (2*r),0

上面r的值为50,则控制点Y轴的偏移值为:

yOffset = -(4/3 * r) = -(1.3333 * 50) = -66.667

Quadratic Bézier曲线

Q命令

Q命令是一个二阶的贝塞尔曲线,使用两个端点及一个控制点来绘制曲线。

svg { border: 1px solid #444; height: 250px; & path { fill: none; stroke: white; } }

交互。

let selectedElement = null; let dataWrapper = { ptStart: { x: 109, y: 198 }, cp: { x: 138, y: 42 }, ptEnd: { x: 349, y: 100 } }; init(); function init() { let svg = document.querySelector('svg'); svg.style.cursor = 'default'; svg.addEventListener('mousemove', onSVGMouseMove); svg.addEventListener('mouseup', onSVGMouseUp); let circles = document.querySelectorAll('svg > circle'); circles.forEach(circle => { circle.addEventListener('mousedown', onCircleMouseDown); }); updateView(); } function updateView() { let entries = Object.entries(dataWrapper); for (let [id, point] of entries) { let element = document.querySelector(`#${id}`); element.setAttributeNS(null, 'cx', point.x); element.setAttributeNS(null, 'cy', point.y); } let path = document.querySelector(`#bezier-path`); const {ptStart, cp, ptEnd} = dataWrapper; path.setAttributeNS(null, 'd', `M ${ptStart.x},${ptStart.y} Q ${cp.x},${cp.y} ${ptEnd.x},${ptEnd.y}`); let line1 = document.querySelector(`#line1`); line1.setAttributeNS(null, 'x1', ptStart.x); line1.setAttributeNS(null, 'y1', ptStart.y); line1.setAttributeNS(null, 'x2', cp.x); line1.setAttributeNS(null, 'y2', cp.y); let line2 = document.querySelector(`#line2`); line2.setAttributeNS(null, 'x1', ptEnd.x); line2.setAttributeNS(null, 'y1', ptEnd.y); line2.setAttributeNS(null, 'x2', cp.x); line2.setAttributeNS(null, 'y2', cp.y); let output = document.querySelector('#output'); output.innerHTML = `d = "M ${ptStart.x},${ptStart.y} Q ${cp.x},${cp.y} ${ptEnd.x},${ptEnd.y}"`; } function onSVGMouseMove(evt) { if (!selectedElement) { return; } dataWrapper[selectedElement.id].x = evt.offsetX; dataWrapper[selectedElement.id].y = evt.offsetY; updateView(); } function onSVGMouseUp(evt) { selectedElement = null; } function onCircleMouseDown(evt) { selectedElement = evt.target; }

svg { display: block; width: 100vw; height: 250px; border: 1px solid #444; & circle { fill: teal; stroke: #CCC; } #cp { fill: #444; stroke: gray; } & path { fill: none; stroke: white; } & line { stroke: #FFF3; } }

T命令

T命令的控制点为前面的QT命令的控制点相对于当前点的镜像。

svg { border: 1px solid #444; width: 100vw; height: 250px; & path { fill: none; stroke: white; } }

交互。

let selectedElement = null; let dataWrapper = { ptStart: {x:108, y:127}, cp: {x:130, y:27}, ptEnd: {x:183, y:120}, t_cp: {x: 0, y: 0}, t_ptEnd: {x: 394, y:75} }; init(); function init() { let svg = document.querySelector('svg'); svg.style.cursor = 'default'; svg.addEventListener('mousemove', onSVGMouseMove); svg.addEventListener('mouseup', onSVGMouseUp); let circles = document.querySelectorAll('svg > circle'); circles.forEach(circle => { circle.addEventListener('mousedown', onCircleMouseDown); }); updateView(); } function updateView() { let offsetX = -(dataWrapper.cp.x - dataWrapper.ptEnd.x); let offsetY = -(dataWrapper.cp.y - dataWrapper.ptEnd.y); dataWrapper.t_cp = {x: dataWrapper.ptEnd.x + offsetX, y: dataWrapper.ptEnd.y + offsetY}; let entries = Object.entries(dataWrapper); for (let [id, point] of entries) { let element = document.querySelector(`#${id}`); element.setAttributeNS(null, 'cx', point.x); element.setAttributeNS(null, 'cy', point.y); } let path = document.querySelector(`#bezier-path`); const {ptStart, cp, ptEnd, t_cp, t_ptEnd} = dataWrapper; path.setAttributeNS(null, 'd', `M ${ptStart.x},${ptStart.y} Q ${cp.x},${cp.y} ${ptEnd.x},${ptEnd.y} T ${t_ptEnd.x},${t_ptEnd.y}`); let line1 = document.querySelector(`#line1`); line1.setAttributeNS(null, 'x1', ptStart.x); line1.setAttributeNS(null, 'y1', ptStart.y); line1.setAttributeNS(null, 'x2', cp.x); line1.setAttributeNS(null, 'y2', cp.y); let line2 = document.querySelector(`#line2`); line2.setAttributeNS(null, 'x1', ptEnd.x); line2.setAttributeNS(null, 'y1', ptEnd.y); line2.setAttributeNS(null, 'x2', cp.x); line2.setAttributeNS(null, 'y2', cp.y); let line3 = document.querySelector(`#line3`); line3.setAttributeNS(null, 'x1', ptEnd.x); line3.setAttributeNS(null, 'y1', ptEnd.y); line3.setAttributeNS(null, 'x2', t_cp.x); line3.setAttributeNS(null, 'y2', t_cp.y); let line4 = document.querySelector(`#line4`); line4.setAttributeNS(null, 'x1', t_ptEnd.x); line4.setAttributeNS(null, 'y1', t_ptEnd.y); line4.setAttributeNS(null, 'x2', t_cp.x); line4.setAttributeNS(null, 'y2', t_cp.y); let output = document.querySelector('#output'); output.innerHTML = `d = "M ${ptStart.x},${ptStart.y} Q ${cp.x},${cp.y} ${ptEnd.x},${ptEnd.y} T ${t_ptEnd.x},${t_ptEnd.y}"`; } function onSVGMouseMove(evt) { if (!selectedElement) { return; } dataWrapper[selectedElement.id].x = evt.offsetX; dataWrapper[selectedElement.id].y = evt.offsetY; updateView(); } function onSVGMouseUp(evt) { selectedElement = null; } function onCircleMouseDown(evt) { selectedElement = evt.target; }

svg { display: block; width: 100vw; height: 250px; border: 1px solid #444; & circle { fill: teal; stroke: #CCC; } #cp { fill: hsl(230, 50%, 50%); stroke: gray; } #ptEnd { fill: orange; } #t_cp { fill: #222; stroke: gray; } & path { fill: none; stroke: white; } & line { stroke: #FFF3; } }

如果T命令前面没有QT命令,则控制点坍塌至当前点,从而绘制出一条直线。

svg { border: 1px solid #444; width: 100vw; height: 250px; & path { fill: none; stroke: white; } }

d属性子命令总结

下面分类列出d属性的所有子命令。

分类符号名称参数简介
定位 M,
m
moveto(x y)+
  • d必须以moveto命令开始。
    • 第一个moveto命令开始新路径,且设置为新路径的初始点及当前点;
    • 后续若有其他的moveto命令,开始新的子路径,且设置为子路径的初始点及当前点。
    • 初始点在指定后不会变化,而当前点受后续子命令的影响而不断变化。
  • M后面跟随绝对坐标值,m后面跟随相对坐标值。
  • 如果moveto后面跟随多对坐标值,则后续的各组坐标值视为隐式的lineto命令。
    • M的隐式lineto命令为L命令。
    • m的隐式lineto命令为l命令。
    • 如果dm开始,则第一组坐标值为绝对值;后续的其他各组坐标值(若有),则一概视为相对值。
关闭图形 Z,
z
closepathN/A 从当前点绘制一条直线至初始点,以关闭当前子路径。
绘制直线 L,
l
lineto(x y)+
  • 从当前点绘制一条直线至所指定的(x, y)坐标,并将后者设置为当前点。
  • L后面跟随绝对坐标值,l后面跟随相对坐标值。
  • 可以跟随多组坐标值,以绘制由多点组成的连线。最后一组坐标值将被设置为当前点。
H,
h
horizontal linetox+
  • 从当前点绘制一条水平直线至指定的位置,其X轴坐标值由参数x指定,Y轴坐标值与当前点相同。
  • H后面跟随绝对坐标值,h后面跟随相对坐标值。
    • H x等效于L x y',其中y'为当前点的Y轴坐标值。
    • h x等效于l x 0
  • 可以跟随多个x坐标值,以绘制由多点组成的水平直线(尽管通常无此必要)。最后一个x坐标值,连同当前点的Y轴坐标值,将被设置为当前点。
V,
v
vertical linetoy+
  • 从当前点绘制一条竖直直线至指定的位置,其Y轴坐标值由参数y指定,X轴坐标值与当前点相同。
  • V后面跟随绝对坐标值,v后面跟随相对坐标值。
    • V y等效于L x' y,其中x'为当前点的X轴坐标值。
    • v y等效于l 0 y
  • 可以跟随多个y坐标值,以绘制由多点组成的竖直直线(尽管通常无此必要)。最后一个y坐标值,连同当前点的X轴坐标值,将被设置为当前点。
绘制三次贝塞尔曲线 C,
c
curveto(
 x1 y1
 x2 y2
 x  y
)+
  • 从当前点绘制一条三次贝塞尔曲线至所指定的(x, y)坐标,并将后者设置为当前点。
  • (x1, y1)是连接当前点的第一个控制点;(x2, y2)是连接终点的第二个控制点。
  • C后面跟随绝对坐标值,c后面跟随相对坐标值。
  • 可以跟随多组坐标值,以绘制由多条三次贝塞尔曲线组成的连线。最后一组坐标值中的(x, y)坐标将被设置为当前点。
S,
s
smooth curveto(
 x2 y2
 x   y
)+
  • 从当前点绘制一条三次贝塞尔曲线至所指定的(x, y)坐标,并将后者设置为当前点。
  • 第一个控制点是前面C, c, Ss命令中的第二个控制点相对于当前点的镜像。
    如果前面无任何命令,或者前面的命令不是C, c, Ss命令,则第一个控制点坍塌至当前点。
  • (x2, y2)是连接终点的第二个控制点。
  • S后面跟随绝对坐标值,s后面跟随相对坐标值。
  • 可以跟随多组坐标值,以绘制由多条三次贝塞尔曲线组成的连线。最后一组坐标值中的(x, y)坐标将被设置为当前点。
绘制二次贝塞尔曲线 Q,
q
quadratic Bézier curveto(
 x1 y1
 x  y
)+
  • 从当前点绘制一条二次贝塞尔曲线至所指定的(x, y)坐标,并将后者设置为当前点。
  • (x1, y1)是连接当前点及终点的控制点。
  • Q后面跟随绝对坐标值,q后面跟随相对坐标值。
  • 可以跟随多组坐标值,以绘制由多条二次贝塞尔曲线组成的连线。最后一组坐标值中的(x, y)坐标将被设置为当前点。
T,
t
smooth quadratic Bézier curveto(x y)+
  • 从当前点绘制一条二次贝塞尔曲线至所指定的(x, y)坐标,并将后者设置为当前点。
  • 控制点是前面Q, q, Tt命令中的控制点相对于当前点的镜像。
    如果前面无任何命令,或者前面的命令不是Q, q, Tt命令,则控制点坍塌至当前点。
  • T后面跟随绝对坐标值,t后面跟随相对坐标值。
  • 可以跟随多组坐标值,以绘制由多条二次贝塞尔曲线组成的连线。最后一组坐标值将被设置为当前点。
绘制圆弧 A,
a
elliptical arc(
 rx ry
 x-axis-rotation
 large-arc-flag
 sweep-flag
 x y
)+
  • 从当前点绘制一条圆弧至所指定的(x, y)坐标,并将后者设置为当前点。
  • (rx, ry)分别对应于X轴半径及Y轴半径。
  • x-axis-rotation是圆弧围绕Z轴旋转的角度,以角度 (degree) 为单位。
  • large-arc-flag指定是否绘制长圆弧。
  • sweep-flag指定在应用椭圆公式时夹角值的变化方向。值为1,则夹角值从小到大而变化;值为0,则夹角值从大到小而变化
  • 圆弧的圆心由SVG引擎自动计算出来,以满足上述参数的约束。
  • A后面跟随绝对坐标值,a后面跟随相对坐标值。
  • 可以跟随多组坐标值,以绘制由多条圆弧组成的连线。最后一组坐标值中的(x, y)坐标将被设置为当前点。

Path DOM

本节部分的APIs只适用于Safari,Chrome目前尚未支持。

CanvasPath2D不能直接操控子路径。而SVGSVGPathElement则提供了直接操控子路径的方法。

let path2D = new Path2D(); pc.log('%O', path2D); let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); pc.log('%O', path);

path的类为SVGPathElement,有以下添加各种子路径的方法:

类别方法名称d属性中的命令作用
指定当前位置createSVGPathSegMovetoAbsM使用绝对值,将指定位置设置为当前位置
createSVGPathSegMovetoRelm使用相对值,将指定位置设置为当前位置
绘制直线createSVGPathSegLinetoAbsL使用绝对值,从当前位置绘制一条直线至指定位置
createSVGPathSegLinetoRell使用相对值,从当前位置绘制一条直线至指定位置
createSVGPathSegLinetoHorizontalAbsH使用绝对值,从当前位置绘制一条水平直线至指定位置
createSVGPathSegLinetoHorizontalRelh使用相对值,从当前位置绘制一条水平直线至指定位置
createSVGPathSegLinetoVerticalAbsV使用绝对值,从当前位置绘制一条竖直的直线至指定位置
createSVGPathSegLinetoVerticalRelv使用相对值,从当前位置绘制一条竖直的直线至指定位置
绘制圆弧createSVGPathSegArcAbsA使用绝对值,从当前位置绘制一条圆弧至指定位置
createSVGPathSegArcRela使用相对值,从当前位置绘制一条圆弧至指定位置
绘制Cubic Bézier曲线createSVGPathSegCurvetoCubicAbsC使用绝对值,从当前位置绘制一条Cubic Bézier曲线至指定位置
createSVGPathSegCurvetoCubicRelc使用相对值,从当前位置绘制一条Cubic Bézier曲线至指定位置
createSVGPathSegCurvetoCubicSmoothAbsS使用绝对值,从当前位置绘制一条平滑的Cubic Bézier曲线至指定位置
createSVGPathSegCurvetoCubicSmoothRels使用相对值,从当前位置绘制一条平滑的Cubic Bézier曲线至指定位置
绘制Quad Bézier曲线createSVGPathSegCurvetoQuadraticAbsQ使用绝对值,从当前位置绘制一条Quad Bézier曲线至指定位置
createSVGPathSegCurvetoQuadraticRelq使用相对值,从当前位置绘制一条Quad Bézier曲线至指定位置
createSVGPathSegCurvetoQuadraticSmoothAbsT使用绝对值,从当前位置绘制一条平滑的Quad Bézier曲线至指定位置
createSVGPathSegCurvetoQuadraticSmoothRelt使用相对值,从当前位置绘制一条平滑的Quad Bézier曲线至指定位置
关闭路径createSVGPathSegClosePathz, Z关闭路径

下面使用上述方法来构建一个正方形,然后,通过SVGPathElementpathSegList来检视每个子路径。

let pathEle = document.querySelector('svg > path'); function constructPath() { let pathSeg = pathEle.createSVGPathSegMovetoAbs(50, 50); pathEle.pathSegList.appendItem(pathSeg); pathSeg = pathEle.createSVGPathSegLinetoHorizontalRel(50); pathEle.pathSegList.appendItem(pathSeg); pathSeg = pathEle.createSVGPathSegLinetoVerticalRel(50); pathEle.pathSegList.appendItem(pathSeg); pathSeg = pathEle.createSVGPathSegLinetoHorizontalRel(-50); pathEle.pathSegList.appendItem(pathSeg); pathSeg = pathEle.createSVGPathSegClosePath(); pathEle.pathSegList.appendItem(pathSeg); } function showSubPaths() { let segList = pathEle.pathSegList; pc.log('%O', segList); for (let index = 0; index < segList.length; index++) { let pathSeg = segList.getItem(index); pc.log('%O', pathSeg); } } constructPath(); showSubPaths();
svg { border: 1px solid #444; width: 100vw; height: 150px; }

了解这些APIs的细节后,我们就可以通过代码来创建可动态交互的路径了。

参考资源

  1. SVG2 Paths
  2. Geometry Interfaces Module Level 1
  3. CSS Object Model (CSSOM)
  4. SVG2 Basic Data Types and Interfaces
  5. SVG2 The grammar for path data
  6. svg-path-parser