'Detect a click on a SVG line even at a distance of 3 pixels
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?
Solution 1:[1]
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>
Solution 2:[2]
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.
Solution 3:[3]
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>
Solution 4:[4]
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>
Solution 5:[5]
wrapping it in a Native Web Component <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>
Solution 6:[6]
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>
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | syduki |
| Solution 2 | the Hutt |
| Solution 3 | |
| Solution 4 | |
| Solution 5 | |
| Solution 6 | user8133129 |
