Detect a click on a SVG line even at a distance of 3 pixels
Asked Answered
D

6

9

Here is how I detect clicks on SVG lines:

window.onmousedown = (e) => {
    if (e.target.tagName == 'line') {
        alert();  // do something with e.target
    }
}
svg line:hover { cursor: pointer; }
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svg">
<line x1="320" y1="160" x2="140" y2="00" stroke="black" stroke-width="2"></line>
<line x1="140" y1="00" x2="180" y2="360" stroke="black" stroke-width="2"></line>
<line x1="180" y1="360" x2="400" y2="260" stroke="black" stroke-width="2"></line>
<line x1="00" y1="140" x2="280" y2="60" stroke="black" stroke-width="2"></line>
</svg>

It only works if the mouse cursor is precisely on the line, which is not easy, so it's a bad UX.

How to detect a click on a SVG line from Javascript, even if not perfectly on the line, but at a distance of <= 3 pixels?

Dislodge answered 31/3, 2022 at 21:34 Comment(4)
And what if in the proximity of 3px is another line?Moskowitz
@Moskowitz Then take the closest. If equal distance, then it doesn't matter, it could take either, it would be fine.Dislodge
svg line:hover { stroke-width: 6px; }, not very nice but does the trick to an extent.Moskowitz
@Moskowitz No, it receives a stroke-width of 6px only when we are precisely on the line (and not before when we are close to the line). Try with 16px instead, you will see what I mean ;)Dislodge
M
10

A bit tricky solution, but does the job:

window.onmousedown = (e) => {
    if (e.target.classList.contains('line')) {
        console.log(e.target.href);
    }
}
svg .line:hover {
  cursor: pointer;
}
.line {
  stroke: black;
  stroke-width: 2px;
}
.line.stroke {
  stroke: transparent;
  stroke-width: 6px;
}
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svg">
    <defs>
      <line id="line1" x1="320" y1="160" x2="140" y2="00"></line>
      <line id="line2" x1="140" y1="00" x2="180" y2="360"></line>
      <line id="line3" x1="180" y1="360" x2="400" y2="260"></line>
      <line id="line4" x1="00" y1="140" x2="280" y2="60"></line>
    </defs>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line1" class="line stroke"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line1" class="line"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line2" class="line stroke"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line2" class="line"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line3" class="line stroke"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line3" class="line"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line4" class="line stroke"></use>
    <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#line4" class="line"></use>
</svg>
Moskowitz answered 31/3, 2022 at 22:22 Comment(5)
Great @syduki! Can we also do it with a structure <svg ...> <line id="line1" class="line"> <use ... xlink:href="#line1" class="line stroke">, without having to use a defs block? I tried it without success. Can we avoid the <defs>?Dislodge
@Dislodge Definitely, this is a good optimization :). You can even drop the xmlns:xlink="http://www.w3.org/1999/xlink" xlink: also as xlink seems to be deprecated nowadaysMoskowitz
Why do we have to do 3 elements : <line id="line1">, and two <use>? Can we do it with two elements only: <line id="line1"> + 1 <use> only for the transparent part? When I do this the original line isn't visible. Why @syduki?Dislodge
@Dislodge I did a bit of research, seems that it cannot be done without defs since the use cannot overwrite the stroke attributes of line. This is explained hereMoskowitz
Great finding @syduki, you're right, the use cannot override the stroke. I have a similar SVG problem in case you have an idea ;) #71700857Dislodge
E
8

A solution with just one <line> and some JavaScript would be interesting.

We can use existing Web API document.elementFromPoint(x, y). It returns topmost element at given point.
Form user click point we can travel along each axis and find first <line> element using the method. We stop the search when we get a line or we reach maximum search distance.

In following demo no extra elements have been created. The variable proximity controls the max distance from a line to consider it for selection.
Bonus feature: the nearest line to the mouse pointer is highlighted. So user can easily click on desired line without any hassle.

const proximity = 8;

const directions = [
  [0, 0],
  [0, 1], [0, -1],
  [1, 1], [-1, -1],
  [1, 0], [-1, 0],
  [-1, 1], [1, -1]
];

