D3 - Positioning tooltip on SVG element not working - svg

I have a webpage with an SVG. On some of its shapes I need to display a tooltip. However, I can't get the tooltip to appear where it should, just some pixels away from the shape itself.
It appears way on the right hand side of the screen, maybe some 300px away.
The code I am using to get the coordinates is as follows:
d3.select("body")
.select("svg")
.select("g")
.selectAll("circle")
.on("mouseover", function(){return tooltip.style("visibility", "visible");})
.on("mousemove", function(){
var svgPos = $('svg').offset(),
/*** Tooltip ***/
//This should be the correct one, but is displaying at all working at all.
/*x = svgPos.left + d3.event.target.cx.animVal.value,
y = svgPos.top + d3.event.target.cy.animVal.value;*/
//This displays a tool tip but way much to the left of the screen.
x = svgPos.left + d3.event.target.cx.animVal.value,
y = svgPos.top + d3.event.target.cy.animVal.value;
Tooltip
window.alert("svgPos: "+svgPos+" top: "+y+"px left: "+x+"px "+d3.event.target.cx.animVal.value);
return tooltip.style("top", x+"px").style("left",y+"px");
})
.on("mouseout", function(){return tooltip.style("visibility", "hidden");});
I got to this code following this SO post.
I have changed $(ev.target).attr(cx) as it is not returning a value on my machine; d3.event.target.cx is, even though it seems it is not affecting the end result anyway.
What am I doing wrong? Could somebody help me please? Thank you very much in advance for your time.

