Search code examples
javascriptsvg

Opposite of svg's getPointAtLength(d) - I want getLengthAtPoint(x,y)


A SVGGeometryElement element has a method SVGGeometryElement.getPointAtLength() which returns a DomPoint object (e.g. x and y) at a distance along the path.

I want the opposite. Given an (x,y) coordinate, what is the distance along that path (assuming it intersects)? It seems like I'd have to test all points along the path until it matched/fuzzily matched the x,y target coordinates. Is there an easier way?


Solution

  • As commented by Robert Longson:
    we may have multiple length results for a point (e.g due to self inflections like in a Moebius/infinity loop)

    Example1: lengthAtPoint via lookup

    Here is a sloppy approach to get something like a lengthAtPoint functionality:

    const svg = document.querySelector("svg");
    const pathTmpl = document.getElementById("pathTmpl");
    const strokePath = document.getElementById("stroke");
    
    // steps for pathlength lookup
    let precision = 500;
    let tolerance = 10;
    
    // create length lookup
    let t0 = performance.now()
    let lengthLookup = getLengthLookup(pathTmpl, precision);
    let t1 = performance.now()
    let t2 = t1-t0
    console.log('lookup calculation:', precision+' sample points', t2, 'ms')
    
    
    document.addEventListener("click", (e) => {
      // cursor point
      let pt = new DOMPoint(e.clientX, e.clientY);
    
      // update cursor
      let ptSvg = screenToSVG(svg, pt);
      circle.setAttribute("cx", ptSvg.x);
      circle.setAttribute("cy", ptSvg.y);
    
      // update length
      let lengthAtPoint = getLengthAtPoint(lengthLookup, ptSvg);
    
      // demo illustration: change dasharray
      if (lengthAtPoint) {
        strokePath.setAttribute(
          "stroke-dasharray",
          `${lengthAtPoint} ${lengthLookup.pathLength}`
        );
      }
    });
    
    function getLengthAtPoint(lengthLookup, pt) {
      let lengthAtPoint = 0;
      let { lengthArr, yArr, xArr, pathLength } = lengthLookup;
    
      // find length
      let found = false;
    
      for (let i = 0; i < yArr.length && !found; i++) {
        let x = xArr[i];
        let y = yArr[i];
    
        // compare diviations
        let diffX = Math.abs(pt.x - x);
        let diffY = Math.abs(pt.y - y);
        let diff = (diffX + diffY) / 2;
        let diffMin = Math.min(diffX, diffY);
        let diffMax = Math.max(diffX, diffY);
    
    
        // add tolerance threshold
        let maxDiffRat = 1.5
        if (
          diff < tolerance ||
          (diffX < tolerance && diffY < tolerance * maxDiffRat) ||
          (diffY < tolerance && diffX < tolerance * maxDiffRat)
        ) {
          
          // nearest length with close x/y coordinates
          let length = lengthArr[i];
          
          /**
          * at this point you can certainly 
          * find a smarter interpolation to adjust
          * the actual path length
          */
          lengthAtPoint = (lengthArr[i]) + (diffMax);
    
          // stop loop
          found = true;
        } 
      }
      return lengthAtPoint;
    }
    
    
    
    /**
    * create lookup containing lengths and 
    * coordinates at equidistant
    */
    function getLengthLookup(path, precision = 100) {
      //create pathlength lookup
      let pathLength = path.getTotalLength();
      let lengthLookup = {
        yArr: [],
        xArr: [],
        lengthArr: [],
        pathLength: pathLength
      };
    
      // sample point to calculate Y at pathLengths
      let step = Math.ceil(pathLength / precision);
    
      for (let l = 0; l < pathLength; l += step) {
        //let pt = SVGToScreen(svg, path.getPointAtLength(l));
        let pt = path.getPointAtLength(l);
        lengthLookup.xArr.push(pt.x);
        lengthLookup.yArr.push(pt.y);
        lengthLookup.lengthArr.push(l);
      }
      return lengthLookup;
    }
    
    /** Based on @Paul LeBeau's answer
     * https://stackoverflow.com/questions/48343436/how-to-convert-svg-element-coordinates-to-screen-coordinates#48354404
     */
    function SVGToScreen(svg, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      p = p.matrixTransform(svg.getScreenCTM());
      return p;
    }
    
    function screenToSVG(svg, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      return p.matrixTransform(svg.getScreenCTM().inverse());
    }
    svg {
      overflow: visible;
    }
    <h3>Click on path</h3>
      
      <svg  width="543" height="7907" viewBox="0 0 543 7907" >
        <defs>
          <path fill="none" id="pathTmpl" d="M125.5 1v0c0 78.2 63.4 141.5 141.5 141.5h126.9c25.9 0 51.4 6.9 73.9 19.8v0c45.9 26.5 74.2 75.4 74.2 128.4v22.9l-2.2 20.1c-5.7 53.2-40.2 99-89.8 119.1l-25.3 10.2c-5.1 2.1-9.6 5.3-13.1 9.5v0c-5.2 6.1-8.1 13.9-8.1 22v9.7v0c0 12.8-10.4 23.3-23.3 23.3h-368.7c-5.8 0-10.5 4.7-10.5 10.5v0v405.5v0c0 5.8 4.7 10.5 10.5 10.5h378v0c7.7 0 14 6.3 14 14v384.3v762.9c0 34.2-27.8 62-62 62v0c-34.2 0-62 27.7-62 62v180.8c0 7.2-5.8 13-13 13v0h-249.5c-6.6 0-12 5.4-12 12v0v400v0c0 8.8 7.2 16 16 16h245.5v0c7.2 0 13 5.8 13 13v1129.6c0 53.9-43.8 97.7-97.7 97.7v0c-54 0-97.8 43.8-97.8 97.8v133.4v0c0 3.3 2.7 6 6 6h92.5v0c3.3 0 6 2.7 6 6v417.5c0 2.8-2.2 5-5 5v0h-92.5c-3.9 0-7 3.1-7 7v0v331.7c0 77.8 63.1 141 141 141h36.2c57.9 0 104.8 46.9 104.8 104.8v0v104.5v0c0 7.5-6 13.5-13.5 13.5h-240c-8.6 0-15.5 6.9-15.5 15.5v0v376.5v0c0 9.9 8.1 18 18 18h233v0c9.9 0 18 8.1 18 18v549.5c0 9.9-8.1 18-18 18v0h-237c-6.9 0-12.5 5.6-12.5 12.5v0v381v0c0 9.1 7.4 16.5 16.5 16.5h233v0c9.9 0 18 8.1 18 18v681.9c0 53-43 96-96 96v0c-53 0-96 43-96 96v139.6  
                " />
          </path>
        </defs>
        <use href="#pathTmpl" stroke-width="0.25%" stroke-dasharray="0" stroke="#ccc"></use>
        <use id="stroke" href="#pathTmpl" stroke-dasharray="0 100000" stroke-width="0.25%" stroke="red"></use>
      
      <circle id="circle" cy="0" cx="0" r="0.5%" fill="green" fill-opacity="0.5"></circle>
      </svg>

    How it works

    1. Measure path and save data to a lookup

    getPointAtLength() isn't per se slow but quite expensive when called hundreds or thousands of times.
    We should avoid calling it again and again if we can reuse point coordinates for further processing and also avoid unnecessarily detailed sample point intervals (e.g. iterating by +1 length unit - as a path can easily be > 10 0000 units long).

    Therefore, we calculate coordinates at equidistant sample points and save length, x and y to a lookup object.
    We can use this lookup later to find a suitable length based on the target x/y coordinates.

    2. Tolerance threshold

    We need to define an error tolerance because we most likely don't have the exact coordinates.
    In the above example we return an "on-point" length if

    • both xTarget/xLookup and yTarget/yLookup diffs are below tolerance = 10 user units
    • OR xTarget/xLookup diff is below threshold and yTarget/yLookup diff is smaller tolerance * 2

    The function will stop once it found a length/coordinate match within the defined tolerance - subsequent matches are ignored.

    It is an arbitrary tolerance threshold. For a better accuracy you can decrease the tolerance and increase the number of sample points (currently 500 – pathlength ~ 10000 units)

    You may also combine this function with isPointInStroke() to exclude off-path coordinates beforehand. Albeit this may be more expensive than a sloppy x/y coordinate diff check.

    Main challenge: point-to-length relation

    Although we can quite efficiently calculate many point coordinates deploying the appropriate formula for lines/polylines/polygons (linear interpolation – LERP) and quadratic or cubic bézier curves (De Casteljau algorithm) – we can't easily relate these points to actual lengths when it comes to béziers. See also "Finding points on curves in HTML 5 2d Canvas context" and Primer on Bézier Curves §24 Arc length

    So the natively supported getPointAtlength() method already has to apply some complex calculations for a decent approximation – a native lengthAtPoint() method would be a potential performance killer.


    Alternatives to native getPointAtLength() for better performance

    There are JS libraries developed mainly to provide getTotalLength() and getPointAtLength() functionality in headless or virtual DOM environments such as node.js or react e.g. svg-path-properties.
    In fact these "emulations" can often provide a better performance if you need to calculate > 100 points.

    svg-path-properties for instance reads important path metrics once

    const properties = new path.svgPathProperties("M0,100 Q50,-50 100,100 T200,100");
    

    and generates subsequent points based on this data

    const point = properties.getPointAtLength(200);
    

    Generating many points this way is significantly faster than running native getPointAtLength() – as the latter will start from scratch each time we call it (parse path data, measure segment lengths etc).

    The above example generating 500 sample points at length via native method can easily take more than 200ms whereas svg-path-properties would need ~ 5–10ms (depending on your browser and device)

    Example 2: pointAtlength() altenative

    The following example is based on my own experimental path length script which shares a lot of concepts with svg-path-properties.

    We can easily increase accuracy by calculating 10 000 samples to improve length-at-point accuracy – albeit ~ 2000–5000 should be enough and unnecessarily high precision will also introduce higher memory usage. All in all – we need to find a balance between accuracy and performance.

    const svg = document.querySelector("svg");
    const pathTmpl = document.getElementById("pathTmpl");
    const strokePath = document.getElementById("stroke");
    
    // steps for pathlength lookup
    let precision = 10000;
    let tolerance = 10;
    
    // get pathLength alternative
    t0 = performance.now()
    // lookup for path length calculations
    let lengthLookup = getPathLengthLookup(pathTmpl.getAttribute('d'))
    
    // lookup for point at length calculations
    let pointAtLengthLookup = getPointAtLengthLookup(lengthLookup, precision)
    
    t1 = performance.now()
    t2 = t1 - t0
    console.log('pathLength alternative:', precision, 'sample points', t2, 'ms')
    
    
    document.addEventListener("click", (e) => {
      // cursor point
      let pt = new DOMPoint(e.clientX, e.clientY);
    
      // update cursor
      let ptSvg = screenToSVG(svg, pt);
      circle.setAttribute("cx", ptSvg.x);
      circle.setAttribute("cy", ptSvg.y);
    
      // update length
      let lengthAtPoint = getLengthAtPoint(pointAtLengthLookup, ptSvg);
    
      // demo illustration: change dasharray
      if (lengthAtPoint) {
        strokePath.setAttribute(
          "stroke-dasharray",
          `${lengthAtPoint} ${lengthLookup.totalLength}`
        );
      }
    });
    
    
    function getLengthAtPoint(lengthLookup, pt) {
      let lengthAtPoint = 0;
      let {
        lengthArr,
        yArr,
        xArr,
        pathLength
      } = lengthLookup;
    
    
      // find length
      let found = false;
    
      for (let i = 0; i < yArr.length && !found; i++) {
        let x = xArr[i];
        let y = yArr[i];
    
        // compare diviations
        let diffX = Math.abs(pt.x - x);
        let diffY = Math.abs(pt.y - y);
        let diffMin = Math.min(diffX, diffY)
        let diffMax = Math.max(diffX, diffY)
        let diffTolerance = (tolerance - diffMax);
    
    
        // simple average diff
        let diff = (diffX + diffY) / 2;
    
        // add tolerance threshold
        let maxDiffRat = 1.5
        if (
          diff <= tolerance ||
          (diffX <= tolerance && diffY <= tolerance * maxDiffRat) ||
          (diffY <= tolerance && diffX <= tolerance * maxDiffRat)
        ) {
    
          // nearest length with close x/y coordinates
          let length = lengthArr[i];
    
          // interpolate length based on length deviation
          let lengthPrev = lengthArr[i - 1] ? lengthArr[i - 1] : length;
          lengthAtPoint = (lengthArr[i]) + (diffMax)
    
          // stop loop
          found = true;
        }
      }
      return lengthAtPoint;
    }
    
    
    /**
     * create lookup containing lengths and 
     * coordinates at equidistant
     */
    function getPointAtLengthLookup(lengthLookup, precision = 100) {
      //create pathlength lookup
      let pathLength = lengthLookup.totalLength;
      let lengthAtPointLookup = {
        yArr: [],
        xArr: [],
        lengthArr: [],
        pathLength: pathLength
      };
    
      // sample point to calculate Y at pathLengths
      let step = Math.ceil(pathLength / precision);
    
      for (let l = 0; l < pathLength; l += step) {
        //let pt = SVGToScreen(svg, path.getPointAtLength(l));
        let pt = lengthLookup.getPointAtLength(l);
        lengthAtPointLookup.xArr.push(pt.x);
        lengthAtPointLookup.yArr.push(pt.y);
        lengthAtPointLookup.lengthArr.push(l);
      }
      return lengthAtPointLookup;
    }
    
    /** Based on @Paul LeBeau's answer
     * https://stackoverflow.com/questions/48343436/how-to-convert-svg-element-coordinates-to-screen-coordinates#48354404
     */
    function SVGToScreen(svg, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      p = p.matrixTransform(svg.getScreenCTM());
      return p;
    }
    
    function screenToSVG(svg, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      return p.matrixTransform(svg.getScreenCTM().inverse());
    }
    svg {
      overflow: visible;
    }
    <script src="https://cdn.jsdelivr.net/npm/svg-getpointatlength@1.0.5/getPointAtLengthLookup.js"></script>
    
    
    <svg width="543" height="7907" viewBox="0 0 543 7907">
            <defs>
                <path fill="none" id="pathTmpl" d="M125.5 1v0c0 78.2 63.4 141.5 141.5 141.5h126.9c25.9 0 51.4 6.9 73.9 19.8v0c45.9 26.5 74.2 75.4 74.2 128.4v22.9l-2.2 20.1c-5.7 53.2-40.2 99-89.8 119.1l-25.3 10.2c-5.1 2.1-9.6 5.3-13.1 9.5v0c-5.2 6.1-8.1 13.9-8.1 22v9.7v0c0 12.8-10.4 23.3-23.3 23.3h-368.7c-5.8 0-10.5 4.7-10.5 10.5v0v405.5v0c0 5.8 4.7 10.5 10.5 10.5h378v0c7.7 0 14 6.3 14 14v384.3v762.9c0 34.2-27.8 62-62 62v0c-34.2 0-62 27.7-62 62v180.8c0 7.2-5.8 13-13 13v0h-249.5c-6.6 0-12 5.4-12 12v0v400v0c0 8.8 7.2 16 16 16h245.5v0c7.2 0 13 5.8 13 13v1129.6c0 53.9-43.8 97.7-97.7 97.7v0c-54 0-97.8 43.8-97.8 97.8v133.4v0c0 3.3 2.7 6 6 6h92.5v0c3.3 0 6 2.7 6 6v417.5c0 2.8-2.2 5-5 5v0h-92.5c-3.9 0-7 3.1-7 7v0v331.7c0 77.8 63.1 141 141 141h36.2c57.9 0 104.8 46.9 104.8 104.8v0v104.5v0c0 7.5-6 13.5-13.5 13.5h-240c-8.6 0-15.5 6.9-15.5 15.5v0v376.5v0c0 9.9 8.1 18 18 18h233v0c9.9 0 18 8.1 18 18v549.5c0 9.9-8.1 18-18 18v0h-237c-6.9 0-12.5 5.6-12.5 12.5v0v381v0c0 9.1 7.4 16.5 16.5 16.5h233v0c9.9 0 18 8.1 18 18v681.9c0 53-43 96-96 96v0c-53 0-96 43-96 96v139.6  
                    " />
                </path>
            </defs>
            <use href="#pathTmpl" stroke-width="0.25%" stroke-dasharray="0" stroke="#ccc"></use>
            <use id="stroke" href="#pathTmpl" stroke-dasharray="0 100000" stroke-width="0.25%" stroke="red"></use>
    
            <circle id="circle" cy="0" cx="0" r="0.5%" fill="green" fill-opacity="0.5"></circle>
        </svg>

    Alternative path length libraries