Search code examples
htmlsvgcanvasbezier

HTML: draw cubic Bézier curve in multiple colors


I am working with Bézier curves in HTML.

I have a cubic Bézier curve that I want to draw in multiple colors (color 1 from t = 0 to t = x, color 2 from t = x to t = 1). I could do this either with canvas or with SVG.

Is there a way to specify the stroke color based on the t value?

If not, is there a way to specify the t-interval in which the curve should be drawn? Then I could just draw it multiple times in different intervals with the particular color.


Solution

  • You need to split your <path> into 2 separate paths at the given t value.

    let t = 0.75;
    let path = document.querySelector('#pathCubic');
    
    /**
     * parse pathData
     * quadratic and arc commands will be converted to cubic
     */
    let pathData = path.getPathData({
      normalize: true
    });
    let pathDataSplit = splitNthPathSegmentAtT(pathData, 1, t);
    
    let d0 = pathDataSplit[0].map(com => {
      return `${com.type} ${com.values.join(' ')}`
    }).join(' ');
    let d1 = pathDataSplit[1].map(com => {
      return `${com.type} ${com.values.join(' ')}`
    }).join(' ');
    
    // apply path data
    pathSplit0.setAttribute('d', d0);
    pathSplit1.setAttribute('d', d1);
    
    
    /**
     * split nth path segment at "t"
     */
    function splitNthPathSegmentAtT(pathData, i = 1, t = 0.5) {
    
      let pathData0 = [pathData[i - 1]];
      let pathData1 = [];
      let com = pathData[i];
    
      let [type, values] = [com.type, com.values];
      let valuesL = values.length;
      let comPrev = pathData[i - 1];
      let valuesPrev = comPrev ? comPrev.values : [];
      let valuesPrevL = valuesPrev.length;
      let p0, cp1, cp2, p1, p2, m0, m1, m2, m3, m4;
      switch (type) {
        case "C":
          p0 = {
            x: valuesPrev[valuesPrevL - 2],
            y: valuesPrev[valuesPrevL - 1]
          };
          cp1 = {
            x: values[valuesL - 6],
            y: values[valuesL - 5]
          };
          cp2 = {
            x: values[valuesL - 4],
            y: values[valuesL - 3]
          };
    
          p1 = {
            x: values[valuesL - 2],
            y: values[valuesL - 1]
          };
          m0 = interpolatedPoint(p0, cp1, t);
          m1 = interpolatedPoint(cp1, cp2, t);
          m2 = interpolatedPoint(cp2, p1, t);
          m3 = interpolatedPoint(m0, m1, t);
          m4 = interpolatedPoint(m1, m2, t);
    
          // split end point
          p2 = interpolatedPoint(m3, m4, t);
    
          // first segment
          pathData0.push({
            type: "C",
            values: [m0.x, m0.y, m3.x, m3.y, p2.x, p2.y]
          });
    
          // second segment
          pathData1.push({
            type: "M",
            values: [p2.x, p2.y]
          });
    
          pathData1.push({
            type: "C",
            values: [m4.x, m4.y, m2.x, m2.y, p1.x, p1.y]
          });
          break;
    
    
        case "L":
          p0 = {
            x: valuesPrev[valuesPrevL - 2],
            y: valuesPrev[valuesPrevL - 1]
          };
          p1 = {
            x: values[valuesL - 2],
            y: values[valuesL - 1]
          };
          m1 = interpolatedPoint(p0, p1, t);
          // first segment
          pathData0.push({
            type: "L",
            values: [m1.x, m1.y]
          });
          // second segment
          pathData1.push({
            type: "M",
            values: [m1.x, m1.y]
          });
          pathData1.push({
            type: "L",
            values: [p1.x, p1.y]
          });
          break;
    
      }
    
      return [pathData0, pathData1];
    }
    
    
    /**
     * Linear  interpolation (LERP) helper
     */
    function interpolatedPoint(p1, p2, t = 0.5) {
      //t: 0.5 - point in the middle
      if (Array.isArray(p1)) {
        p1.x = p1[0];
        p1.y = p1[1];
      }
      if (Array.isArray(p2)) {
        p2.x = p2[0];
        p2.y = p2[1];
      }
      let [x, y] = [(p2.x - p1.x) * t + p1.x, (p2.y - p1.y) * t + p1.y];
      return {
        x: x,
        y: y
      };
    }
    .layout {
      display: flex;
      gap: 1em;
    }
    
    svg{
    width:100%
    }
    <script src="https://cdn.jsdelivr.net/npm/path-data-polyfill@1.0.4/path-data-polyfill.min.js"></script>
    <div class="layout">
      <svg viewBox="0 0 100 100">
    <path id="pathCubic" d="M0 100 C33.33 33.33 66.67 33.33 100 100" stroke="#000" fill="none" />
    </svg>
      <svg viewBox="0 0 100 100">
    <path id="pathSplit0" d="" stroke="green" fill="none" />
    <path id="pathSplit1" d="" stroke="red" fill="none" />
    </svg>
    </div>

    I'm using getPathData() polyfilled to parse the d pathData attribute to an array of commands.

    Helper function splitNthPathSegmentAtT() will calculate the new interpolated C command control points according to the desired t value.

    Further reading

    Split at pathLength

    If you need to color you path at certain lengths you could use the pathLength attribute combined with stroke-dasharray and stroke-dashoffset.
    Not the same, since we're visually "splitting" (by duplicating the path and applying different stroke-dasharray values) according to a path length percentage.

    let t = 0.75;
    let path = document.querySelector('#pathCubic');
    
    
    /**
     * emulate split path by stroke-dasharray
     * split via path length
     */
    path.setAttribute('pathLength', 1);
    let clonedSeg = path.cloneNode();
    let svg = path.closest('svg');
    svg.appendChild(clonedSeg);
    
    
    // 1. segment
    path.setAttribute('stroke', 'green');
    path.setAttribute('stroke-dashoffset', `0`);
    path.setAttribute('stroke-dasharray', `${t} 1`);
    
    // 2. segment
    clonedSeg.setAttribute('stroke', 'red');
    clonedSeg.setAttribute('stroke-dashoffset', `-${t}`);
    clonedSeg.setAttribute('stroke-dasharray', `${1 - t} 1`);
    
    
    /**
     * parse pathData
     * quadratic and arc commands will be converted to cubic
     */
    
    let pathData = path.getPathData({
      normalize: true
    });
    let pathDataSplit = splitNthPathSegmentAtT(pathData, 1, t);
    
    let d0 = pathDataSplit[0].map(com => {
      return `${com.type} ${com.values.join(' ')}`
    }).join(' ');
    let d1 = pathDataSplit[1].map(com => {
      return `${com.type} ${com.values.join(' ')}`
    }).join(' ');
    
    // apply path data
    pathSplit0.setAttribute('d', d0);
    pathSplit1.setAttribute('d', d1);
    
    
    
    /**
     * split nth path segment at "t"
     */
    function splitNthPathSegmentAtT(pathData, i = 1, t = 0.5) {
    
      let pathData0 = [pathData[i - 1]];
      let pathData1 = [];
      let com = pathData[i];
    
      let [type, values] = [com.type, com.values];
      let valuesL = values.length;
      let comPrev = pathData[i - 1];
      let valuesPrev = comPrev ? comPrev.values : [];
      let valuesPrevL = valuesPrev.length;
      let p0, cp1, cp2, p1, p2, m0, m1, m2, m3, m4;
      switch (type) {
        case "C":
          p0 = {
            x: valuesPrev[valuesPrevL - 2],
            y: valuesPrev[valuesPrevL - 1]
          };
          cp1 = {
            x: values[valuesL - 6],
            y: values[valuesL - 5]
          };
          cp2 = {
            x: values[valuesL - 4],
            y: values[valuesL - 3]
          };
    
          p1 = {
            x: values[valuesL - 2],
            y: values[valuesL - 1]
          };
          m0 = interpolatedPoint(p0, cp1, t);
          m1 = interpolatedPoint(cp1, cp2, t);
          m2 = interpolatedPoint(cp2, p1, t);
          m3 = interpolatedPoint(m0, m1, t);
          m4 = interpolatedPoint(m1, m2, t);
    
          // split end point
          p2 = interpolatedPoint(m3, m4, t);
    
          // first segment
          pathData0.push({
            type: "C",
            values: [m0.x, m0.y, m3.x, m3.y, p2.x, p2.y]
          });
    
          // second segment
          pathData1.push({
            type: "M",
            values: [p2.x, p2.y]
          });
    
          pathData1.push({
            type: "C",
            values: [m4.x, m4.y, m2.x, m2.y, p1.x, p1.y]
          });
          break;
    
    
        case "L":
          p0 = {
            x: valuesPrev[valuesPrevL - 2],
            y: valuesPrev[valuesPrevL - 1]
          };
          p1 = {
            x: values[valuesL - 2],
            y: values[valuesL - 1]
          };
          m1 = interpolatedPoint(p0, p1, t);
          // first segment
          pathData0.push({
            type: "L",
            values: [m1.x, m1.y]
          });
          // second segment
          pathData1.push({
            type: "M",
            values: [m1.x, m1.y]
          });
          pathData1.push({
            type: "L",
            values: [p1.x, p1.y]
          });
          break;
    
      }
    
    
      return [pathData0, pathData1];
    }
    
    
    /**
     * Linear  interpolation (LERP) helper
     */
    function interpolatedPoint(p1, p2, t = 0.5) {
      //t: 0.5 - point in the middle
      if (Array.isArray(p1)) {
        p1.x = p1[0];
        p1.y = p1[1];
      }
      if (Array.isArray(p2)) {
        p2.x = p2[0];
        p2.y = p2[1];
      }
      let [x, y] = [(p2.x - p1.x) * t + p1.x, (p2.y - p1.y) * t + p1.y];
      return {
        x: x,
        y: y
      };
    }
    .layout {
      display: flex;
      gap: 1em;
    }
    
    svg{
    width:100%
    }
    <script src="https://cdn.jsdelivr.net/npm/path-data-polyfill@1.0.4/path-data-polyfill.min.js"></script>
    
    
    <div class="layout">
      <svg viewBox="0 0 100 100">
                <path id="pathCubic" d="M0 100 C33.33 33.33 66.67 33.33 100 100" stroke="#000" fill="none" />
            </svg>
      <svg viewBox="0 0 100 100">
                <path id="pathSplit0" d="" stroke="green" fill="none" />
                <path id="pathSplit1" d="" stroke="red" fill="none" />
            </svg>
    </div>

    Left: split at pathLength*0.75; Right split at t 0.75.