Search code examples
javascripthtmlcsssvgd3.js

More accurate bounding box for SVG text elements


I've got a "word cloud" of sorts and am currently trying to make little tooltips when hovering over a text element. However, the "mouseover", "mousemove", and "mouseout" events operate on the "bounding box" of the text element. As an example, in the image below, the word "merry" is taking up a considerable portion of "last", which leads to confusion as the tooltip would display "merry" while hovering over "last".

I'm not really sure how to proceed here. I tried

text {
    pointer-events: painted;
}
/* and also */
svg {
    pointer-events: painted;
}

from MDN but these don't work - the same behavior remains.

This is what my tooltip code looks like but I don't think it's important for this problem:

d3.selectAll("text")
    .on("mouseover", (e) => {
        tooltip
            .style("opacity", 1)
            .html(`<p>${e.target.textContent} used ${e.target.dataset.value} times</p>`)
            .style("left", e.pageX + "px")
            .style("top", e.pageY - 25 + "px");
    })
    .on("mousemove", (e) => {
        tooltip.style("left", e.pageX + "px").style("top", e.pageY - 25 + "px");
    })
    .on("mouseout", () => {
        tooltip.style("opacity", 0);
    });

Try it out yourself below. Hover over the bottom half of "last" and jiggle your mouse up and down. The console will print "merry" and "last" back and forth even if your mouse is always on "last".

document.querySelectorAll("text").forEach((node) => {
    node.onmouseover = (e) => console.log(e.target.textContent);
})
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900">
    <g transform="translate(450, 450)">
        <text data-value="63" text-anchor="middle" transform="translate(34, -59) rotate(0)" style="font-size: 507px; font-family: Impact; fill: #F12046">last</text>
        <text data-value="38" text-anchor="middle" transform="translate(15, 137) rotate(0)" style="font-size: 307px; font-family: Impact; fill: #009DDC">merry</text>
    </g>
</svg>

How can I get the tooltip to only show when the mouse is hovering over the "painted" part of the SVG?

enter image description here


Solution

  • Well... It's not perfect, but I found that using a canvas context and measuring the text is pretty accurate. It's not as how I would hope, but at least it's a reasonable bounding box.

    This is my code to insert in rects that approximate the measured texts' size.

    const ctx = document.createElement("canvas").getContext("2d")!;
    
    document.querySelectorAll("text").forEach((text) => {
        ctx.font = `${text.style.fontSize} ${text.style.fontFamily}`;
    
        const metrics = ctx.measureText(text.textContent!);
        const width = metrics.actualBoundingBoxRight - metrics.actualBoundingBoxLeft;
        const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
    
        const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
    
        rect.dataset.word = text.textContent!;
        rect.dataset.value = text.dataset.value;
    
        console.log(metrics);
    
        rect.setAttributeNS(null, "x", (Number(text.dataset.x) - width / 2).toString());
        rect.setAttributeNS(
            null,
            "y",
            (Number(text.dataset.y) - height / 2 - (metrics as any).hangingBaseline / 2).toString()
        );
        rect.setAttributeNS(null, "width", width.toString());
        rect.setAttributeNS(null, "height", (height * 1.1).toString());
        rect.setAttributeNS(null, "fill", "rgba(0, 0, 0, 0.25)");
    
        text.parentNode!.insertBefore(rect, text.nextSibling);
    });
    

    Then I can just listen for events on the rects only. Sometimes there'll still be part of the word outside of the bounding box, but it's a lot better than doing nothing. Here's the result (rects are colored gray):

    enter image description here

    References: