Search code examples
javascriptsvgdomparser

isPointInFIll does not work when programatically creating a SVG via DOMParser


I'm trying to programatically create a svg node like this

const parser = new DOMParser()
const doc = parser.parseFromString(mySvgAsString, 'image/svg+xml')
const svg = doc.children[0]

Everything seems to work fine, however when I try to use the isPointInFill method, it always returns false. Like this:

const pathNode = svgNode.children[0]
const point = svgNode.createSVGPoint()
point.x = 5
point.y = 5

pathNode.isPointInFill(p) // always false

I created a fiddle that loads a svg square when iterates throught a 10 * 10 matrix - they all return false.

Fiddle https://jsfiddle.net/9hyp51cg/

I noticed that also functions like getBBox() on the svg return 0 as width / height. I'd suppose this is connected to this behaviour

const $ul = document.getElementById("list")
// basically a square
const svgString = `<svg width="11" height="10" viewBox="0 0 11 10" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 0.5H1V9.5H10V0.5Z" fill="#D9D9D9" stroke="black"/></svg>`
const parser = new DOMParser()
const doc = parser.parseFromString(svgString, 'image/svg+xml')
const svgNode = doc.children[0]
const pathNode = svgNode.children[0]

const point = svgNode.createSVGPoint()

for (let i = 0; i <= 10; i++) {
 for (let j = 0; j <= 10; j++) {
        point.x = i;
    point.y = j;
    
    $ul.innerHTML = $ul.innerHTML + `<li>${i},${j} => ${pathNode.isPointInFill(point)}</li>`;
 }
}

 
<ul id="list"></ul>


