d3 zoom: pan axis on scroll, pan axis on drag, zoom axis on pinch - d3.js

I'm hoping to configure the zoom behavior of a plot to have three kinds of interaction:
It should be possible to pan from left to right with the scroll wheel.
It should be possible to pan from left to right with mousedown drag events.
it should be possible to zoom in 1-D with the pinch event (i.e. scroll wheel with control key pressed).
Right now, I can get the latter two to work in this Fiddle: https://jsfiddle.net/s1t7mrpw/6/
The zoomed function looks like this:
function zoomed() {
if (d3.event.sourceEvent.ctrlKey || d3.event.sourceEvent.type === 'mousemove') {
view.attr("transform", d3.event.transform);
centerline.call(xAxis.scale(d3.event.transform.rescaleX(x)));
d3.event.transform.y = 0;
g.attr("transform", d3.event.transform);
} else {
current_transform = d3.zoomTransform(view);
current_transform.x = current_transform.x - d3.event.sourceEvent.deltaY;
// what do I do here to pan the axis and update `view`'s transform?
// centerline.call(xAxis.scale(d3.event.transform.rescaleX(x))); ?
// view.attr('transform', current_transform); ?
g.attr('transform', current_transform);
}
}
It uses the rescale nomenclature from these blocks:
https://bl.ocks.org/mbostock/db6b4335bf1662b413e7968910104f0f
https://bl.ocks.org/rutgerhofste/5bd5b06f7817f0ff3ba1daa64dee629d
And it uses d3-xyzoom for independent scaling in the x and y directions.
https://bl.ocks.org/etiennecrb/863a08b5be3eafe7f1d61c85d724e6c4
But I can't figure out how to pan the axis in the else bracket, when the user is just scrolling (without pressing the control key).
I had previously used a separate trigger for wheel.zoom but then I need to handle the control key in that other function as well.
I basically want the default zoom behaviors for mousemove and when the control key is pressed, but panning rather than zooming on scroll when the control key is not pressed.

I am using d3-zoom on this answer as it is more common and part of the d3 bundle. d3-xyzoom, as an add-on module, adds some useful functionality using d3-zoom as a base, so this solution should work with xyzoom with minor revisions - but I don't believe its use is necessary
The key challenge lies in using the wheel to apply a translate change not a scale change. Roughly speaking, when a scroll event without control occurs you grab the transform on the selection (this should be the node) and update it by taking the scroll and creating an x offset from it to translate the view rectangle. But, you don't update the zoomTransform to nullify the change in scale caused by the zoom - so when you pan by dragging, the rectangle changes size because you haven't updated the zoomTransform used by the zoom behavior. Likewise, the translate can be off if it doesn't account for scale.
The disassociation between zoom transform applied to an element as a "transform" attribute and the zoom transform tracked by the zoom behavior is the cause of many headaches on SO - but it allows freer implementation options as one not need use an element's transform attribute to apply a zoom behavior (useful in canvas, zooming unorthodox things like color, etc).
I'm going to propose a basic solution here.
First, the zoom transform tracked by the zoom behavior can be updated by modifying the d3.event.transform object itself (or with d3.zoomTransform(selection.node) where selection is the selection that the zoom was called on originally).
Secondly, as the d3.event.transform object registers scrolling as scaling, we need to override this and convert this scaling into translating - when needed. To do so, we need to keep track of the current (pre-event) translate and scale - so we can modify them manually:
// Keep track of zoom state:
var k = 1;
var tx = 0;
function zoomed() {
var t = d3.event.transform;
// If control is not pressed and a wheel was turned, set the scale to last known scale, and modify transform.x by some amount:
if(!d3.event.sourceEvent.ctrlKey && d3.event.sourceEvent.type == "wheel") {
t.k > k ? tx += 40/k : tx -= 40/k;
t.k = k;
t.x = tx;
}
// otherwise, proceed as normal, but track current k and tx:
else {
k = t.k;
tx = t.x;
}
// Apply the transform:
view.attr("transform", "translate("+[t.x,0]+")scale("+[t.k,1]+")");
axisG.call(xAxis.scale(t.rescaleX(xScale)));
}
Above, I modify the transform t.k and t.k for events where wheeling/scrolling should translate. By comparing t.k with the stored k value I can determine direction: would it normally be a zoom out or zoom in event, and then I can convert this into a change in the current translate value. By setting t.k back to the original k value, I can stop the change in scaling that would otherwise occur.
For other zoom events it is business as usual, except, I store the t.x and t.k values for later, for when wheel events should translate rather than scale.
Note: I've set the y scaling and translate in the transform to a hard coded zero, so we don't need to worry about the y component at all.
var width = 500;
var height = 120;
// Keep track of zoom state:
var k = 1;
var tx = 0;
// Some text to show the transform parameters:
var p = d3.select("body")
.append("p");
// Nothing unordinary here:
var svg = d3.select("body")
.append("svg")
.attr("width",width)
.attr("height",height);
var xScale = d3.scaleLinear()
.domain([0,100])
.range([20,width-20])
var xAxis = d3.axisTop(xScale);
var axisG = svg.append("g")
.attr("transform","translate(0,50)")
.call(xAxis);
var view = svg.append("rect")
.attr("width", 20)
.attr("height",20)
.attr("x", width/2)
.attr("y", 60)
var zoom = d3.zoom()
.on("zoom",zoomed);
svg.call(zoom)
function zoomed() {
var t = d3.event.transform;
// If control is not pressed and a wheel was turned, set the scale to last known scale, and modify transform.x by some amount:
if(!d3.event.sourceEvent.ctrlKey && d3.event.sourceEvent.type == "wheel") {
t.k > k ? tx += 40/k : tx -= 40/k;
t.k = k;
t.x = tx;
}
// otherwise, proceed as normal, but track current k and tx:
else {
k = t.k;
tx = t.x;
}
// Apply the transform:
view.attr("transform", "translate("+[t.x,0]+")scale("+[t.k,1]+")");
axisG.call(xAxis.scale(t.rescaleX(xScale)));
// Show current zoom transform:
p.text("transform.k:" + t.k + ", transform.x: " + t.x);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.6.0/d3.min.js"></script>

Related

D3 zoom breaks mouse wheel zooming after using zoomIdentity

When I use d3-zoom and programatically call the scaleTo function using zoomIdentity I cannot zoom using the mouse wheel anymore.
How do I fix this issue?
https://observablehq.com/d/8a5dfbc7d858a16b
// mouse wheel zoom not working because use of zoomIdentity
chart = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("display", "block");
const zoom = d3Zoom.zoom()
svg.call(zoom);
const zoomArea = svg.append('g');
zoom.on('zoom', (e) => {
zoomArea.attr("transform", e.transform)
})
zoomArea.append('circle')
.attr("cx", width/2)
.attr("cy", height/2)
.attr("r", 20)
zoom.scaleTo(svg, d3Zoom.zoomIdentity)
return svg.node();
}
The second parameter of zoom.scaleTo(svg, d3Zoom.zoomIdentity) accepts a k scaling factor (e.g., 2 for 2x zoom). The method zoom.scaleTo is intended to be used when you want to set the zoom level, but not the translation (x and y positions).
If you want to set the whole transform to the zoom identity (which resets both the zoom level and the x and y positions), the method is zoom.transform(svg, d3Zoom.zoomIdentity).
If you indeed just want to reset the scale, you can use zoom.scaleTo(svg, d3Zoom.zoomIdentity.k), or simply zoom.scaleTo(svg, 1).

How to correctly position html elements in three js coordinate system?

I hopefully have a simple problem I can't get an answer to.
I have three js geometric spheres which move in a box. I place this box at the centre of the scene. The mechanics of how the spheres stay in the box is irrelevant. What is important is the spheres move about the origin (0,0) and the canvas always fills the page.
I want to draw a line from the moving spheres to a div or img element on the page. To do this I would assume I have to transform the css coordinates to three js coordinates. I found something I thought did something like this (Note: Over use of somethings to signify I am probably mistaken)
I can add a html element to the same scene/camera as webgl renderer but obviously using a different renderer but I am unsure how to proceed from there?
Basically I want to know:
How should I change the size of the div preserving aspect ratio if need be?
In essence I want the div or element to fill screen at some camera depth.
How to place the div at the centre of the scene by default?
Mines seems to be shifted 1000 in z direction but this might be the size of the div(img) which I have to bring into view.
How to draw a line between the webgl sphere and html div/img?
thanks in advance!
Unfortunately you have asked 3 questions, it is tricky to address them all at once.
I will explain how to position DIV element on top of some 3D object. My example would be a tooltip that appears when you hover the object by mouse: http://jsfiddle.net/mmalex/ycnh0wze/
So let's get started,
First of all you need to subscribe mouse events and convert 2D coordinates of a mouse to relative coordinates on the viewport. Very well explained you will find it here: Get mouse clicked point's 3D coordinate in three.js
Having 2D coordinates, raycast the object. These steps are quite trivial, but for completeness I provide the code chunk.
var raycaster = new THREE.Raycaster();
function handleManipulationUpdate() {
// cleanup previous results, mouse moved and they're obsolete now
latestMouseIntersection = undefined;
hoveredObj = undefined;
raycaster.setFromCamera(mouse, camera);
{
var intersects = raycaster.intersectObjects(tooltipEnabledObjects);
if (intersects.length > 0) {
// keep point in 3D for next steps
latestMouseIntersection = intersects[0].point;
// remember what object was hovered, as we will need to extract tooltip text from it
hoveredObj = intersects[0].object;
}
}
... // do anything else
//with some conditions it may show or hide tooltip
showTooltip();
}
// Following two functions will convert mouse coordinates
// from screen to three.js system (where [0,0] is in the middle of the screen)
function updateMouseCoords(event, coordsObj) {
coordsObj.x = ((event.clientX - renderer.domElement.offsetLeft + 0.5) / window.innerWidth) * 2 - 1;
coordsObj.y = -((event.clientY - renderer.domElement.offsetTop + 0.5) / window.innerHeight) * 2 + 1;
}
function onMouseMove(event) {
updateMouseCoords(event, mouse);
handleManipulationUpdate();
}
window.addEventListener('mousemove', onMouseMove, false);
And finally see the most important part, DIV element placement. To understand the code it is essential to get convenient with Vector3.project method.
The sequence of calculations is as follows:
Get 2D mouse coordinates,
Raycast object and remember 3D coordinate of intersection (if any),
Project 3D coordinate back into 2D (this step may seem redundant here, but what if you want to trigger object tooltip programmatically? You won't have mouse coordinates)
Mess around to place DIV centered above 2D point, with nice margin.
// This will move tooltip to the current mouse position and show it by timer.
function showTooltip() {
var divElement = $("#tooltip");
//element found and mouse hovers some object?
if (divElement && latestMouseIntersection) {
//hide until tooltip is ready (prevents some visual artifacts)
divElement.css({
display: "block",
opacity: 0.0
});
//!!! === IMPORTANT ===
// DIV element is positioned here
var canvasHalfWidth = renderer.domElement.offsetWidth / 2;
var canvasHalfHeight = renderer.domElement.offsetHeight / 2;
var tooltipPosition = latestMouseProjection.clone().project(camera);
tooltipPosition.x = (tooltipPosition.x * canvasHalfWidth) + canvasHalfWidth + renderer.domElement.offsetLeft;
tooltipPosition.y = -(tooltipPosition.y * canvasHalfHeight) + canvasHalfHeight + renderer.domElement.offsetTop;
var tootipWidth = divElement[0].offsetWidth;
var tootipHeight = divElement[0].offsetHeight;
divElement.css({
left: `${tooltipPosition.x - tootipWidth/2}px`,
top: `${tooltipPosition.y - tootipHeight - 5}px`
});
//get text from hovered object (we store it in .userData)
divElement.text(hoveredObj.userData.tooltipText);
divElement.css({
opacity: 1.0
});
}
}

d3.js v4 svg semantic zooming and panning (pan along both x axis and y axis, zoom only along x axis)

I want to achieve panning the svg objects along both x and y axis and semantic zooming only along x axis.
As long as the d3.event.transform object holds the computed values for both zooming and panning, anytime I call panning and zooming one after each other, the newly translated y values are wrong because they ignore\do not ignore previous opposite actions. (zoom ignores the d3.event.transform.y of pan actions and pan does not ignore d3.event.transform.y of zoom actions)
If you zoom anywhere in the svg area, the circles should only translate the x coordinate and the y coordinate should stay the same as before (taking into account previous pannings)
Now the circles are "jumping" due to wrong y values.
if (isZoom)
{//zoom
return "translate(" + t.apply(d)[0] + "," + (d[1]) +")"; //ignores previous panning-only values and positions to static initial value
}
//panning
return "translate(" + t.apply(d)[0] + "," + (t.y + d[1]) +")"; //how to ignore portion of t.y belonging to previous zooming?
You can uncomment this line of code to prevent circles from jumping but the y is changing while zooming (which should not)
//ignore isZoom and apply both for panning and zooming
return "translate(" + t.apply(d)[0] + "," + (t.y + d[1]) +")";
https://jsfiddle.net/197cz2vj/
Thanks!
UPDATE
Finally I came up with a hack-like solution. I do not like it and I am still looking for a proper solution(I don't like deciding the isZoom action and also the deltaPanY solution. It is all succeptible for future changes in d3 libray). Here it is:
Every time the transformation changes and the change is triggered by the mousemove(panning), update the deltaPanY variable comparing the new value with the old remembered value of the transformation. (I make a copy also of the t.x and t.k but for my purposes only t.y and deltaPanY is necessary).
function copyLastTransform(t)
{
lastTransform =
{
x: t.x,
y: t.y,
k: t.k
};
};
Every time the transform occurs, set the delta variables and store the last transform:
if (isZoom)
{
deltaZoomY += t.y - lastTransform.y;
}
else
{
deltaPanY += t.y - lastTransform.y;
}
copyLastTransform(t);
Translate function looks like this now:
return "translate(" + t.apply(d)[0] + "," + (deltaPanY + d[1]) +")";
Forked fiddle:
https://jsfiddle.net/xpr364uo/

D3 v5 zoom limit pan

I am trying to limit pan in d3 zoom but I am not getting correct results. I am using following code to extent both scale and translate.
var treeGroup = d3.select('.treeGroup');
var rootSVG = d3.select('.rootSVG')
var zoom = d3.zoom()
.scaleExtent([1.6285, 3])
.translateExtent([[0, 0],[800, 600]])
.on('zoom', function(){
treeGroup.attr('transform', d3.event.transform);
})
rootSVG.call(zoom);
Here is the JSFiddle: https://jsfiddle.net/nohe76yd/45/
scaleExtent works fine but translateExtent is giving issues. How do I specify correct value for translateExtent so that while panning content always stays inside the svg container?
The translateExtent works best when used dynamically to the graph group you're using. It takes two arguments: topLeft and bottomRight, which are x and y coordinates each.
In my example, I recalculate the extent based on the graph's size, with the help of getBBox() and adding some margins. Take a look, it might help you: https://bl.ocks.org/agnjunio/fd86583e176ecd94d37f3d2de3a56814
EDIT: Adding the code that does this to make easier to read, inside zoom function.
// Define some world boundaries based on the graph total size
// so we don't scroll indefinitely
const graphBox = this.selections.graph.node().getBBox();
const margin = 200;
const worldTopLeft = [graphBox.x - margin, graphBox.y - margin];
const worldBottomRight = [
graphBox.x + graphBox.width + margin,
graphBox.y + graphBox.height + margin
];
this.zoom.translateExtent([worldTopLeft, worldBottomRight]);

Getting Screen Positions of D3 Nodes After Transform

I'm trying to get the screen position of a node after the layout has been transformed by d3.behavior.zoom() but I'm not having much luck. How might I go about getting a node's actual position in the window after translating and scaling the layout?
mouseOver = function(node) {
screenX = magic(node.x); // Need a magic function to transform node
screenY = magic(node.y); // positions into screen coordinates.
};
Any guidance would be appreciated.
EDIT: 'node' above is a force layout node, so it's x and y properties are set by the simulation and remain constant after the simulation comes to rest, regardless of what type of transform is applied.
EDIT: The strategy I'm using to transform the SVG comes from d3's zoom behavior, which is outlined here: SVG Geometric Zooming.
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.call(d3.behavior.zoom().scaleExtent([1, 8]).on("zoom", zoom))
.append("g");
svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height);
svg.selectAll("circle")
.data(data)
.enter().append("circle")
.attr("r", 2.5)
.attr("transform", function(d) { return "translate(" + d + ")"; });
function zoom() {
svg.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}
It's pretty straightforward. d3's zoom behavior delivers pan and zoom events to a handler, which applies the transforms to the container element by way of the transform attribute.
EDIT: I'm working around the issue by using mouse coordinates instead of node coordinates, since I'm interested in the node position when the node is hovered over with the mouse pointer. It's not exactly the behavior I'm after, but it works for the most part, and is better than nothing.
EDIT: The solution was to get the current transformation matrix of the svg element with element.getCTM() and then use it to offset the x and y coordinates to a screen-relative state. See below.
It appears the solution to my original question looks something like this:
(Updated to support rotation transforms.)
// The magic function.
function getScreenCoords(x, y, ctm) {
var xn = ctm.e + x*ctm.a + y*ctm.c;
var yn = ctm.f + x*ctm.b + y*ctm.d;
return { x: xn, y: yn };
}
var circle = document.getElementById('svgCircle'),
cx = +circle.getAttribute('cx'),
cy = +circle.getAttribute('cy'),
ctm = circle.getCTM(),
coords = getScreenCoords(cx, cy, ctm);
console.log(coords.x, coords.y); // shows coords relative to my svg container
Alternately, this can also be done using the translate and scale properties from d3.event (if rotation transforms are not needed):
// This function is called by d3's zoom event.
function zoom() {
// The magic function - converts node positions into positions on screen.
function getScreenCoords(x, y, translate, scale) {
var xn = translate[0] + x*scale;
var yn = translate[1] + y*scale;
return { x: xn, y: yn };
}
// Get element coordinates and transform them to screen coordinates.
var circle = document.getElementById('svgCircle');
cx = +circle.getAttribute('cx'),
cy = +circle.getAttribute('cy'),
coords = getScreenCoords(cx, cy, d3.event.translate, d3.event.scale);
console.log(coords.x, coords.y); // shows coords relative to my svg container
// ...
}
EDIT: I found the below form of the function to be the most useful and generic, and it seems to stand up where getBoundingClientRect falls down. More specifically, when I was trying to get accurate SVG node positions in a D3 force layout project, getBoundingClientRect produced inaccurate results while the below method returned the circle element's exact center coordinates across multiple browsers.
(Updated to support rotation transforms.)
// Pass in the element and its pre-transform coords
function getElementCoords(element, coords) {
var ctm = element.getCTM(),
x = ctm.e + coords.x*ctm.a + coords.y*ctm.c,
y = ctm.f + coords.x*ctm.b + coords.y*ctm.d;
return {x: x, y: y};
};
// Get post-transform coords from the element.
var circle = document.getElementById('svgCircle'),
x = +circle.getAttribute('cx'),
y = +circle.getAttribute('cy'),
coords = getElementCoords(circle, {x:x, y:y});
// Get post-transform coords using a 'node' object.
// Any object with x,y properties will do.
var node = ..., // some D3 node or object with x,y properties.
circle = document.getElementById('svgCircle'),
coords = getElementCoords(circle, node);
The function works by getting the transform matrix of the DOM element, and then using the matrix rotation, scale, and translate information to return the post-transform coordinates of the given node object.
You can try node.getBBox() to get the pixel positions of a tight bounding box around the node shapes after any transform has been applied. See here for more: link.
EDIT:
getBBox doesn't work quite the way I thought. Since the rectangle is defined in terms of the transformed coordinate space it is always relative to the parent <g> and will therefore always be the same for contained shapes.
There is another function called element.getBoundingClientRect that appears to be quite widely supported and it returns its rectangle in pixel position relative to the top left of the browser view port. That might get you closer to what you want without needing to mess with the transform matrix directly.

Resources