SVG <text> element bounding box changes on transform - javascript

As I understand it, getBBox() is supposed to ignore the transformations applied to the element it's measuring.
eg: if I have: <g transform="scale(0.5, 0.5)"><rect width="100" height="100" /></g>, calling getBBox on <g> will always return width: 100, height: 100, no matter what the scale() args are. However, this does NOT appear to be the case when using a <text> element instead of <rect>. The size of <text> reported by getBBox() changes as the transformation changes.
Full example:
const gNode = document.querySelector("g");
const dimensionsBB = () => {
console.log(gNode.getBBox());
}
// trigger every time a change in size is detected
const resizeObserver = new ResizeObserver(dimensionsBB);
resizeObserver.observe(gNode);
// update transformation every second
setInterval(() => {
gNode.setAttribute("transform", `scale(${Math.random()}, ${Math.random()})`);
}, 1000);
<svg width="500" height="500">
<g>
<text>Text goes here</text>
</g>
</svg>
In this example, the resizeObserver is triggered each time the scale changes, and the dimensions reported by getBBox() are different each time (with slight variations).
Why? And is there a way to always get a consistent result?

Related

How to get the exact BBox for svg <tspan>

I am trying to figure out why getBBox() for tspan element of a svg does not return the dimension.
To demonstrate this with an example, if I run BBox on both tsp1 and rect1, it returns the correct dimension for rect1 but not for tsp1
var tsp = document.getElementById('tsp1');
var tspBBox = tsp.getBBox();
var rect = document.getElementById('rect1');
var rectBBox = rect.getBBox();
console.log(tspBBox);
console.log(rectBBox);
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<text class="t1" id="t1" font-size="20" font-family="PT Mono" text-decoration="underline">
<tspan class="tsp1" id="tsp1" x="10.23" y="135.05">Abc ef ghi</tspan>
</text>
<rect class="rect1" id="rect1" x="9.23" y="112.73368530273439" height="31.546314697265625" width="1" fill="orange" />
</svg>
I was expecting BBox to return the exact x and y for tsp1 but it does not.
I don't know why. I need to pass on the exact values to the succeeding class dynamically.
How can javascript return the exact dimension for the tspan element?
There are a number of methods for measuring text, and they are a bit more complex than defining a simple box. This is because with the dx, dy and rotate attributes, each addressable character can be be positioned individually - moved and rotated in every direction. Therefore, it makes more sense to answer the question where a single character is positioned, and where, after completing one sequence, the next character would be positioned.
In your case none of the above attributes are set ( on the <tspan> or <text> element). In this case is is possible to retrieve the start position of the <tspan> with .getStartPositionOfChar(0) and the horizontal width with .getComputedTextLength().* The height according to the font metrics is the same for all characters in the tspan, so it is enough to return one .getExtentOfChar(0) - 0 refers to the first character within the sequence of addressable characters.
As chrwahl pointed out in his answer, the start position refers to the font-specific baseline and normally will not be identical to the top left corner of a bounding box.
*There is a subtle trick here: if the letter-spacing or word-spacing CSS properties were defined, the "length" returned would not only return the width from the start of the first character to the end of the last, but also would add (or subtract) a spacing value that is defined after the end of the string. In other words: despite its name, the method returns the relative horizontal start position of the next character after the string examined.
var tsp = document.getElementById('tsp1');
var tspPos = tsp.getStartPositionOfChar(0);
console.log('start position', tspPos.x, tspPos.y);
console.log('horizontal advance', tsp.getComputedTextLength());
console.log('vertical extent', tsp.getExtentOfChar(0).height);
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<text class="t1" id="t1" font-size="20" font-family="PT Mono" text-decoration="underline">
<tspan class="tsp1" id="tsp1" x="10.23" y="135.05">Abc ef ghi</tspan>
</text>
</svg>
It is all about the dominant-baseline. So, there is a differences between where the text is placed according to the dominant-baseline and the box that the text takes up. The value text-before-edge will place the text according to the upper left corner of the box.
var tsp = document.getElementById('tsp1');
var tspBBox = tsp.getBBox();
var rect = document.getElementById('rect1');
var rectBBox = rect.getBBox();
console.log('tspBBox', tspBBox.x, tspBBox.y);
console.log('rectBBox', rectBBox.x, rectBBox.y);
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 100 400 200">
<text class="t1" id="t1" font-size="20" font-family="PT Mono"
text-decoration="underline" dominant-baseline="text-before-edge">
<tspan class="tsp1" id="tsp1" x="10.23" y="135.05">Abc ef ghi</tspan>
</text>
<rect class="rect1" id="rect1" x="9.23" y="112.73368530273439" height="31.546314697265625" width="1" fill="orange" />
</svg>