// tracks nearest line
let currentLine = null;

// highlight nearest line to mouse pointer
container.onmousemove = (e) => {
  let line = getNearestLine(e.clientX, e.clientY);
  if (line) {
    if (currentLine !== line)
      currentLine?.classList.remove('highlight');

    currentLine = line;
    currentLine.classList.add('highlight');
    container.classList.add('pointer');
  } else {
    currentLine?.classList.remove('highlight');
    currentLine = null;
    container.classList.remove('pointer')
  }
}

container.onclick = (e) => {
  // we already know in 'onmousemove' which line is the nearest
  // so no need to figure it out again.
  log.textContent = currentLine ? currentLine.textContent : '';
}

// find a nearest line within 'proximity'
function getNearestLine(x, y) {
  // move along each axis and see if we land on a line
  for (let i = 1; i <= proximity; i++) {
    for (let j = 0; j < directions.length; j++) {
      const xx = x + directions[j][0] * i;
      const yy = y + directions[j][1] * i;
      const element = document.elementFromPoint(xx, yy);
      if (element?.tagName == 'line')
        return element;
    };
  }
  return null;
}
svg {
  background-color: wheat;
}

.pointer {
  cursor: pointer;
}

.highlight {
  filter: drop-shadow(0 0 4px black);
}

#log {
  user-select: none;
}
<p>Clicked on: <span id="log"></span></p>
<svg id='container' width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svg">
    <line x1="320" y1="160" x2="140" y2="00" stroke="red" stroke-width="2">1</line>
    <line x1="140" y1="00" x2="180" y2="360" stroke="green" stroke-width="2">2</line>
    <line x1="18" y1="60" x2="400" y2="60" stroke="orange" stroke-width="2">3</line>
    <line x1="00" y1="140" x2="280" y2="60" stroke="blue" stroke-width="2">4</line>
  </svg>

This is just a demo code you can get rid of unwanted stuff. If you don't want hand to show when in proximity then delete onmousemove and move the logic to onclick method.
Only filter: drop-shadow(...) can highlight non-square shapes. Otherwise, you can change line width or color etc.

Entablement answered 4/4, 2022 at 6:56 Comment(0)
I
5

Just do the maths...

This is probably overkill, but the exactness of those 3 pixels bothered me so here's an "all about the math's" solution.

getLinesInRange(point, minDist,svg) will return ALL lines in range of the minDist. It is currently applying a class to all lines in range with mousemove. Click shows an array of all lines in range sorted by distance having the closest line first.

One caveat, this will not work in svg's where any internal scaling or offset positioning is being performed.

UPDATE: Now doesn't care about any SVG mutations like scaling and offset.

UPDATE 2 The question of speed has been brought up so I've decided to demonstrate how quickly it actual does calculations. One thing computers are good at is crunching numbers. The only real slowdown is when it's applying a drop-shadow to 150+ lines, however, this is a limitation of the render and not the maths, with a small modification you can just apply the effect to the closest line only. Now you can add up to 1000 lines to test.

//Distance Calculations
const disToLine = (p, a, b) => {
    let sqr = (n) => n * n,
        disSqr = (a, b) => sqr(a.x - b.x) + sqr(a.y - b.y),
        lSqr = disSqr(a, b);
  if (!lSqr) return disSqr(p, a);
  let t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / lSqr;
  t = Math.max(0, Math.min(1, t));
  return Math.sqrt(
    disSqr(p, { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) })
  );
};

//Calculates the absolute coordinates of a line
const calculateAbsoluteCords = (line) => {
    let getSlope = ([p1, p2]) => (p1.y - p2.y) / (p1.x - p2.x),
        rec = line.getBoundingClientRect(),
      coords = [
          { x: +line.getAttribute("x1"), y: +line.getAttribute("y1") },
          { x: +line.getAttribute("x2"), y: +line.getAttribute("y2") }];
  if (getSlope(coords) <= 0)
    coords = [ 
      { x: rec.x, y: rec.y + rec.height },
      { x: rec.x + rec.width, y: rec.y }
    ];
  else
    coords = [
      { x: rec.x, y: rec.y },
      { x: rec.x + rec.width, y: rec.y + rec.height }
    ];
  return coords;
};

