d3.js version 4 - rect zoom - cannot get the transformation right - javascript

d3.js verison 4:
I have a line chart, which should have a rectangle zoom.
I used this example: http://bl.ocks.org/jasondavies/3689931
I don't want to apply the rectangle data to the scales, like in the example
Instead I want to apply this to my normal zoom Element.
For that I have the math:
.on("mouseup.zoomRect", function() {
d3.select(window).on("mousemove.zoomRect", null).on("mouseup.zoomRect", null);
d3.select("body").classed("noselect", false);
var m = d3.mouse(e);
m[0] = Math.max(0, Math.min(width, m[0]));
m[1] = Math.max(0, Math.min(height, m[1]));
if (m[0] !== origin[0] && m[1] !== origin[1]) {
//different code here
//I have the scale factor
var zoomRectWidth = Math.abs(m[0] - origin[0]);
scaleFactor = width / zoomRectWidth;
//Getting the translation
var translateX = Math.min(m[0], origin[0]);
//Apply to __zoom Element
var t = d3.zoomTransform(e.node());
e.transition()
.duration(that.chart.animationDuration)
.call(that.chart.zoomX.transform, t
.translate(translateX, 0)
.scale(scaleFactor)
.translate(-translateX, 0)
);
}
rect.remove();
refresh();
}, true);
So I actually get the scaleFactor right and it zooms in smoothly.
Only problem is, that I don't seem to get the translation correct.
So it zooms in to the wrong position.

So, now I got it right:
All transformations by earlier zooms need to be undone.
so that k = 1, x = 0, y = 0;
This is the d3.zoomIdentity.
From that point the current zoom needs to be applied and afterwards the translation.
After that the old transform needs to be applied, first translate and then scale
var t = d3.zoomTransform(e.node());
//store x translation
var x = t.x;
//store scaling factor
var k = t.k;
//apply first rect zoom scale and then translation
//then old translate and old zoom scale
e.transition()
.call(that.chart.zoomX.transform, d3.zoomIdentity
.scale(scaleFactor)
.translate(-translateX, 0)
.translate(x)
.scale(k)
);
Working Fiddle only for X-Axis here: https://jsfiddle.net/9j4kqq1v/3/
Working fiddle for X and Y-axis here: https://jsfiddle.net/9j4kqq1v/5/

Related

Accurate pan and zoom to svg node

I am trying to pan and zoom to a svg node using d3js. But I cannot get my head around the math here.
If I force the desired zoom level to be 1, then I seem to get it right.
Here's an example:
let svg = d3.select('svg'),
svgW = svg.node().getBoundingClientRect().width,
svgH = svg.node().getBoundingClientRect().height,
svgCentroid = {
x : svgW / 2,
y : svgH / 2
};
// zoom functionality has been applied to this one
let selector = d3.select('#container');
let elem = d3.select('[id="6"]'),
elemBounds = elem.node().getBBox(),
elemCentroid = {
x : elemBounds.x + (elemBounds.width / 2),
y : elemBounds.y + (elemBounds.height / 2)
};
let position = {
x : svgCentroid.x - elemCentroid.x,
y : svgCentroid.y - elemCentroid.y
};
selector.transition()
.duration(750)
.call(this.zoom.transform, d3.zoomIdentity
.translate(position.x, position.y)
// set scale to 1
.scale(1)
);
My first naive thought was "piece of cake". I will just multiply the calculated positions with desired zoom level. But, surprise surprise, that got me terribly wrong.
// failed miserably
selector.transition()
.duration(750)
.call(this.zoom.transform, d3.zoomIdentity
.translate(position.x * 5, position.y * 5)
.scale(5)
);
I've been trying to play around with this example:
https://bl.ocks.org/smithant/664d6cf86e53442d09687b154a9a411d
It pretty much sums up my intentions, but even though it's right there I don't fully understand it and thus it does not work properly with the rest of my code. I guess what confuses me most about this particular example are how the variables have their names declared.
I'd be grateful if someone could point me in the right direction here. How can I achieve this? What is the appropriate math to correctly zoom and pan within an SVG?
Thanks :)
I think that what you're looking for is:
function () {
var t = d3.transform(d3.select(this).attr("transform")),
x = t.translate[0],
y = t.translate[1];
var scale = 10;
svg.transition().duration(3000)
.call(zoom.translate([((x * -scale) + (svgWidth / 2)), ((y * -scale) + svgHeight / 2)])
.scale(scale).event);
}
Where this represents the element. Have a look here for a working example. In the example you'll be able to zoom to element after pressing on it. Also if panning and zooming an svg is all you need to do check out this library. It just works, no maths required :).

D3 v4 invert function

I am trying to project a JPG basemap onto an Orthographic projection using the inverse projection. I have been able to get it working in v3 of D3, but I am having an issue in v4 of D3. For some reason, v4 gives me the edge of the source image as the background (rather than the black background I have specified). Are there any known issues with the inverse projection in v4 or any fixes for this?
D3 v4 JSBin Link
<title>Final Project</title>
<style>
canvas {
background-color: black;
}
</style>
<body>
<div id="canvas-image-orthographic"></div>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
// Canvas element width and height
var width = 960,
height = 500;
// Append the canvas element to the container div
var div = d3.select('#canvas-image-orthographic'),
canvas = div.append('canvas')
.attr('width', width)
.attr('height', height);
// Get the 2D context of the canvas instance
var context = canvas.node().getContext('2d');
// Create and configure the Equirectangular projection
var equirectangular = d3.geoEquirectangular()
.scale(width / (2 * Math.PI))
.translate([width / 2, height / 2]);
// Create and configure the Orthographic projection
var orthographic = d3.geoOrthographic()
.scale(Math.sqrt(2) * height / Math.PI)
.translate([width / 2, height / 2])
.clipAngle(90);
// Create the image element
var image = new Image(width, height);
image.crossOrigin = "Anonymous";
image.onload = onLoad;
image.src = 'https://tatornator12.github.io/classes/final-project/32908689360_24792ca036_k.jpg';
// Copy the image to the canvas context
function onLoad() {
// Copy the image to the canvas area
context.drawImage(image, 0, 0, image.width, image.height);
// Reads the source image data from the canvas context
var sourceData = context.getImageData(0, 0, image.width, image.height).data;
// Creates an empty target image and gets its data
var target = context.createImageData(image.width, image.height),
targetData = target.data;
// Iterate in the target image
for (var x = 0, w = image.width; x < w; x += 1) {
for (var y = 0, h = image.height; y < h; y += 1) {
// Compute the geographic coordinates of the current pixel
var coords = orthographic.invert([x, y]);
// Source and target image indices
var targetIndex,
sourceIndex,
pixels;
// Check if the inverse projection is defined
if ((!isNaN(coords[0])) && (!isNaN(coords[1]))) {
// Compute the source pixel coordinates
pixels = equirectangular(coords);
// Compute the index of the red channel
sourceIndex = 4 * (Math.floor(pixels[0]) + w * Math.floor(pixels[1]));
sourceIndex = sourceIndex - (sourceIndex % 4);
targetIndex = 4 * (x + w * y);
targetIndex = targetIndex - (targetIndex % 4);
// Copy the red, green, blue and alpha channels
targetData[targetIndex] = sourceData[sourceIndex];
targetData[targetIndex + 1] = sourceData[sourceIndex + 1];
targetData[targetIndex + 2] = sourceData[sourceIndex + 2];
targetData[targetIndex + 3] = sourceData[sourceIndex + 3];
}
}
}
// Clear the canvas element and copy the target image
context.clearRect(0, 0, image.width, image.height);
context.putImageData(target, 0, 0);
}
</script>
The problem is that the invert function is not one to one. There are two ways that I'm aware of that can solve the problem. One, calculate the area of the disc that makes up the projection and skip pixels that are outside of that radius. Or two (which I use below), calculate the forward projection of your coordinates and see if they match the x,y coordinates that you started with:
if (
(Math.abs(x - orthographic(coords)[0]) < 0.5 ) &&
(Math.abs(y - orthographic(coords)[1]) < 0.5 )
)
Essentially this asks is [x,y] equal to projection(projection.invert([x,y])). By ensuring that this statement is equal (or near equal) then the pixel is indeed in the projection disc. This is needed as multiple svg points can represent a given lat long but projection() returns only the one you want.
There is a tolerance factor there for rounding errors in the code block above, as long as the forward projection is within half a pixel of the original x,y coordinate it'll be drawn (which appears to work pretty well):
I've got an updated bin here (click run, I unchecked auto run).
Naturally this is the more computationally involved process when compared to calculating the radius of the projection disc (but that method is limited to projections that project to a disc).
This question's two answers might be able to explain further - they cover both approaches.

Combining a selection-rectangle with zoom + pan in a D3.js 4.x Force Simulation

I've built a Force Simulation in D3.js 4.4 that uses the built in zoom + pan functions (d3.zoom()), combined with a selection-rectangle for selecting nodes (like here or here).
Dragging/panning the viewport around is the default behaviour, but is filtered out when the Shift-key is held. Then another drag-handler takes over and draws a rectangle from the starting point. On mouseup the code intersects the rectangle with the nodes on screen and selects them.
This all works fine, but the whole thing goes sideways when I apply zoom behavior to the equation. I've pinpointed the problem to the scale values d3.event.transform applies to the g-container that contains the nodes.
Due to the nature of this project (and the internet-less dev environment we're working in) I can't copy/paste the working code on the web. So here are the abridged parts that are causing the problem (I had to re-type these from another monitor).
var currentZoom = null;
var zoom = d3.zoom()
.scaleExtent([0.5, 10])
.filter(function() {
return !d3.event.shiftKey // Don't zoom/pan when shift is pressed
})
.on("zoom", zoomed);
function zoomed() {
currentZoom = d3.event.transform;
d3.select("g.links").attr("transform", d3.event.transform);
d3.select("g.nodes").attr("transform", d3.event.transform);
}
// This fires on mouseup after dragging the rectangle (svg rect)
function intersectSelectionRect() {
var rect = svg.select("rect.selection-rect");
// Intersect rectangle with nodes
var lx = parseInt(rect.attr("x"));
var ly = parseInt(rect.attr("y"));
var lw = parseInt(rect.attr("width"));
var lh = parseInt(rect.attr("height"));
// Account for zoom
if (!!currentZoom) {
// Recalculate for pan
lx -= currentZoom.x;
ly -= currentZoom.y;
// Recalculate for zoom (scale)
// currentZoom.k ???????????????
}
// Rectangle corners
var upperRight = [lx + lw, ly];
var lowerLeft = [lx + ly + lh];
nodes.forEach(function(item, index) {
if ((item.x < upperRight[0] && item.x > lowerLeft[0]) &&
(item.y > upperRight[1] && item.y < lowerLeft[1])) {
item.selected = true;
}
});
}
As you can (hopefully) make out, the problem occurs when recalculating the rect x and y with the zoom k (scale). If the scale is 1 (starting value) the recalculation is correct. If it's anything other than that the actual selection rectangle no longer matches the one drawn on screen.
If everything is setup properly you should be able to apply the transform to the points,
var upperRight = [lx, ly];
var lowerLeft = [lx + lw, ly + lh];
if (!!currentZoom) {
upperRight = currentZoom.apply(upperRight);
lowerLeft = currentZoom.apply(lowerLeft);
}

How to keep current zoom scale and translate level when re-rendering graph

I have a d3 graph that allows for pan and zoom behavior. It also has subGraph functionality that allows a user to expand and collapse the subGraph. When the user clicks on the icon to open and close the subGraph this code runs:
btn.on("click", function(d:any, i:any) {
parentNode = nodeObject; // gives me he x and y coordinates of the parent node
d3.event["stopPropagation"]();
e["subGraph"].expand = !expand; // add or remove subGraph
window.resetGraph = !window.resetGraph;
console.log(window.resetGraph);
if (window.resetGraph) {
window.scale = window.zoom.scale();
window.translate = window.zoom.translate();
window.zoom.scale(1).translate([0 , 0]);
} else {
window.zoom.scale(window.scale).translate(window.translate);
}
console.log(window.zoom.scale());
console.log(translate);
renderGraph();
});
I'm essentially adding and removing the subGraph property of the node on click of the icon. Then redrawing the graph completely.
I can get the x and y coordinates of the parent container, but if I am re-rendering the graph, how can I go about rendering the graph so that the graph stays at the same scale and translation as it was when I clicked on the toggle subGraph icon. I am trying to redraw the graph at the same position as it was before just with the subGraph either expanded or collapsed. Below is the code that seems to be fighting me when the graph renders:
var scaleGraph = function() {
var graphWidth = g.graph().width + 4;
var graphHeight = g.graph().height + 4;
var width = parseInt(svg.style("width").replace(/px/, ""), 10);
var height = parseInt(svg.style("height").replace(/px/, ""), 10);
var zoomScale = originalZoomScale;
// Zoom and scale to fit
if (ctrl.autoResizeGraph === "original") {
zoomScale = initialZoomScale;
}
translate = [(width / 2) - ((graphWidth * zoomScale) / 2) + 2, 1];
zoom.center([width / 2, height / 2]);
zoom.size([width, height]);
zoom.translate(translate);
zoom.scale(zoomScale);
zoom.event(svg);
};
If I check if the toggle Icon has been clicked, can I redraw the graph with the same scale and translation as before it was redrawn?
Thanks
I just solved it by keeping track of the current scale and translation values:
zoom.on("zoom", function() {
svgGroup.attr("transform", "translate(" + (<any>d3.event).translate + ")" + "scale(" + (<any>d3.event).scale + ")");
// keep track of the currentZoomScale and currentPosition at all times
currentZoomScale = (<any>d3.event).scale;
currentPosition = (<any>d3.event).translate;
});
Then when re-rendering the graph in my scaleGraph function above I just checked to see if the graph was re-rendered because of the subGraph being toggled, if it has then I use the current scale and translation values:
if (ctrl.autoResizeGraph !== "original" && togglingSubGraph) {
zoomScale = currentZoomScale; // render the graph at its current scale
translate = currentPosition; // render the graph at its current position
}
I have created one fiddle for you.
fiddle
Please check the function refreshGraph
var refreshGraph =function(){
window.resetGraph = !window.resetGraph;
console.log(window.resetGraph);
if(window.resetGraph){
window.scale = window.zoom.scale();
window.translate = window.zoom.translate();
window.zoom.scale(1).translate([0,0]);
}else{
window.zoom.scale(window.scale)
.translate(window.translate);
}
console.log(window.zoom.scale());
console.log(translate);
draw();
}
I have used window.zoom (used window to let you know that this is shared)
Button reset toggles the zoom level to 0 scale and toggles back to where it was if clicked again.
Hope it helps.

Position D3 SVG Canvas x/y and Zoom Level On Load

I'm using D3 to create an organization chart. I've got the data loading fine and have figured out how to make the canvas move by dragging the mouse as well a zoom with the mouse wheel.
My problem is that the org chart is rather large so when the document first loads the root node is out of the browser's view area and the zoom level is set fairly high.
I need to figure out how to set the viewable area of the canvas around the first node and set the initial zoom level to 100%.
I was able to create a solution thanks to #Lars Kotthoff.
I retrieved the root node's x value from it's translate attribute (i.e. translate(x,y)) and then took the browser's width / 2 - the x value. I applied this value to the parent group's translate attribute which centers the document around the root node.
var windowWidth = $(window).width();
var node0 = d3.select("#node-0");
var translate = parseTranslate(node0.attr("transform"));
var translateX = translate.x - (windowWidth / 2);
var svgGroup = d3.select("#svg_g");
svgGroup.attr("transform", "translate(-" + translateX + ",22) scale(1)"); // with 20 y padding
NOTE: Because I'm new to SVG and D3 I am still not sure how to get just the "x" value of a node's translate attribute so I created a function that parses the translate attribute with regex. I'm sure there is a better way of getting this value so if anyone wants to update my answer or add a comment for future readers that would increase the value of this question.
The function I created is:
function parseTranslate(str) {
var translate = {
x: 0,
y: 0,
scale: 0
}
var pattern = /\((.+?)\)/g;
var matches = [];
while (match = pattern.exec(str)) {
matches.push(match[1]);
}
if (matches.length) {
if (matches.length == 1) {
if (matches[0].indexOf(",") > -1) {
var p = matches[0].split(',');
translate.x = p[0];
translate.y = p[1];
} else {
translate.scale = matches[0];
}
} else if (matches.length == 2) {
var p = matches[0].split(',');
translate.x = p[0];
translate.y = p[1];
translate.scale = matches[1];
}
}
return translate;
}
I'm also using jQuery in my project to get the width of the browser (ex: $(window).width();)

Categories