If your tooltip is an HTML element, then you want to position it relative to the page as a whole, not the internal SVG coordinates, so accessing the cx/cy value is just complicating things. I can't say for sure without looking at your code, but if you have any transforms on your <svg> or <g> elements, then that could be what's throwing you off.
However, there is a much easier solution. Just access the mouse event's default .pageX and .pageY properties, which give the position of the mouse relative to the HTML body, and use these coordinates to position your tooltip div.
Example here: http://fiddle.jshell.net/tPv46/1/
Key code:
.on("mousemove", function () {
//console.log(d3.event);
return tooltip
.style("top", (d3.event.pageY + 16) + "px")
.style("left", (d3.event.pageX + 16) + "px");
})
Even with rotational transforms on the SVG circles, the mouse knows where it is on the page and the tooltip is positioned accordingly.
There are other ways to do this, including getting a tooltip to show up in a fixed location relative to the circle instead of following the mouse around, but I just checked the examples I was working on and realized they aren't cross-browser compatible, so I'll have to standardize them and get back to you. In the meantime, I hope this gets you back on track with your project.
Edit 1
For comparison, here is the same example implemented with both an HTML tooltip (a <div> element) and an SVG tooltip (a <g> element).
http://fiddle.jshell.net/tPv46/4/
The default mouse event coordinates may be great for positioning HTML elements that are direct children of <body>, but they are less useful for positioning SVG elements. The d3.mouse() function calculates the mouse coordinates of the current event relative to a specified SVG element's coordinate system, after all transformations have been applied. It can therefore be used to get the mouse coordinates in the form we need to position an SVG tooltip.
Key code:
.on("mousemove", function () {
var mouseCoords = d3.mouse(
SVGtooltip[0][0].parentNode);
//the d3.mouse() function calculates the mouse
//position relative to an SVG Element, in that
//element's coordinate system
//(after transform or viewBox attributes).
//Because we're using the coordinates to position
//the SVG tooltip, we want the coordinates to be
//with respect to that element's parent.
//SVGtooltip[0][0] accesses the (first and only)
//selected element from the saved d3 selection object.
SVGtooltip
.attr("transform", "translate(" + (mouseCoords[0]-30)
+ "," + (mouseCoords[1]-30) + ")");
HTMLtooltip
.style("top", (d3.event.pageY + 16) + "px")
.style("left", (d3.event.pageX + 16) + "px");
})
Note that it works even though I've scaled the SVG with a viewBox attribute and put the tooltip inside a <g> with a transform attribute.
Tested and works in Chrome, Firefox, and Opera (reasonably recent versions) -- although the text in the SVG tooltip might extend past its rectangle depending on your font settings. One reason to use an HTML tooltip! Another reason is that it doesn't get cut off by the edge of the SVG.
Leave a comment if you have any bugs in Safari or IE9/10/11. (IE8 and under are out of luck, since they don't do SVG).
Edit 2
So what about your original idea, to position the tooltip on the circle itself? There are definite benefits to being able to position the tip exactly: better layout control, and the text doesn't wiggle around with the mouse. And most importantly, you can just position it once, on the mouseover event, instead of reacting to every mousemove event.
But to do this, you can no longer just use the mouse position to figure out where to put the tooltip -- you need to figure out the position of the element, which means you have to deal with transformations. The SVG spec introduces a set of interfaces for locating SVG elements relative to other parts of the DOM.
For converting between two SVG transformation systems you use SVGElement.getTransformToElement(SVGElement); for converting between an SVG coordinate system and the screen, you use SVGElement.getScreenCTM(). The result are transformation matrices from which you can
extract the net horizontal and vertical translation.
The key code for the SVG tooltip is
var tooltipParent = SVGtooltip[0][0].parentNode;
var matrix =
this.getTransformToElement(tooltipParent)
.translate(+this.getAttribute("cx"),
+this.getAttribute("cy"));
SVGtooltip
.attr("transform", "translate(" + (matrix.e)
+ "," + (matrix.f - 30) + ")");
The key code for the HTML tooltip is
var matrix = this.getScreenCTM()
.translate(+this.getAttribute("cx"),
+this.getAttribute("cy"));
absoluteHTMLtooltip
.style("left",
(window.pageXOffset + matrix.e) + "px")
.style("top",
(window.pageYOffset + matrix.f + 30) + "px");
Live example: http://fiddle.jshell.net/tPv46/89/
Again, I'd appreciate a confirmation comment from anyone who can test this in Safari or IE -- or any mobile browser. I'm pretty sure I've used standard API for everything, but just because the API is standard doesn't mean it's universally implemented!

Related

How to create zoomable text

I am looking to add zoomable text to the rendered image in OpenSeadragon. The desired functionality is that when you are zoomed out, the text is very small, and as you zoom in, the text increases in size.
I have tried to add text to an overlay div DOM element. This adds text, but as you zoom out, the size of the div gets smaller but the text doesn't. The text gets squeezed to multiple lines.
I use the Render2 class in Angular 7 to manipulate the DOM, but this can be done in any framework/library:
// create the DOM element need for SeaDragon's overlay
let div = this.renderer.createElement('div');
const text = this.renderer.createText(`x: ${xaxis}, y: ${yaxis}`);
// append text to div element
this.renderer.appendChild(div, text);
// add id for Seadragon overlay creation and styling of overlay
this.renderer.setAttribute(div, 'id', overlayname);
this.renderer.setAttribute(div, 'class', 'image-ruler');
// now append the div tag to ViewChild div
this.renderer.appendChild(this.singleOverlay.nativeElement, div);
I expect the text to zoom with the image. Is this possible?
I figured out how to do it. I saw this example that uses an svg plugin so d3 can be used to draw an overlay. After downloading this plugin, I noticed that when I printed text on the screen, it was incredibly large. Too large to read.
As d3 at this zoom level uses the scale of 0-1 for the size of the image, I had to divide the scale by the width of my image.
var p = viewer.viewport.pixelFromPoint(new OpenSeadragon.Point(0, 0), true);
var zoom = viewer.viewport.getZoom(true);
var rotation = viewer.viewport.getRotation();
var imageSize = viewer.world.getItemAt(0).getContentSize();
var scale = viewer.viewport._containerInnerSize.x * zoom / imageSize.x;
g.attr('transform',
'translate(' + p.x + ',' + p.y + ') scale(' + scale + ') rotate(' + rotation + ')');
I then reimplemented the plugin's resize function in my component as not to modify a node module. The result was small text on my enormous image that was readable when you zoom in.

Moving an SVG clip path

I'm trying to get the svg clip path to move with the svg element, but the two seem to be attached. I'm basically looking to make a mouse moveable clip, and it needs to all be implemented in javascript. What's going on?
var clip = document.getElementById('svgPath');
var goggles = document.getElementById('gogglesMain');
var blur = document.getElementById('blur');
var mouse = {x:100, y:100};
//mouse listener to move goggles
document.addEventListener('mousemove', mouseListen, false);
function mouseListen(e){
mouse.x = e.clientX || e.pageX;
mouse.y = e.clientY || e.pageY;
gogglesMain.style.left = mouse.x - 300 + "px";
gogglesMain.style.top = mouse.y - 100 + "px";
}
https://jsfiddle.net/9n414sxs/
I'm not clear on exactly what you are trying to do, but it sounds like you think that moving the SVG, that contains a <clipPath>, will also move the clipped area of the div you are clipping with it.
That is not the case. Any clipPath that you are using from CSS is totally independent of the position of the SVG that it is a part of. You are just borrowing the clipPath definition from it.
If you want to change the area that's clipped, you would have to move the clipPath itself. Unfortunately, that doesn't seem to be reliable at the moment.
For example, the following demo almost works.
Demo here

D3js: finding path's bounding box (without getBBox() )?

The following code works on Chromium :
var node = window.d3.selectAll('#L1 > *:nth-child(2)');
var bbox = node.node().getBBox();
console.log(bbox) // {height: 44, width: 44, y: -13, x: 144}
but not with nodejs + jsdom:
"TypeError: Object [ PATH ] has no method 'getBBox' "
M. Bostock pointed out that JSDOM doesn't support getBBox()
What D3js replacement to use to get the bounding box of #L1 > *:nth-child(2) ?
Past efforts lead me there : getBBox() based fiddle
Path's bounding box
Digging straight into the element's path data d="..." should work. An svg line is basically a set of x,y points. Assuming absolute coordinates without translation nor big bezier curves, which is the case of my D3js-generated svg lines, I'am finding in this data the min and max values for both x and y.
To do so, I get the d="..." svg line or multilines code. For simplicity sake, I rudely removes possible relative jumps such h30 or v20 since I never saw any in my D3js output, then clean out letters (aka svg commands : M,L,H,V,C,S,Q,T,A,Z), simplify the spaces and line jumps, then split by the remaining spaces. I get a clean arrays of coordinates.
Important to note, my selector directly target a single non-translated path.
var getBBox = function(selector){
var xmin, xmax, ymin, ymax,p;
// clean up path
var t = d3.select(selector).attr("d"); // get svg line's code
console.log(t)
t = t.replace(/[a-z].*/g," ") // remove relative coords, could rather tag it for later processing to absolute!
.replace(/[\sA-Z]+/gi," ").trim().split(" "); // remove letters and simplify spaces.
console.log(t)
for(var i in t){ // set valid initial values
if(t[i].length>1){
p = t[i].split(",");
xmin = xmax = p[0]; ymin = ymax = p[1]; }
}
for(var i in t){ // update xmin,xmax,ymin,ymax
p = t[i].split(",");
if(!p[1]){ p[0]=xmin; p[1] = ymin;} // ignore relative jumps such h20 v-10
xmin = Math.min(xmin, p[0]);
xmax = Math.max(xmax, p[0]);
ymin = Math.min(ymin, p[1]);
ymax = Math.max(ymax, p[1]);
} return [[xmin,ymax],[xmax,ymin]]; // [[left, bottom], [right, top]] as for https://github.com/mbostock/d3/wiki/Geo-Paths#bounds
}
var bb = getBBox("path");
JSfiddle DEMO
Groups bounding boxes
For groups of multiple paths, you may want to traverse the svg DOM to loop upon each single path of the group in order to update xmin, ymin, xmax, ymax.
Translated elements
To handle translated elements, adapt further.
Alternatives
Other better approaches may exist. Remember to check if getBBox() and getBoundingClientRect() are available in your context, since they are native and very convenient.
The reason why getBBox/getBoundingClientRect/getClientRect does not work in NodeJS+JSDOM is that calculating these values of an SVG (or HTML) element involves massive amounts of computation.
First, all CSS code in <style> elements must be parsed (which is already not trivial). Then the CSS selectors, cascading and inheritance rules must be applied to know what size, position or line width an element has. And even after you know all style property values, you need to do some non-trivial maths to calculate the bounding boxes: definition of different SVG transform functions, compositions of these, bounding boxes of SVG primitives and Bezier curves. Browsers support all of these (they have to, in order to draw the element), but JSDOM is simply not meant for all of these.
But fortunately, canvg is a JavaScript implementation of most of SVG, which uses a <canvas> element to draw the image. It does support most of the above, and although it does not have an interface for giving you those data, fortunately it has very nice (and MIT licensed) code, so hopefully you can copy and reuse parts of it. As of now, the code is written in a single file, and it has CSS parsing, applying cascading rules, path data parsing, definitions of SVG transforms, applying transformations, and bezier curve bounding box calculation. That is, almost everything you need to calculate bounding boxes :) It does not, however, support CSS selectors, but it can reuse another library. But unfortunately, as far as I can tell, canvg is not ready for running in NodeJS, you probably need some tweaks.
There is, however canvgc, an SVG to JS compiler, which contains an older version of canvg, and it is capable of running in NodeJS. So it is easier to start with that.

how to get text bounding box in famo.us

I am attempting to draw an SVG bezier curve that starts at the end of a text string that is in a Surface. I can set the size of the Surface to [true, true], which is supposed to make the size equal the text bounding box. But if I later try "mySurface.size[0]" in order to get the width, it returns "true"! I need to get a number for the width and height of that bounding box, in order to calculate the end point of my bezier curve! The equivalent DOM approach would just be to use the .getBBox() function.. how do I do this in Famo.us?
this is maybe because the surface hasn't rendered yet. there are a few similar questions here, one of them from me:
how can we get the size of a surface within famo.us?
you could also try deferring or using a setTimeout or Engine.nextTick() to check the size on the next loop through.
if you find an elegant solution let us know as this is a big problem in many places using famous - having to do multiple highjinks where you can't really position a scene on the initial setup - you have to let it render and then adjust...
You can use the 'getSize' function and specify 'true' to get the real size of the surface:
var realSize = surface.getSize(true);
#ljzerenHein, thanks for the hint.. unfortunately, surface.getSize(true) returns null!
#dcsan, thanks for the link. I believe you may be right, however the solution linked to ends up being much too involved for me.
After much searching, hacking, and experimenting, I've settled on the following approach:
-] use the DOM to get untransformed bounding boxes for text strings
-] format the text strings in SVG form
-] make it so the strings are invisible (set fill and stroke to none)
-] reuse the same "div" element for all the strings that I want to measure
-] once I have the untransformed bounding box, then set the famous surface size to that and then apply modifiers.
-] if I need the bounding box after all transforms have been applied, then get the total accumulated transforms for the surface and multiply that with the original untransformed bounding box
Here's the code to create the DOM element, insert SVG text, then get the bounding box:
//Do this part once, of creating a DOM element and adding it to the document
var el1 = document.createElement("div");
document.body.appendChild(el1); //only need to append once -- just set innerHTML after
//now set the SVG text string -- from this point down can be repeated for multiple
// strings without removing or re-adding the element, nor fiddling with the DOM
var text1_1_1_SVG = '<svg> <text x="0" y="0" style="font-family: Arial; font-size: 12;fill:none;stroke:none" id="svgText1">' + myFamousSurface.content + '</text> </svg>';
//note the id is inside the text element! Also the fill and stroke are null so nothing paints
el1.innerHTML = text1_1_1_SVG;
//now get the element -- this seems to be what triggers the bounding box calc
var test = document.getElementById("svgText1"); //this is ID in the text element
//get the box, take the values out of it, and display them
var rect = test.getBoundingClientRect();
var str = "";
for (i in rect) { //a trick for getting all the attributes of the object
str += i + " = " + rect[i] + " ";
}
console.log("svgText1: " + str);
FYI, all of the SVGTextElement methods seem to be callable upon gotElem.
SVGTextElement docs here: http://msdn.microsoft.com/en-us/library/ie/ff972126(v=vs.85).aspx
#seanhalle
I'm pretty sure .getSize(true) is returning null because the element has not yet been added to the DOM. Keep in mind that famo.us is synchronized with animation-frames, and updates to the DOM happen don't happen instantly. Accesssing the DOM directly (aka pinging) is strongly disadviced because you will loose the performance benefits that famo.us promises.
What I would do is create a custom view to wrap your surface inside and implement a render-method in it. In the render-method, use getSize(true) to get the size. If it returns null,
you know it has not yet been committed to the DOM.
view in action as jsfiddle
define('MyView', function (require, exports, module) {
var View = require('famous/core/View');
var Surface = require('famous/core/Surface');
function MyView() {
View.apply(this, arguments);
this.surface = new Surface();
this.add(this.surface);
}
MyView.prototype = Object.create(View.prototype);
MyView.prototype.constructor = MyView;
MyView.prototype.render = function() {
var size = this.getSize(true);
if (size) {
if (!this.hasSize) {
this.hasSize = true;
console.log('now we have a size: ' + size);
}
this.surface.setContent('Size: ' + JSON.stringify(size));
} else {
console.log('no size yet');
}
return this._node.render();
};
module.exports = MyView;
});

SVG geometric zoom issue in IE9+ and webkit (d3.js)

I have quite complex SVG created in Inkscape. It has couple layers(groups), <path>, <rect>, <circle> and <text> elements inside. I want to add ability of zooming for greater details, so I tried to implement geometric zoom like in this Mike's example: SVG Geometric Zooming. And it works as expected in Firefox, but in IE9+ and webkit based browsers I can see transform attribute changing, but picture stays the same.
Here is code for zoom:
var svg = d3.select("svg#svgCanvas");
var zoom = d3.behavior.zoom()
.scaleExtent([1, 3])
.on("zoom", zooming);
svg.call(zoom);
function zooming() {
svg.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}
Any ideas why it can fails?
Issue was caused by Inkscape's layers, so changing them to simple <g> elements by deleting inkscape:groupmode = layer and other unnecessary attributes fixed all.

Resources