//gets all lines in range of a given point
const getLinesInRange = (point, minimumDistance, svg) => {
  let linesInRange = [],
    lines = svg.querySelectorAll("line");
  lines.forEach(line => {
    let [p1, p2] = calculateAbsoluteCords(line),
      dis = disToLine(point, p1, p2);
    if (dis <= minimumDistance) {
      line.classList.add("closeTo");
      linesInRange.push({ dis: dis, line: line });
    } else line.classList.remove("closeTo");
  });
  return linesInRange.sort((a,b) => a.dis > b.dis ? 1 : -1).map(l => l.line);
};

let minDist = 3, el = {};
['mouseRange', 'rangeDisplay', 'mouseRangeDisplay', 'numberOfLines', 'numberInRange', 'numberOfLinesDisplay', 'clicked', 'svgContainer']
    .forEach(l => {el[l] = document.getElementById(l); })

el.svgContainer.addEventListener("mousemove", (e) => {
  el.numberInRange.textContent = getLinesInRange({ x: e.clientX, y: e.clientY }, minDist, el.svgContainer).length;
});

el.svgContainer.addEventListener("click", (e) => {
  let lines = getLinesInRange({ x: e.clientX, y: e.clientY }, minDist, el.svgContainer);
  el.clicked.textContent = lines.map((l) => l.getAttribute("stroke")).join(', ');
});

el.mouseRange.addEventListener("input", () => {
  minDist = parseInt(el.mouseRange.value);
  el.mouseRangeDisplay.textContent = minDist;
});

el.numberOfLines.addEventListener("input", () => {
  let numOfLines = parseInt(el.numberOfLines.value);
  el.numberOfLinesDisplay.textContent = numOfLines;
  generateLines(numOfLines);
});

let generateLines = (total) => {
    let lineCount = el.svgContainer.querySelectorAll('line').length;
  if(lineCount > total) {
    let lines = el.svgContainer.querySelectorAll(`line:nth-last-child(-n+${lineCount-total})`);
    lines.forEach(l => l.remove());
  }
  for(let i=lineCount; i<total; i++) {
    var newLine = document.createElementNS('http://www.w3.org/2000/svg','line')
    newLine.setAttribute('id','line2');
    ['x1','y1','x2','y2'].map(attr => newLine.setAttribute(attr,Math.floor(Math.random()*500)));
    newLine.setAttribute("stroke", '#' + Math.floor(Math.random()*16777215).toString(16));
    el.svgContainer.appendChild(newLine);
  }
}
generateLines(10);
.closeTo {
  filter: drop-shadow(0 0 3px rgba(0,0,0,1));
}
Range: <input type="range" min="1" max="50" id="mouseRange" value="3" /><span id="mouseRangeDisplay">3</span>
#Lines: <input type="range" min="0" max="1000" id="numberOfLines" value="10" step="10" /><span id="numberOfLinesDisplay">10</span>
In Range: <span id="numberInRange">3</span>
<br/>
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svgContainer" style="width:500px;height:500px;background:#F1F1F1;">
</svg><br/>
Clicked: <span id="clicked"></span>
Insincerity answered 7/4, 2022 at 16:5 Comment(2)
If there are 100 lines in the SVG then this will do the calculations for all 100 lines on each mousemove event.Entablement
@theHutt Yes it would, and in testing I saw no slowdown. However, the question was about getting the closest clicked element, so under use it'll have one iteration per click.Insincerity
S
3

Using multiple elements

In general, you can use an svg group ('g' element), and include two elements, with one bigger and an opacity of 0 or a stroke/fill of transparent.

document.querySelectorAll('g.clickable').forEach(node => node.addEventListener('click', function() {
  alert();
}))
svg .clickable:hover { cursor: pointer; }
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svg">
<g class="clickable">
<line x1="320" y1="160" x2="140" y2="0" stroke="black" stroke-width="2"></line>
<line x1="320" y1="160" x2="140" y2="0" stroke="transparent" stroke-width="16" opacity="0"></line>
</g>
</svg>

Automatically doing this