Solution

  • Methods like isPointInFill(), isPointInStroke() or getBBox() require elements to be rendered. Otherwise the browser can't calculate point/element intersections. (In fact Firefox can test isPointInFill() for not rendered elements but that's obviously not enough for a cross-browser solution)

    Workaround 1: attach the parsed svg temporarily

    You can hide the svg by applying a style like so
    position:absolute; height:0; width:0.
    This way you don't have to worry about layout shifts.

    const $ul = document.getElementById("list");
    // basically a square
    const svgString = `<svg width="11" height="10" viewBox="0 0 11 10" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 0.5 H1V9.5H10V0.5Z" fill="#D9D9D9" stroke="black"/></svg>`;
    const parser = new DOMParser();
    const doc = parser.parseFromString(svgString, "image/svg+xml");
    const svgNode = doc.children[0];
    const pathNode = svgNode.children[0];
    
    // append svg and hide it
    svgNode.setAttribute("style", "position:absolute; height:0; width:0;");
    document.body.append(svgNode);
    
    const point = svgNode.createSVGPoint();
    point.x = 0;
    point.y = 1;
    
    
    for (let i = 0; i <= 10; i++) {
      for (let j = 0; j <= 10; j++) {
        point.x = i;
        point.y = j;
    
        $ul.innerHTML =
          $ul.innerHTML +
          `<li>${i}, ${j} => ${pathNode.isPointInFill(point)}  </li>`;
      }
    }
    
    //remove svg
    svgNode.remove();
    <ul id="list"></ul>

    Workaround 2: use canvas method isPointInPath()

    The canvas drawing API provides a similar method that doesn't require a rendered element at all.

    let canvas = document.createElement("canvas");
    let ctx = canvas.getContext("2d");
    let d = pathNode.getAttribute("d");
    let pathCanvas = new Path2D(d);
    let pointInPathCanvas = ctx.isPointInPath(pathCanvas, x, y);
    
    

    Another benefit of this approach is that it's usually significantly faster than it's SVG counterpart (especially on chromium/blink based browsers).
    See also "I have a problem with Chrome's version of SVGGeometryElement.isPointInFill() (works in Firefox)"

    const $ul = document.getElementById("list");
    // basically a square
    const svgString = `<svg width="11" height="10" viewBox="0 0 11 10" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 0.5 H1V9.5H10V0.5Z" fill="#D9D9D9" stroke="black"/></svg>`;
    const parser = new DOMParser();
    const doc = parser.parseFromString(svgString, "image/svg+xml");
    const svgNode = doc.children[0];
    const pathNode = svgNode.children[0];
    
    
    // draw svg path to canvas
    let canvas = document.createElement("canvas");
    let ctx = canvas.getContext("2d");
    let d = pathNode.getAttribute("d");
    let pathCanvas = new Path2D(d);
    
    
    for (let i = 0; i <= 10; i++) {
      for (let j = 0; j <= 10; j++) {
    
        let pointInPathCanvas = ctx.isPointInPath(pathCanvas, i, j);
        $ul.innerHTML =
          $ul.innerHTML +
          `<li>${i}, ${j} => ${pointInPathCanvas} </li>`;
      }
    }
    <ul id="list"></ul>

    Workaround 3: ray-cast method

    This concept is explained in detail in this post "How can I determine whether a 2D Point is within a Polygon?" and may be an alternative for headless or virtual DOM environments.

    To apply this ray-cast approach to SVG <path> elements, we need some extra steps:

    1. parse the path data to absolute path commands
    2. approximate beziers and arcs to polygons. As paths may be a compound path - if it contains multiple overlapping/subtracted shapes - so we need an array of polygons
    3. test points in polygon via ray-cast algorithm.
      polygons that are overlapping we interpret multiple intersections as not in fill.

    I've collected my tests in this github repository - not well tested and not ready for production.

    let svg = document.querySelector("svg");
    let path = document.querySelector("path");
    let d = path.getAttribute("d");
    
    //split bezier segments for polygon approximation
    let precision = 12;
    
    let pathData = parsePathDataNormalized(d, {
      arcsToCubic: true
    });
    let compoundPoly = pathDataToCompoundPoly(pathData, precision)
    
    
    
    /**
     * test performance
     * random points
     */
    let maxPoints = 130 * 130;
    let cols = 130;
    let rows = 130;
    let pts = [];
    let x = 0
    let y = 0
    
    for (let i = 0; i <= maxPoints; i++) {
    
      let pt = {
        x: x,
        y: y
      };
    
      x++
      if (x >= cols) {
        x = 0;
        y++
      }
    
      pts.push(pt);
    }
    
    /**
     * 1. raycast speed test
     */
    let pixels = [];
    t0 = performance.now();
    pts.forEach((pt) => {
      let pointInPoly = isPointInFillCompoundPolygon(compoundPoly, pt);
      //let pointInPoly = isPointInFillPathData(d, pt, precision )
      if (pointInPoly) pixels.push(pt);
    });
    
    t1 = performance.now() - t0;
    console.log("points in poly raycast", t1, 'ms total points:', pts.length);
    
    
    pixels.forEach((pt) => {
      renderPoint(svg2, pt, "green", "0.25%");
    });
    
    
    // update transform matrix
    let matrix = svg.getScreenCTM().inverse();
    window.addEventListener("resize", (e) => {
      matrix = svg.getScreenCTM().inverse();
    });
    
    document.addEventListener("mousemove", (e) => {
      // move svg cursor
      let svgCursor = screenToSVG({
        x: e.clientX,
        y: e.clientY
      }, matrix);
      cursor.setAttribute("cx", svgCursor.x);
      cursor.setAttribute("cy", svgCursor.y);
    
      // highlight
      let pointInPoly = isPointInFillCompoundPolygon(compoundPoly, svgCursor);
    
      if (pointInPoly) {
        cursor.setAttribute("fill", "green");
      } else {
        cursor.setAttribute("fill", "red");
      }
    });
    
    
    //just for illustration
    function renderPoint(
      svg,
      coords,
      fill = "red",
      r = "2",
      opacity = "1",
      id = "",
      className = ""
    ) {
      //console.log(coords);
      if (Array.isArray(coords)) {
        coords = {
          x: coords[0],
          y: coords[1]
        };
      }
    
      let marker = `<circle class="${className}" opacity="${opacity}" id="${id}" cx="${coords.x}" cy="${coords.y}" r="${r}" fill="${fill}">
      <title>${coords.x} ${coords.y}</title></circle>`;
      svg.insertAdjacentHTML("beforeend", marker);
    }
    
    /**
     * helper function to translate between
     * svg and HTML DOM coordinates:
     * based on @Paul LeBeau's anser to
     * "How to convert svg element coordinates to screen coordinates?"
     * https://stackoverflow.com/questions/48343436/how-to-convert-svg-element-coordinates-to-screen-coordinates/48354404#48354404
     */
    function screenToSVG(p, matrix) {
      return new DOMPoint(p.x, p.y).matrixTransform(matrix);
    }
    .grd {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 2em
    }
    <h1>Pointilize</h1>
    <p>Move your mouse over the left svg. If it's in fill the fill changes to green.</p>
    
    <div class="grd">
      <svg id="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 130" overflow="visible">
                <path fill="#ccc" class="glyph"
                    d="M28 99.6 L28 99.6 Q19.8 99.6 13.5 95.4 Q7.1 91.2 3.5 83.5 Q0 75.8 0 65.3 L0 65.3 Q0 54.8 3.5 47.3 Q7.1 39.7 13.5 35.7 Q19.8 31.6 28 31.6 L28 31.6 Q36.3 31.6 42.6 35.7 Q48.9 39.7 52.5 47.3 Q56.1 54.8 56.1 65.3 L56.1 65.3 Q56.1 75.8 52.5 83.5 Q48.9 91.2 42.6 95.4 Q36.3 99.6 28 99.6 ZM28 92.3 L28 92.3 Q33.9 92.3 38.3 89 Q42.6 85.6 45 79.6 Q47.4 73.5 47.4 65.3 L47.4 65.3 Q47.4 53.1 42.1 46 Q36.8 38.9 28 38.9 L28 38.9 Q19.2 38.9 13.9 46 Q8.6 53.1 8.6 65.3 L8.6 65.3 Q8.6 73.5 11 79.6 Q13.4 85.6 17.8 89 Q22.2 92.3 28 92.3 ZM85.8 120.8 L85.8 120.8 Q76.9 120.8 71.3 117.4 Q65.7 114 65.7 107.7 L65.7 107.7 Q65.7 104.6 67.6 101.8 Q69.5 98.9 72.8 96.7 L72.8 96.7 L72.8 96.3 Q71 95.2 69.8 93.2 Q68.5 91.2 68.5 88.4 L68.5 88.4 Q68.5 85.3 70.2 83 Q71.9 80.7 73.8 79.4 L73.8 79.4 L73.8 79 Q71.4 77 69.5 73.6 Q67.5 70.2 67.5 65.9 L67.5 65.9 Q67.5 60.6 70 56.7 Q72.5 52.8 76.7 50.7 Q80.9 48.6 85.8 48.6 L85.8 48.6 Q87.8 48.6 89.6 49 Q91.4 49.3 92.7 49.8 L92.7 49.8 L109.6 49.8 L109.6 56.1 L99.6 56.1 Q101.3 57.7 102.5 60.4 Q103.6 63 103.6 66.1 L103.6 66.1 Q103.6 71.3 101.2 75.1 Q98.8 78.9 94.8 81 Q90.8 83 85.8 83 L85.8 83 Q81.9 83 78.5 81.3 L78.5 81.3 Q77.2 82.4 76.3 83.8 Q75.4 85.1 75.4 87.1 L75.4 87.1 Q75.4 89.4 77.3 90.9 Q79.1 92.4 84 92.4 L84 92.4 L93.4 92.4 Q101.9 92.4 106.2 95.2 Q110.4 97.9 110.4 104 L110.4 104 Q110.4 108.5 107.4 112.3 Q104.4 116.1 98.9 118.5 Q93.4 120.8 85.8 120.8 ZM85.8 77.5 L85.8 77.5 Q90 77.5 93.1 74.4 Q96.1 71.2 96.1 65.9 L96.1 65.9 Q96.1 60.6 93.1 57.6 Q90.1 54.6 85.8 54.6 L85.8 54.6 Q81.5 54.6 78.5 57.6 Q75.5 60.6 75.5 65.9 L75.5 65.9 Q75.5 71.2 78.6 74.4 Q81.6 77.5 85.8 77.5 ZM87 115.1 L87 115.1 Q94 115.1 98.2 112.1 Q102.4 109 102.4 105.2 L102.4 105.2 Q102.4 101.8 99.9 100.5 Q97.3 99.2 92.6 99.2 L92.6 99.2 L84.2 99.2 Q82.8 99.2 81.2 99 Q79.5 98.8 77.9 98.4 L77.9 98.4 Q75.3 100.3 74.1 102.4 Q72.9 104.5 72.9 106.6 L72.9 106.6 Q72.9 110.5 76.7 112.8 Q80.4 115.1 87 115.1 Z ">
                </path>
                <circle id="cursor" cx="50%" cy="50%" r="1" fill="red" />
            </svg>
    
      <svg id="svg2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 130" overflow="visible">
            </svg>
    </div>
    
    
    <script src="https://cdn.jsdelivr.net/gh/herrstrietzel/point-in-fill-raycast@main/js/point-in-fill-raycast.js"></script>