javascriptsvgsmoothingbezier

Smoothly close an SVG path with JavaScript


I'm working on some code where a user drops a number or points/markers and a Bezier curved SVG line is drawn through them (so a smooth line connects all the points). It's based off

https://codepen.io/francoisromain/pen/dzoZZj

svg.innerHTML = svgPath(points, bezierCommand)

An example path that I've created is:

<svg width="100px" height="100px" viewBox="100 200 500 400">
  <path d="M 346,186.5 C 374,208.875 452,237.875 458,276 C 464,314.125 433.75,340.5 370,339 C 306.25,337.5 253,307.75 203,270 C 153,232.25 134.75,209 170,188 C 205.25,167 300.5,186.5 344,186">
</svg>

So far it's working well till I get to closing the line. If I add Z it abruptly joins the points.

The closing needs to be (if necessary) a curve so that there's a gentle transition from the last point to the first and on to the second... i.e. the whole path becomes a "smooth" loop.

I tried adding a Bezier curve but the join ends up being a sharp bump. And I tried adding another point close to the first and then using Z but again I get a sharp bump.

How can this be done?


Solution

  • I modified the original function to return a pathData (according to the SVG 2 SVGPathData interface draft) the array instead of a d attribute string.
    This way, we can easily manipulate and sort commands.

    I've also changed the coordinate structure so we're working with point objects.

    [
      { x: 344, y: 186 }
    ]
    

    instead of

    [
      [ 344, 186 ]
    ]
    

    This pretty handy, since most native methods like getPointAtLength() or properties like points also return coordinates as objects

    Extend the polyline by appending the first 2 point to the point array

    
         points = [
          { x: 344, y: 186 },
          { x: 458, y: 276 },
          { x: 370, y: 339 },
          { x: 203, y: 270 },
          { x: 170, y: 188 },
           //duplicated points from the beginning
          { x: 344, y: 186 },
          { x: 458, y: 276 },
           
        ];
    
      // append first 2 points for closed paths
      if (closed) {
        points = points.concat(points.slice(0, 2));
      }
    

    This way the function will create a smooth curve between first/last and second vertice.

    polyline smoothed

    Obviously, this path has an overlapping curve segment, we need to remove. Besides, the first C and needs some adjustment.

    Copy last C commands 1. control point and delete last command

    We can copy the last command's 1st control point to the first C command.

      // copy last commands 1st controlpoint to first curveto
      if (closed) {
        let comLast = pathData[pathData.length - 1];
        let valuesLastC = comLast.values;
        let valuesFirstC = pathData[1].values;
        
        pathData[1] = {
          type: "C",
          values: [valuesLastC[0], valuesLastC[1], valuesFirstC.slice(2)].flat()
        };
        // delete last curveto
        pathData = pathData.slice(0, pathData.length - 1); 
      }
    

    let points = [
      { x: 344, y: 186 },
      { x: 458, y: 276 },
      { x: 370, y: 339 },
      { x: 203, y: 270 },
      { x: 170, y: 188 }
    ];
    
    
    let smoothing = 0.3;
    let pathData = getCurvePathData(points, smoothing, true);
    
    // serialize pathData to d attribute string
    let d = pathDataToD(pathData, 1);
    path.setAttribute("d", d);
    
    
    // Render the svg <path> element
    function getCurvePathData(points, smoothing = 0.2, closed=true){ 
      
      // append first 2 points for closed paths
      if (closed) {
        points = points.concat(points.slice(0, 2));
      }
      
      // Properties of a line
      const line = (pointA, pointB) => {
        const lengthX = pointB.x - pointA.x;
        const lengthY = pointB.y - pointA.y;
        return {
          length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)),
          angle: Math.atan2(lengthY, lengthX)
        };
      };
    
      // Position of a control point
      const controlPoint = (current, previous, next, reverse) => {
        const p = previous || current;
        const n = next || current;
        const o = line(p, n);
    
        const angle = o.angle + (reverse ? Math.PI : 0);
        const length = o.length * smoothing;
    
        const x = current.x + Math.cos(angle) * length;
        const y = current.y + Math.sin(angle) * length;
        return { x, y };
      };
    
      let pathData = [];
      pathData.push({ type: "M", values: [points[0].x, points[0].y] });
    
      for (let i = 1; i < points.length; i++) {
        let point = points[i];
        const cp1 = controlPoint(points[i - 1], points[i - 2], point);
        const cp2 = controlPoint(point, points[i - 1], points[i + 1], true);
        //console.log( i, 'a', a)
        const command = {
          type: "C",
          values: [cp1.x, cp1.y, cp2.x, cp2.y, point.x, point.y]
        };
    
        pathData.push(command);
      }
      
      // copy last commands 1st controlpoint to first curveto
      if (closed) {
        let comLast = pathData[pathData.length - 1];
        let valuesLastC = comLast.values;
        let valuesFirstC = pathData[1].values;
        
        pathData[1] = {
          type: "C",
          values: [valuesLastC[0], valuesLastC[1], valuesFirstC.slice(2)].flat()
        };
        // delete last curveto
        pathData = pathData.slice(0, pathData.length - 1); 
      }
      
      return pathData;
    };
    
    // convert pathdata to d attribute string
    function pathDataToD(pathData, decimals=3){
      let d = pathData
      .map((com) => {
        return `${com.type}${com.values.map(value=>{return +value.toFixed(decimals)}).join(" ")}`;
      })
      .join(" ");
      return d;
    }
    <svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" id="svg">
      <path id="pathPoly" fill="none" stroke="green"></path>
      <path id="path" fill="none" stroke="#000"></path>
    </svg>

    See original post by François Romain: Smooth a Svg path with cubic bezier curves