Using two elements with the same coordinates is a bit redundant. In practice, probably you'd want to construct elements based from dynamic data (particularly if you're doing data-driven graphics), or you can programmatically iterate through all of the existing lines and then replace them with group elements.

I'll show the second, since that's what the question seems to be asking:

var svgNS = 'http://www.w3.org/2000/svg';
document.querySelectorAll('svg line').forEach(function (node) {
  if (svg.parentNode.classList.contains('clickable-line')) {
    return;
  }
  var g = document.createElementNS(svgNS, 'g');
  g.classList.add('clickable-line');
  var displayLine = node.cloneNode();
  var transparentLine = node.cloneNode();
  g.appendChild(displayLine);
  g.appendChild(transparentLine);
  transparentLine.setAttributeNS(null, 'stroke-width', '20');
  transparentLine.setAttributeNS(null, 'opacity', '0');
  
  g.addEventListener('click', function () {
    // do something with `node` or `g`
    alert();
  });
  node.parentNode.replaceChild(g, node);
});
svg .clickable-line:hover {
  cursor: pointer
}
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svg">
<line x1="320" y1="160" x2="140" y2="0" stroke="black" stroke-width="2"></line>
<line x1="140" y1="0" x2="180" y2="360" stroke="black" stroke-width="2"></line>
</svg>
Sickroom answered 3/4, 2022 at 16:59 Comment(0)
B
3

wrapping it in a Native Web Component (JSWC) <svg-lines>

Supported in all Browsers. So you can reuse it anywhere you want

  • taking the best parts from other answers

<svg-lines margin="30">
  <svg>
    <style> line { stroke-width:2 }  </style>
    <line x1="320" y1="160" x2="140" y2="00" stroke="red"  >1</line>
    <line x1="140" y1="0"  x2="180" y2="360" stroke="green" >2</line>
    <line x1="18"  y1="60"  x2="400" y2="60" stroke="orange">3</line>
    <line x1="00"  y1="140" x2="280" y2="60" stroke="blue"  >4</line>
  </svg>
</svg-lines>

<script>
  customElements.define("svg-lines", class extends HTMLElement {
    connectedCallback() {
      setTimeout(() => { // wait till lightDOM is parsed
        this.querySelector("svg")
          .append(Object.assign(
              document.createElement("style"), {
                innerHTML: `.hover { filter:drop-shadow(0 0 4px black) }
                       .hoverline {stroke-width:${this.getAttribute("margin")||20}; 
                                   opacity:0; cursor:pointer }`
              }),
            ...[...this.querySelector("svg")
                       .querySelectorAll("[stroke]")
               ].map((el) => {
                  let hover = el.cloneNode();
                  hover.classList.add("hoverline");
                  hover.onmouseenter = () => el.classList.add("hover");
                  hover.onmouseout = () => el.classList.remove("hover");
                  hover.onclick = () => alert("clicked line#" + el.innerHTML);
                  return hover;
            }));
      })
    }
  })
</script>
Benefield answered 5/4, 2022 at 13:14 Comment(0)
A
1

Make two copies of the line, group them together, and increase the stroke width of the second line in CSS also set stroke: transparent to hide second line, now you will get clickable area wider. I hope you find this is the best method.

document.querySelectorAll('#svg g').forEach((item) => {
    item.addEventListener('click', (e) => {
        const index = Array.from(item.parentNode.children).indexOf((item))
        console.log(index+1);
    })
})
g{
  cursor: pointer;
}
line{
  stroke: black;
  stroke-width: 2px;
}
line:nth-child(2) {
  stroke-width: 1em;
  stroke: transparent;
}
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" id="svg">
      <g>
        <line x1="320" y1="160" x2="140" y2="00"></line>
        <line x1="320" y1="160" x2="140" y2="00"></line>
      </g>
      <g>
        <line x1="140" y1="00" x2="180" y2="360"></line>
        <line x1="140" y1="00" x2="180" y2="360"></line>
      </g>
      <g>
        <line x1="00" y1="140" x2="280" y2="60"></line>
        <line x1="00" y1="140" x2="280" y2="60"></line>
      </g>
</svg>
Apologize answered 10/4, 2022 at 9:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.