Add text to SVG path dynamically

I have an SVG exported from Adobe Illustrator with several paths like this, which produces a small polygon I intend to use as a text box
<svg viewbox="387 390 74 20">
<g>
<path class="st37" d="M452,408h-56c-4.42,0-8-3.58-8-8l0,0c0-4.42,3.58-8,8-8h56c4.42,0,8,3.58,8,8l0,0 C460,404.42,456.42,408,452,408z" />
</g>
</svg>
I'd like to dynamically add text to it. I've seen many similar questions here, but most of them involed specifying a x and y property for a text element based on the x and y property the path element. My path, however, does not have such properties.
I've tried to use a textPath element with xlink:href pointing to my path. I gets attached to the path, but the text wraps my path element instead of being inside it.
So, is there a way to achieve this? I'm open to different solutions here. My dream would be to use javascript to get the path element and add the text from there. But I could also edit the base SVG file to add a text or any other relevant element and attributes to make this work, as long as I can change the text dynamically from javascript later. And since this SVG is produced by Illustrator, I could also try different export options there in order to get a proper output for my goal.
Here's some sample code that takes a label path and adds a <text> element after it with whatever text you choose.
let label1 = document.querySelector("#label1");
addLabelText(label1, "Something");
function addLabelText(bgPath, labelText)
{
let bbox = bgPath.getBBox();
let x = bbox.x + bbox.width / 2;
let y = bbox.y + bbox.height / 2;
// Create a <text> element
let textElem = document.createElementNS(bgPath.namespaceURI, "text");
textElem.setAttribute("x", x);
textElem.setAttribute("y", y);
// Centre text horizontally at x,y
textElem.setAttribute("text-anchor", "middle");
// Give it a class that will determine the text size, colour, etc
textElem.classList.add("label-text");
// Set the text
textElem.textContent = labelText;
// Add this text element directly after the label background path
bgPath.after(textElem);
}
.st37 {
fill: linen;
}
.label-text {
font-size: 10px;
fill: rebeccapurple;
transform: translate(0, 3px); /* adjust vertical position to centre text */
}
<svg viewbox="387 390 74 20">
<g>
<path id="label1" class="st37" d="M452,408h-56c-4.42,0-8-3.58-8-8l0,0c0-4.42,3.58-8,8-8h56c4.42,0,8,3.58,8,8l0,0 C460,404.42,456.42,408,452,408z" />
</g>
</svg>
Since you can edit your base SVG align
create a proper SVG to work with
Your path is a background label starting (red circle) at a large offset x=452 y=408
I would recreate it,
starting at the green circle, (editor: https://yqnn.github.io/svg-path-editor/)
in a viewBox="0 0 80 20"
To get single coordinates for both backgroundlabel and (blue) textPath line
after that use JavaScript to add text dynamically.
No need for text x,y calculations, pathLength and startoffset do the work
Or if you go fancy you can create the blue line dynamically from getBBox()
That will also work with your current path; just more calculations required
It is all about coordinates,
and positioning the blue line (with stroke="transparent" then):
playground:
<svg viewbox="387 390 74 20">
<path fill="lightgreen" d="M452,408h-56c-4.42,0-8-3.58-8-8l0,0c0-4.42,3.58-8,8-8h56c4.42,0,8,3.58,8,8l0,0C460,404.42,456.42,408,452,408z" />
<circle cx="452" cy="408" r="2" fill="red"/>
<circle cx="388" cy="400" r="2" fill="green"/>
<path id="P" pathLength="100" d="M388 400h72" stroke="blue"/>
<text>
<textPath href="#P" startoffset="50" text-anchor="middle" dominant-baseline="middle"
fill="green" font-size="14px">My Text</textPath>
</text>
</svg>
Thanks for the answers! I end up using a tweaked version of Paul LeBeau's function to take into account the structure suggested by Danny '365CSI' Engelman so I don't have to use translate to center the text vertically.
let label = document.querySelector("#mylabel");
addLabelTextPath(label, "Something");
function addLabelTextPath(bgPath, labelText) {
let bbox = bgPath.getBBox();
let x = bbox.x + bbox.width / 2;
let y = bbox.y + bbox.height / 2;
// Create the path line
let pathElem = document.createElementNS(bgPath.namespaceURI, "path");
pathElem.setAttribute("pathLength", 100);
pathElem.setAttribute("d", `M${bbox.x} ${y}h${bbox.width}`);
pathElem.id = `baseline-${bgPath.id}`;
// Create a <text> element
let textElem = document.createElementNS(bgPath.namespaceURI, "text");
// Create a <textPath> element
let textPath = document.createElementNS(bgPath.namespaceURI, "textPath");
textPath.setAttribute("href", `#${pathElem.id}`);
//Center text
textPath.setAttribute("dominant-baseline", "Middle");
textPath.setAttribute("startOffset", 50);
textPath.setAttribute("text-anchor", "middle");
// Give it a class that will determine the text size, colour, etc
textPath.classList.add("label-text");
// Set the text
textPath.textContent = labelText;
// Add the elements directly after the label background path
bgPath.after(pathElem);
pathElem.after(textElem);
textElem.appendChild(textPath);
}
.st37 {
fill: lightblue;
}
.label-text {
font-size: 10px;
fill: darkblue;
}
<svg viewbox="387 390 74 20">
<g>
<path id="mylabel" class="st37" d="M452,408h-56c-4.42,0-8-3.58-8-8l0,0c0-4.42,3.58-8,8-8h56c4.42,0,8,3.58,8,8l0,0 C460,404.42,456.42,408,452,408z" />
</g>
</svg>

Setting D3 svg.transition to go from slow to fast to slow

I have a D3 graph that allows a user to click a button to take them to a specified node. The button looks like this:
<button type="button" class="btn btn-primary" ng-click="ctrl.panGraph(9)">Go to End</button>
This button will take the user from wherever they are in the svg at the time of click, to the x and y coordinates of the last node, with the id of 9. On click this function is called:
function panGraph (nodeId:any) {
svgWidth = parseInt(svg.style("width").replace(/px/, ""), 10);
svgHeight = parseInt(svg.style("height").replace(/px/, ""), 10);
for (var i = 0; i < renderedNodes.length; i++) {
if (nodeID === renderedNodes[i].id) {
ctrl.selectedNode = renderedNodes[i];
var translate = [svgWidth / 2 - renderedNodes[i].x, svgHeight / 2 - renderedNodes[i].y];
var scale = 1;
svg.transition().duration(4000).ease(d3.easeExpInOut).call(zoom.translate(translate).scale(scale).event);
}
}
}
In the above function I have all the rendered nodes that have been rendered on the page, once I find the matching id I use its x and y coordinates to center the specified node in the middle of the svg. That all works fine.
I am trying to use some animations during the time that the graph is translating to the specified node on button click. When the user clicks the button that takes him or her to the specified node, is it possible to animate the transition so that the transition initially starts slow, then speeds up, but then slows down again at the end as it gets close to the specified node? Thanks
UPDATE:
The above code with the "ease" incluided gives me this console error:
angular.js:13550 TypeError: Cannot read property 'indexOf' of undefined
at Object.d3.ease (d3.js:5844)
at Array.d3_transitionPrototype.ease (d3.js:8838)
at zoomOnNode (DiagramComponent.ts:1128)
at DiagramComponent.ts:1072
at Scope.$digest (angular.js:17073)
at Scope.$apply (angular.js:17337)
at HTMLButtonElement.<anonymous> (angular.js:25023)
at HTMLButtonElement.dispatch (jquery.js:4737)
at HTMLButtonElement.elemData.handle (jquery.js:4549)
Here is the v3 equivalent to Gerardo's post regarding v4:
svg.transition().duration(1000).ease("exp-in-out").call(zoom.translate(translate).scale(scale).event);
For a list of all the easing equivalents from v3 to v4 and other changes:
https://github.com/d3/d3/blob/master/CHANGES.md
One (out of several) solution is to use ease with d3.easeExpInOut, or d3.easePolyInOut.exponent(x) with a high exponent (like x=4 or x=5).
See this snippet. Click the circle to see it moving from left to right, starting slow, speeding up and then slowing down again:
d3.select("circle").on("click", function(){
d3.select(this).transition()
.duration(4000)
.ease(d3.easeExpInOut)
.attr("cx", 360)
});
<script src="https://d3js.org/d3.v4.min.js"></script>
<svg width="400" height="200">
<circle cx="40" cy="100" r="30" fill="teal"></circle>
<line x1="40" x2="40" y1="100" y2="150" stroke="black" stroke-width="1"></line>
<line x1="360" x2="360" y1="100" y2="150" stroke="black" stroke-width="1"></line>
</svg>

How can I map click events to elements in multiple layers?

I have multiple SVG elements that are in separate groups. They overlap each other. Example:
<svg id="board" width="100%" height="80%">
<g id="terrain" class="layer">
<path d="M-32,-32L32,-32 32,32 -32,32Z" transform="translate(0, 0)" class="mote terrain hill"></path>
</g>
<g id="guy" class="layer">
<path d="M-21...Z" transform="translate(192, 448)" class="mote guy"></path>
</g>
</svg>
When an x, y position that matches both is clicked, I want to know all that both were clicked. If I bind each to the 'click' event, only the event handlers for one on top gets called. Which is reasonable, although not what I want here.
I'm thinking of creating a topmost layer and having that catch all clicks, then figure out which elements in the other layers should be notified. That's a lot of tracking that I'd like to avoid, if possible. Are there simpler approaches to this?
From The SVG spec
"By default, pointer-events must not be dispatched on the clipped (non-visible) regions of a shape. For example, a circle with a radius of 10 which is clipped to a circle with a radius of 5 will not receive 'click' events outside the smaller radius. Later versions of SVG may define new properties to enable fine-grained control over the interactions between hit testing and clipping."
However, there is a way of getting a list of svg shapes that intersect at a particular point. The "getIntersectionList" function returns a list of items.
I've created one of those jsfiddle things jsfiddle.net/uKVVg/1/ Click on the intersection of the circles to get a list of ID's. Manually send events to that list.
Javascript follows:
function s$(a) {
return document.getElementById(a);
}
var list
function hoverElement(evt) {
var root = s$("canvas");
var disp = s$("pointer");
disp.setAttribute("x",evt.clientX);
disp.setAttribute("y",evt.clientY);
rpos = root.createSVGRect();
rpos.x = evt.clientX;
rpos.y = evt.clientY;
rpos.width = rpos.height = 1;
list = root.getIntersectionList(rpos, null);
s = "clicked: "
$.each(list,function(item,val){
if (val.id != "pointer") {
s = s + (val.id) + " ";
}
})
alert(s);
}
var root = s$("canvas");
root.addEventListener("click", hoverElement, false);
There's some javascript that could probably be tidied up, but hopefully it answers your question.

Highlighting an area of an image map based on search text

This is a new question feeding from another question that was just answered here.
I am working to highlight a <div> based on search text. We've accomplished that, thanks to Alex.
Now, I'm trying to apply the same concept to mapped coordinates on an image map.
There's a jsfiddle here.
Here's the JS(jQuery 1.10.2)...
function doSearch(text) {
$('#content div').removeClass('highlight');
$('#content div:contains(' + text + ')').addClass('highlight');
}
If you want a method without SVG, you can use the Maphilight jQuery plugin (GitHub).
I have updated your jsFiddle.
function doSearch(text) {
$('#content div').removeClass('highlight');
$('#content div:contains(' + text + ')').addClass('highlight');
$('#Map area').mouseout();
$('#Map area[data-text*="' + text + '"]').mouseover();
}
$(function() {
$('#imgmap').maphilight({ stroke: false, fillColor: "ffff00", fillOpacity: 0.6 });
});
Note: For a better result just use a bigger image, because your bunny.jpg is too small and you have forced its size with height/width attributes.
It is not possible with image-maps and area elements, because those are non visible elements, that cannot have child elements, nor styles. You would have to do it a lot more complicated like described here
But it is possible using modern embeded SVGs - Almost every browser does support it nowadays. Even IE.
I tested it with Chromium and Firefox.
It cannot be done with the help of jQuery as far as I know but with usual Javascript. The key is:
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="663px" height="663px">
<image xlink:href="http://webfro.gs/south/kb2/images/bunny.jpg" x="0" y="0" width="663" height="663" />
<circle class="office" cx="504" cy="124" r="94" />
<circle class="fire-exit" cx="168" cy="150" r="97" />
<circle class="main-exit" cx="378" cy="589" r="48" />
</svg>
_
var svgns="http://www.w3.org/2000/svg";
var areas = document.getElementsByTagNameNS(svgns, 'circle');
$(areas).each(function(elem) {
if(areas[elem].className.baseVal === text) {
areas[elem].className.baseVal += ' highlightsvg';
} else {
areas[elem].className.baseVal = areas[elem].className.baseVal.replace(' highlightsvg', '');
}
});
See here in the JSFiddle. Is that the way you want it?

Categories