Search code examples
javascripthtmlanimationsvgscroll

How can I scroll to move SVG centered in the window along the path with SVG resized?


I followed this post how to resize SVG and successfully kept the red square always on the path when resizing the SVG. However, a new issue arises: when scrolling down, the red square is not move follow positioned at the midline (Center of the window). How can I fix it?

      let svg = document.querySelector(".svg-path");
      let mPath = document.getElementById("Path_440");
      let strokePath = document.getElementById("theFill");
      let pathIcon = document.getElementById("pathIcon");

      document.addEventListener("DOMContentLoaded", function () {
        // auto adjust viewBox
        let svgRect = svg.getBBox();
        let width = svgRect.width;
        let height = svgRect.height;
        svg.setAttribute("viewBox", `0 0 ${width} ${height}`);

        // update offset path
        defineNewOffsetPath();
      });

      function defineNewOffsetPath() {
        // retrieve the current scale from SVG transformation matrix
        let matrix = svg.getCTM();
        let scale = matrix.a;

        // parse path data
        let d = mPath.getAttribute("d");
        let pathData = parsePathData(d);

        //scale pathdata
        pathData = scalePathData(pathData, scale);

        // apply scaled pathdata as stringified d attribute value
        d = pathDataToD(pathData);

        pathIcon.style.offsetPath = `path('${d}')`;
      }

      // recalculate offset path on resize
      window.addEventListener("resize", (e) => {
        defineNewOffsetPath();
        matrix = svg.getScreenCTM();
        updateLengthLookup();
      });

      // just for illustration
      resizeObserver();
      function resizeObserver() {
        defineNewOffsetPath();
      }
      new ResizeObserver(resizeObserver).observe(scrollDiv);
      /**
       * scale path data proportional
       */
      function scalePathData(pathData, scale = 1) {
        let pathDataScaled = [];
        pathData.forEach((com, i) => {
          let { type, values } = com;
          let comT = {
            type: type,
            values: [],
          };

          switch (type.toLowerCase()) {
            // line to shorthands
            case "h":
              comT.values = [values[0] * scale]; // horizontal - x-only
              break;
            case "v":
              comT.values = [values[0] * scale]; // vertical - x-only
              break;

            // arcto
            case "a":
              comT.values = [
                values[0] * scale, // rx: scale
                values[1] * scale, // ry: scale
                values[2], // x-axis-rotation: keep it
                values[3], // largeArc: dito
                values[4], // sweep: dito
                values[5] * scale, // final x: scale
                values[6] * scale, // final y: scale
              ];
              break;

            /**
             * Other point based commands: L, C, S, Q, T
             * scale all values
             */
            default:
              if (values.length) {
                comT.values = values.map((val, i) => {
                  return val * scale;
                });
              }
          }
          pathDataScaled.push(comT);
        });
        return pathDataScaled;
      }

      /**
       * parse stringified path data used in d attribute
       * to an array of computable command data
       */
      function parsePathData(d) {
        d = d
          // remove new lines, tabs an comma with whitespace
          .replace(/[\n\r\t|,]/g, " ")
          // pre trim left and right whitespace
          .trim()
          // add space before minus sign
          .replace(/(\d)-/g, "$1 -")
          // decompose multiple adjacent decimal delimiters like 0.5.5.5 => 0.5 0.5 0.5
          .replace(/(\.)(?=(\d+\.\d+)+)(\d+)/g, "$1$3 ");

        let pathData = [];
        let cmdRegEx = /([mlcqazvhst])([^mlcqazvhst]*)/gi;
        let commands = d.match(cmdRegEx);

        // valid command value lengths
        let comLengths = {
          m: 2,
          a: 7,
          c: 6,
          h: 1,
          l: 2,
          q: 4,
          s: 4,
          t: 2,
          v: 1,
          z: 0,
        };
        commands.forEach((com) => {
          let type = com.substring(0, 1);
          let typeRel = type.toLowerCase();
          let isRel = type === typeRel;
          let chunkSize = comLengths[typeRel];

          // split values to array
          let values = com
            .substring(1, com.length)
            .trim()
            .split(" ")
            .filter(Boolean);

          /**
           * A - Arc commands
           * large arc and sweep flags
           * are boolean and can be concatenated like
           * 11 or 01
           * or be concatenated with the final on path points like
           * 1110 10 => 1 1 10 10
           */
          if (typeRel === "a" && values.length != comLengths.a) {
            let n = 0,
              arcValues = [];
            for (let i = 0; i < values.length; i++) {
              let value = values[i];

              // reset counter
              if (n >= chunkSize) {
                n = 0;
              }
              // if 3. or 4. parameter longer than 1
              if ((n === 3 || n === 4) && value.length > 1) {
                let largeArc = n === 3 ? value.substring(0, 1) : "";
                let sweep =
                  n === 3 ? value.substring(1, 2) : value.substring(0, 1);
                let finalX = n === 3 ? value.substring(2) : value.substring(1);
                let comN = [largeArc, sweep, finalX].filter(Boolean);
                arcValues.push(comN);
                n += comN.length;
              } else {
                // regular
                arcValues.push(value);
                n++;
              }
            }
            values = arcValues.flat().filter(Boolean);
          }

          // string  to number
          values = values.map(Number);

          // if string contains repeated shorthand commands - split them
          let hasMultiple = values.length > chunkSize;
          let chunk = hasMultiple ? values.slice(0, chunkSize) : values;
          let comChunks = [
            {
              type: type,
              values: chunk,
            },
          ];

          // has implicit or repeated commands – split into chunks
          if (hasMultiple) {
            let typeImplicit = typeRel === "m" ? (isRel ? "l" : "L") : type;
            for (let i = chunkSize; i < values.length; i += chunkSize) {
              let chunk = values.slice(i, i + chunkSize);
              comChunks.push({
                type: typeImplicit,
                values: chunk,
              });
            }
          }
          comChunks.forEach((com) => {
            pathData.push(com);
          });
        });

        /**
         * first M is always absolute/uppercase -
         * unless it adds relative linetos
         * (facilitates d concatenating)
         */
        pathData[0].type = "M";
        return pathData;
      }

      /**
       * serialize pathData array to
       * d attribute string
       */
      function pathDataToD(pathData, decimals = 3) {
        let d = ``;
        pathData.forEach((com) => {
          d += `${com.type}${com.values
            .map((val) => {
              return +val.toFixed(decimals);
            })
            .join(" ")}`;
        });
        return d;
      }
 
      // **Code about scroll below**


      // steps for pathlength lookup
      let precision = 1000;

      // get transform matrix to translate svg units to screen coordinates
      let matrix = svg.getScreenCTM();

      function getLengthLookup(path, precision = 100) {
        //create pathlength lookup
        let pathLength = path.getTotalLength();
        let lengthLookup = {
          yArr: [],
          lengthArr: [],
          pathLength: pathLength,
        };

        // sample point to calculate Y at pathLengths
        let step = Math.floor(pathLength / precision);

        for (let l = 0; l < pathLength; l += step) {
          let pt = SVGToScreen(matrix, path.getPointAtLength(l));
          let y = pt.y;
          lengthLookup.yArr.push(y);
          lengthLookup.lengthArr.push(l);
        }
        return lengthLookup;
      }

      const lengthLookup = getLengthLookup(mPath, precision);
      const { lengthArr, yArr, pathLength } = lengthLookup;
      const maxHeight =
        document.documentElement.scrollHeight - window.innerHeight;
  

      window.addEventListener("scroll", (e) => {
        scrollPathicon();
      });
      function scrollPathicon() {
        let scrollPosMid = getViewportMiddleY();
        midline.style.top = scrollPosMid + "px";

        // get y pos length
        let found = false;

        for (let i = 0; i < yArr.length && !found; i++) {
          // find next largest y in lookup
          let y = yArr[i];
          if (y >= scrollPosMid) {
            let length = lengthArr[i];

            // adjust length via interpolated approximation
            let yPrev = yArr[i - 1] ? yArr[i - 1] : yArr[i];
            let lengthPrev = lengthArr[i - 1] ? lengthArr[i - 1] : length;
            let ratioL = (1 / lengthArr[i]) * lengthPrev;
            let ratioY = (1 / y) * scrollPosMid;
            let ratio = Math.max(ratioL, ratioY);

            let dashLength = lengthArr[i] * ratio;

            // calculate offsetDistance
            let offsetDist = (100 / pathLength) * dashLength;
            pathIcon.style.offsetDistance = offsetDist + "%";

            // change dasharray
            strokePath.setAttribute(
              "stroke-dasharray",
              `${dashLength} ${pathLength}`
            );

            // stop loop
            found = true;
          }
        }
      }

      /**
       * Get the absolute center/middle y-coordinate
       * of the current scroll viewport
       */
      function getViewportMiddleY() {
        const viewportHeight = window.innerHeight;
        const scrollY = window.scrollY || window.pageYOffset;
        const element = document.documentElement;
        const elementOffsetTop = element.offsetTop;
        const middleY = scrollY + viewportHeight / 2 + elementOffsetTop;
        return middleY;
      }

      function SVGToScreen(matrix, pt) {
        let p = new DOMPoint(pt.x, pt.y);
        p = p.matrixTransform(matrix);
        return p;
      }
       html {
        margin: 0;
        padding: 0;
      }

      .svg-path {
        overflow: visible;
        width: 100%;
      }

      #pathIcon {
        position: absolute;
        inset: 0;
        width: 5vw;
        height: 5vw;
        offset-rotate: 0deg;
        offset-distance: 10%;
      }

      #scrollDiv {
        overflow: auto;
        margin: 10px;
        padding-bottom: 10vw;
      }
      #midline {
        display: block;
        position: absolute;
        width: 100%;
        height: 1px;
        border-top: 1px solid orange;
      }
 <div id="scrollDiv" style="position: relative">
      <svg class="svg-path" viewBox="0 0 0 0" fill="none">
        <defs>
          <path
            id="Path_440"
            d="M1293 2 s-16 74.47-96 91.5c-91.45 19.47-308.67-2.43-424.5 7-227 0-469.89 25.44-493.5 195-11 79-13.89 124.33-11 207.5s1.9 142.65 37 238c41.77 113.46 465 97.5 789.5 91 271.5 10.5 581.52-40 671.5 179 37.18 90.5 0 446.5 0 482.5 0 67.5-70 120-148 134-153 0-429.89 5-614.5 5-271 0-633.97-26.81-691.5 85-40.39 78.5-36 202.5-36 264.5 1.12 92.28-3.45 162.17 36 276 27.86 80.39 409.15 66.5 669 66.5 316 0 696.34-17 758.5 79 54.07 83.5 33.23 212.68 33.23 361.5 0 128-2 232.5-120.73 267-39.33 11.43-415 0-759 0-487.5 0-891-2-891-2"
            stroke="#020878"
            stroke-width="2"
            stroke-miterlimit="10"
            stroke-linecap="round"
            stroke-dasharray="20 20"
          />
        </defs>
        <use
          class="stroke"
          href="#Path_440"
          stroke="#ccc"
          stroke-width="10"
          stroke-dasharray="20 10"
        ></use>
        <use
          class="stroke"
          id="theFill"
          href="#Path_440"
          stroke-dasharray="925.988 9259.88"
          stroke-width="10"
          stroke="#4cacff"
        ></use>
      </svg>
      <svg id="pathIcon" fill="none">
        <rect width="100" height="100" fill="red" fill-opacity="0.5" />
      </svg>
      <div id="midline"></div>
    </div>

I think it's wrong in calculating the length of the SVG in viewport and offsetDistance. I tried to calculate updateLengthLookup after defineNewOffsetPath at resize event, but it dont work. I don't know how to fix it.


Solution

  • There are a lot of calculation issues along with outdated and undefined variables (already mentioned in the comments).

    The matrix in defineNewOffsetPath() should be assigned with svg.getScreenCTM() and the function must stop if that value is null. To simplify the calculations margins and paddings can be removed from body and #scrollDiv.

    The updateLengthLookup() should update svg point lookups every time the window size changes. It would be better to prevent the excessive recalculations in event handler. The y value that comes from SVGToScreen should account for document's scrollTop as well.

    In scrollPathicon() svg / div ratio must be calculated before the lookup loop. Once a target position is found the lengthArr[i] can be used for offsetDist calculation without ratio adjustment.

    let svg = document.querySelector(".svg-path");
    let mPath = document.getElementById("Path_440");
    let strokePath = document.getElementById("theFill");
    let pathIcon = document.getElementById("pathIcon");
    
    document.addEventListener("DOMContentLoaded", function() {
      // auto adjust viewBox
      let svgRect = svg.getBBox();
      let width = svgRect.width;
      let height = svgRect.height;
      svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
    
      // update offset path
      defineNewOffsetPath();
    
      updateLengthLookup();
      scrollPathicon();
    });
    
    function defineNewOffsetPath() {
      // retrieve the current scale from SVG transformation matrix
      let matrix = svg.getScreenCTM();
      if (!matrix) {
        return;
      }
      let scale = matrix.a;
    
      // parse path data
      let d = mPath.getAttribute("d");
      let pathData = parsePathData(d);
    
      //scale pathdata
      pathData = scalePathData(pathData, scale);
    
      // apply scaled pathdata as stringified d attribute value
      d = pathDataToD(pathData);
    
      pathIcon.style.offsetPath = `path('${d}')`;
    }
    
    // recalculate offset path on resize
    window.addEventListener("resize", (e) => {
      defineNewOffsetPath();
      updateLengthLookup();
      scrollPathicon();
    });
    
    // just for illustration
    resizeObserver();
    
    function resizeObserver() {
      defineNewOffsetPath();
    }
    new ResizeObserver(resizeObserver).observe(scrollDiv);
    /**
     * scale path data proportional
     */
    function scalePathData(pathData, scale = 1) {
      let pathDataScaled = [];
      pathData.forEach((com, i) => {
        let {
          type,
          values
        } = com;
        let comT = {
          type: type,
          values: [],
        };
    
        switch (type.toLowerCase()) {
          // line to shorthands
          case "h":
            comT.values = [values[0] * scale]; // horizontal - x-only
            break;
          case "v":
            comT.values = [values[0] * scale]; // vertical - x-only
            break;
    
            // arcto
          case "a":
            comT.values = [
              values[0] * scale, // rx: scale
              values[1] * scale, // ry: scale
              values[2], // x-axis-rotation: keep it
              values[3], // largeArc: dito
              values[4], // sweep: dito
              values[5] * scale, // final x: scale
              values[6] * scale, // final y: scale
            ];
            break;
    
            /**
             * Other point based commands: L, C, S, Q, T
             * scale all values
             */
          default:
            if (values.length) {
              comT.values = values.map((val, i) => {
                return val * scale;
              });
            }
        }
        pathDataScaled.push(comT);
      });
      return pathDataScaled;
    }
    
    /**
     * parse stringified path data used in d attribute
     * to an array of computable command data
     */
    function parsePathData(d) {
      d = d
        // remove new lines, tabs an comma with whitespace
        .replace(/[\n\r\t|,]/g, " ")
        // pre trim left and right whitespace
        .trim()
        // add space before minus sign
        .replace(/(\d)-/g, "$1 -")
        // decompose multiple adjacent decimal delimiters like 0.5.5.5 => 0.5 0.5 0.5
        .replace(/(\.)(?=(\d+\.\d+)+)(\d+)/g, "$1$3 ");
    
      let pathData = [];
      let cmdRegEx = /([mlcqazvhst])([^mlcqazvhst]*)/gi;
      let commands = d.match(cmdRegEx);
    
      // valid command value lengths
      let comLengths = {
        m: 2,
        a: 7,
        c: 6,
        h: 1,
        l: 2,
        q: 4,
        s: 4,
        t: 2,
        v: 1,
        z: 0,
      };
      commands.forEach((com) => {
        let type = com.substring(0, 1);
        let typeRel = type.toLowerCase();
        let isRel = type === typeRel;
        let chunkSize = comLengths[typeRel];
    
        // split values to array
        let values = com
          .substring(1, com.length)
          .trim()
          .split(" ")
          .filter(Boolean);
    
        /**
         * A - Arc commands
         * large arc and sweep flags
         * are boolean and can be concatenated like
         * 11 or 01
         * or be concatenated with the final on path points like
         * 1110 10 => 1 1 10 10
         */
        if (typeRel === "a" && values.length != comLengths.a) {
          let n = 0,
            arcValues = [];
          for (let i = 0; i < values.length; i++) {
            let value = values[i];
    
            // reset counter
            if (n >= chunkSize) {
              n = 0;
            }
            // if 3. or 4. parameter longer than 1
            if ((n === 3 || n === 4) && value.length > 1) {
              let largeArc = n === 3 ? value.substring(0, 1) : "";
              let sweep =
                n === 3 ? value.substring(1, 2) : value.substring(0, 1);
              let finalX = n === 3 ? value.substring(2) : value.substring(1);
              let comN = [largeArc, sweep, finalX].filter(Boolean);
              arcValues.push(comN);
              n += comN.length;
            } else {
              // regular
              arcValues.push(value);
              n++;
            }
          }
          values = arcValues.flat().filter(Boolean);
        }
    
        // string  to number
        values = values.map(Number);
    
        // if string contains repeated shorthand commands - split them
        let hasMultiple = values.length > chunkSize;
        let chunk = hasMultiple ? values.slice(0, chunkSize) : values;
        let comChunks = [{
          type: type,
          values: chunk,
        }, ];
    
        // has implicit or repeated commands – split into chunks
        if (hasMultiple) {
          let typeImplicit = typeRel === "m" ? (isRel ? "l" : "L") : type;
          for (let i = chunkSize; i < values.length; i += chunkSize) {
            let chunk = values.slice(i, i + chunkSize);
            comChunks.push({
              type: typeImplicit,
              values: chunk,
            });
          }
        }
        comChunks.forEach((com) => {
          pathData.push(com);
        });
      });
    
      /**
       * first M is always absolute/uppercase -
       * unless it adds relative linetos
       * (facilitates d concatenating)
       */
      pathData[0].type = "M";
      return pathData;
    }
    
    /**
     * serialize pathData array to
     * d attribute string
     */
    function pathDataToD(pathData, decimals = 3) {
      let d = ``;
      pathData.forEach((com) => {
        d += `${com.type}${com.values
          .map((val) => {
            return +val.toFixed(decimals);
          })
          .join(" ")}`;
      });
      return d;
    }
    
    // **Code about scroll below**
    
    
    function getLengthLookup(path, precision = 100) {
      //create pathlength lookup
      const pathLength = path.getTotalLength();
      const lengthLookup = {
        yArr: [],
        lengthArr: [],
        pathLength: pathLength,
      };
    
      // sample point to calculate Y at pathLengths
      const step = Math.floor(pathLength / precision);
      const matrix = svg.getScreenCTM();
      const scrollTop = document.documentElement.scrollTop;
    
      for (let l = 0; l < pathLength; l += step) {
        let pt = SVGToScreen(matrix, path.getPointAtLength(l));
        let y = pt.y + scrollTop;
        lengthLookup.yArr.push(y);
        lengthLookup.lengthArr.push(l);
      }
      return lengthLookup;
    }
    
    let lengthLookup = {};
    let lengthLookupRuns = false;
    
    function updateLengthLookup() {
      if (lengthLookupRuns) {
        return;
      }
      // steps for pathlength lookup
      let precision = 1000;
      lengthLookupRuns = true;
      lengthLookup = getLengthLookup(mPath, precision);
      lengthLookupRuns = false;
    }
    
    window.addEventListener("scroll", (e) => {
      scrollPathicon();
    });
    
    function scrollPathicon() {
      if (lengthLookupRuns) {
        return;
      }
      const {
        lengthArr,
        yArr,
        pathLength
      } = lengthLookup;
      const scrollPosMid = getViewportMiddleY();
      const ratio = yArr[yArr.length - 1] / document.documentElement.offsetHeight;
      const scrollPosScaledToY = scrollPosMid * ratio;
      midline.style.top = scrollPosMid + "px";
    
      for (let i = 0; i < yArr.length; i++) {
        // find next largest y in lookup
        let y = yArr[i];
        if (y >= scrollPosScaledToY) {
    
          let dashLength = lengthArr[i];
    
          let offsetDist = (100 / pathLength) * dashLength;
          pathIcon.style.offsetDistance = offsetDist + "%";
    
          // change dasharray
          strokePath.setAttribute(
            "stroke-dasharray",
            `${dashLength} ${pathLength}`
          );
    
          // stop loop
          break;
        }
      }
    }
    
    /**
     * Get the absolute center/middle y-coordinate
     * of the current scroll viewport
     */
    function getViewportMiddleY() {
      const viewportHeight = window.innerHeight;
      const scrollY = window.scrollY || window.pageYOffset;
      const element = document.documentElement;
      const elementOffsetTop = element.offsetTop;
      const middleY = scrollY + viewportHeight / 2 + elementOffsetTop;
      return middleY;
    }
    
    function SVGToScreen(matrix, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      p = p.matrixTransform(matrix);
      return p;
    }
    html,
    body {
      margin: 0;
      padding: 0;
    }
    
    .svg-path {
      overflow: visible;
      width: 100%;
    }
    
    #pathIcon {
      position: absolute;
      inset: 0;
      width: 5vw;
      height: 5vw;
      offset-rotate: 0deg;
      offset-distance: 10%;
    }
    
    #scrollDiv {
      overflow: auto;
    }
    
    #midline {
      display: block;
      position: absolute;
      width: 100%;
      height: 1px;
      border-top: 1px solid orange;
    }
    <div id="scrollDiv" style="position: relative">
      <svg class="svg-path" viewBox="0 0 0 0" fill="none">
            <defs>
              <path
                id="Path_440"
                d="M1293 2 s-16 74.47-96 91.5c-91.45 19.47-308.67-2.43-424.5 7-227 0-469.89 25.44-493.5 195-11 79-13.89 124.33-11 207.5s1.9 142.65 37 238c41.77 113.46 465 97.5 789.5 91 271.5 10.5 581.52-40 671.5 179 37.18 90.5 0 446.5 0 482.5 0 67.5-70 120-148 134-153 0-429.89 5-614.5 5-271 0-633.97-26.81-691.5 85-40.39 78.5-36 202.5-36 264.5 1.12 92.28-3.45 162.17 36 276 27.86 80.39 409.15 66.5 669 66.5 316 0 696.34-17 758.5 79 54.07 83.5 33.23 212.68 33.23 361.5 0 128-2 232.5-120.73 267-39.33 11.43-415 0-759 0-487.5 0-891-2-891-2"
                stroke="#020878"
                stroke-width="2"
                stroke-miterlimit="10"
                stroke-linecap="round"
                stroke-dasharray="20 20"
              />
            </defs>
            <use
              class="stroke"
              href="#Path_440"
              stroke="#ccc"
              stroke-width="10"
              stroke-dasharray="20 10"
            ></use>
            <use
              class="stroke"
              id="theFill"
              href="#Path_440"
              stroke-dasharray="925.988 9259.88"
              stroke-width="10"
              stroke="#4cacff"
            ></use>
          </svg>
      <svg id="pathIcon" fill="none">
            <rect width="100" height="100" fill="red" fill-opacity="0.5" />
          </svg>
      <div id="midline"></div>
    </div>