I'm trying to do a proof of concept of a SVG floorplan that pans and zooms and also have the ability to place markers on top. When zooming/panning happens the marker doesn't stay in position. I understand why this happens but not sure about the best way to keep the marker in position when panning/zooming.
Heres the code:
var svg = d3.select(".floorplan")
.attr("width", "100%")
.attr("height", "100%")
.call(d3.zoom().on("zoom", zoomed))
.select("g")
var marker = d3.selectAll(".marker")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended)
)
function zoomed() {
svg.attr("transform", d3.event.transform);
}
function dragstarted(d) {
console.log('dragstarted');
}
function dragged(d) {
var x = d3.event.x;
var y = d3.event.y;
d3.select(this).attr("transform", "translate(" + x + "," + y + ")");
}
function dragended(d) {
console.log('drag ended: marker:'+ d3.select(this).attr('data-id') + ' position: ' + d3.event.x +', ' + d3.event.y);
}
Theres also a codepen to visually see here: https://codepen.io/danielhoff/pen/WzQbRr
I have an additional constraints that the marker element shouldn't be contained inside the floorplan svg.
Here is a modified version of your codepen which fixes the movements of the marker during drag events while keeping the marker outside the floorplan svg container:
https://codepen.io/xavierguihot/pen/OvyRPY?editors=0010
To bring back into context, an easy solution would have been to include the marker element inside the floorplan container (in order for the marker to get the same zoom events as the floorplan), but here we want the marker to be in its own svg container.
And it is not trivial!
Appart from including ids in html tags (in order to select these elements from the html), only the javascript part has ben modified.
Let's dig a little bit on the steps necessary to get to this point:
First: Let's modify the zoomed function to apply to the marker as well:
Initially this was the zoom function:
function zoomed() {
svg.attr("transform", d3.event.transform);
}
And the modified version:
function zoomed() {
// Before zooming the floor, let's find the previous scale of the floor:
var curFloor = document.getElementById('floorplan');
var curFloorScale = 1;
if (curFloor.getAttribute("transform")) {
var curFloorTransf = getTransformation(curFloor.getAttribute("transform"));
curFloorScale = curFloorTransf.scaleX;
}
// Let's apply the zoom
svg.attr("transform", d3.event.transform);
// And let's now find the new scale of the floor:
var newFloorTransf = getTransformation(curFloor.getAttribute("transform"));
var newFloorScale = newFloorTransf.scaleX;
// This way we get the diff of scale applied to the floor, which we'll apply to the marker:
var dscale = newFloorScale - curFloorScale;
// Then let's find the current x, y coordinates of the marker:
var marker = document.getElementById('Layer_1');
var currentTransf = getTransformation(marker.getAttribute("transform"));
var currentx = currentTransf.translateX;
var currenty = currentTransf.translateY;
// And the position of the mouse:
var center = d3.mouse(marker);
// In order to find out the distance between the mouse and the marker:
// (43 is based on the size of the marker)
var dx = currentx - center[0] + 43;
var dy = currenty - center[1];
// Which allows us to find out the exact place of the new x, y coordinates of the marker after the zoom:
// 38.5 and 39.8 comes from the ratio between the size of the floor container and the marker container.
// "/2" comes (I think) from the fact that the floor container is initially translated at the center of the screen:
var newx = currentx + dx * dscale / (38.5/2);
var newy = currenty + dy * dscale / (39.8/2);
// And we can finally apply the translation/scale of the marker!:
d3.selectAll(".marker").attr("transform", "translate(" + newx + "," + newy + ") scale(" + d3.event.transform.k + ")");
}
This heavily uses the getTransformation function which allows to retrieve the current transform details of an element.
Then: But now, after having zoomed, when we drag the marker, it takes back its original size:
This means we have to tweak the marker's dragg function to keep its current scale when applying the drag transform:
Here was the initial drag function:
function dragged(d) {
var x = d3.event.x;
var y = d3.event.y;
d3.select(this).attr("transform", "translate(" + x + "," + y + ")");
}
And its modified version:
function draggedMarker(d) {
var x = d3.event.x;
var y = d3.event.y;
// As we want to keep the same current scale of the marker during the transform, let's find out the current scale of the marker:
var marker = document.getElementById('Layer_1');
var curScale = 1;
if (marker.getAttribute("transform")) {
curScale = getTransformation(marker.getAttribute("transform")).scaleX;
}
// We can thus apply the translate And keep the current scale:
d3.select(this).attr("transform", "translate(" + x + "," + y + "), scale(" + curScale + ")");
}
Finally: When dragging the floor we also have to drag the marker accordingly:
We thus have to override the default dragging of the floor in order to include the same dragg event to the marker.
Here is the drag function applied to the floor:
function draggedFloor(d) {
// Overriding the floor drag to do the exact same thing as the default drag behaviour^^:
var dx = d3.event.dx;
var dy = d3.event.dy;
var curFloor = document.getElementById('svg-floor');
var curScale = 1;
var curx = 0;
var cury = 0;
if (curFloor.getAttribute("transform")) {
curScale = getTransformation(curFloor.getAttribute("transform")).scaleX;
curx = getTransformation(curFloor.getAttribute("transform")).translateX;
cury = getTransformation(curFloor.getAttribute("transform")).translateY;
}
d3.select(this).attr("transform", "translate(" + (curx + dx) + "," + (cury + dy) + ")");
// We had to override the floor drag in order to include in the same method the drag of the marker:
var marker = document.getElementById('Layer_1');
var currentTransf = getTransformation(marker.getAttribute("transform"));
var currentx = currentTransf.translateX;
var currenty = currentTransf.translateY;
var currentScale = currentTransf.scaleX;
d3.selectAll(".marker").attr("transform", "translate(" + (currentx + dx) + "," + (currenty + dy) + ") scale(" + currentScale + ")");
}
Related
I have a svg element ; the nodes, links, labels etc. are appended to it. I got the zoom-to-particular-node-by-name functionality running but the issue is after zooming automatically to the respective node , whenever I try to pan svg (by clicking and dragging it around), it resets the zoom and the coordinates to how it was before I zoomed to a particular node. I think it has to do with the way d3.event.transform works but I am not able to fix it. I want to be able to continue panning and zooming from the node I zoomed to without resetting any values.
(Also, from a bit of debugging , I observed that the cx and cy coordinates for the nodes did not change by zooming and panning from the code, but If I were to zoom and pan to a node manually , then it would. I guess that is the problem)
var svg1 = d3.select("svg");
var width = +screen.width;
var height = +screen.height - 500;
svg1.attr("width", width).attr("height", height);
var zoom = d3.zoom();
var svg = svg1
.call(
zoom.on("zoom", function() {
svg.attr("transform", d3.event.transform);
})
)
.on("dblclick.zoom", null)
.append("g");
function highlightNode() {
var userInput = document.getElementById("targetNode");
theNode = d3.select("#" + userInput.value);
const isEmpty = theNode.empty();
if (isEmpty) {
document.getElementById("output").innerHTML = "Given node doesn't exist";
} else {
document.getElementById("output").innerHTML = "";
}
svg
.transition()
.duration(750)
.attr(
"transform",
"translate(" +
-(theNode.attr("cx") - screen.width / 2) +
"," +
-(theNode.attr("cy") - screen.height / 4) +
")"
// This works correctly
);
}
I am using d3.js with a force layout to visualize a large number of nodes. I would like to implement a limitation to the panning option of the zoom.
JSFiddle : https://jsfiddle.net/40z5tw8h/24/
The above fiddle contains a simple version of what I am working on.
Because I would potentially have to visualize a very large dataset, I use a function to scale down the group holding element ('g') after forces are done. In that way i always have the full visualization visible afterwards.
I would like to limit the panning - when the graph is fully visible, to only be able to move it within the viewport.
In case the layout is zoomed, I would like to limit the panning as follows:
The group holding element should not be able to go:
down more than 20 px from the top of the svg.
right more than 20 px from the left side of the svg.
up more than 20 px from the bottom of the svg.
left more than 20 px from the right side of the svg.
I think all the implementation should be within the zoom function, which for now is:
function zoomed(){
if (d3.event.sourceEvent == null){ //when fitFullGraph uses the zoom
g.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}
else{
var gElementBounds = g.node().getBoundingClientRect();
var g_bottom = gElementBounds.bottom;
var g_top = gElementBounds.top;
var g_left = gElementBounds.left;
var g_right = gElementBounds.right;
var g_height = gElementBounds.height;
var g_width = gElementBounds.width;
var svg = g.node().parentElement;
var svgElementBounds = svg.getBoundingClientRect();
var svg_bottom = svgElementBounds.bottom;
var svg_top = svgElementBounds.top;
var svg_left = svgElementBounds.left;
var svg_right = svgElementBounds.right;
var svg_height = svgElementBounds.height;
var svg_width = svgElementBounds.width;
var t = d3.event.translate;
var margin = 20;
if(d3.event.sourceEvent.type == 'wheel'){//event is zoom
g.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}
else{//event is pan
// if(t[0] < svg_left + margin) t[0]= svg_left + margin;
//else if(t[0] > svg_width-g_width - margin) t[0] = svg_width-g_width - margin;
// if(t[1] < g_height +margin) t[1] = g_height + margin;
//else if (t[1] > svg_height - margin) t[1] = svg_height - margin;
//.attr("transform", "translate(" + t+ ")scale(" + d3.event.scale + ")");
//3.event.translate = t;
g.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}
}
}
The limitations I tried to implement are commented out, because they do not work properly.
Does anyone have a solution?
This is not the complete answer to your question.
I used for block panning to left side translate X scale
var translate = d3.event.translate;
var translateX = translate[0];
var translateY = translate[1];
var scale = d3.event.scale;
var tX = translateX * scale;
var tY = translateY * scale;
console.log('tx', tX, 'ty', tY);
// Do not pan more to left
if (tX> 0) {
g.attr("transform", "translate(" + d3.event.translate + ") scale(" + d3.event.scale + ")");
} else {
translate[0] = 0;
g.attr("transform", "translate(" + translate + ") scale(" + d3.event.scale + ")");
}
Which cancels the translation to left but internally it continues. Your user probably stops dragging to the left. Panning to the right gets weird when starting to pan as internally the event has panned far to the left.
I am working to add filter functionality to my d3 graph. When the user searches for a specific node based on label or id, I want to re-render the graph and show the entire graph again but I want the filtered node to sit in the center of the svg element.
here is what I have the helped it to be centered:
// I get the width and height of the SVG element:
var svgWidth = parseInt(svg.style("width").replace(/px/, ""), 10);
var svgHeight = parseInt(svg.style("height").replace(/px/, ""), 10);
// I get the center of the svg:
var centerX = svgWidth / 2;
var centerY = svgHeight / 2;
_.forEach(nodes, function(e) {
// get the full node (with x and y coordinates) based on the id
var nodeObject = g.node(nodeId);
// I look for matches between the nodeId or label and search word
if (searchInput) {
if (nodeObject.id === parseInt(searchInput, 10) || nodeObject.label.toUpperCase().indexOf(searchInput.toUpperCase()) > -1) {
searchedNodes.push(nodeObject);
console.log(searchedNodes);
}
}
}
// after looping through all the nodes rendered
if (searchedNodes.length > 0) {
//var width = searchedNodes[0].elem.getBBox().width;
//var height = searchedNodes[0].elem.getBBox().height;
ctrl.selectedNode = searchedNodes[0];
var offsetX = centerX - searchedNodes[0].x;
var offsetY = centerY - searchedNodes[0].y;
svgGroup.attr("transform", "translate(" + offsetX + "," + offsetY + ")" + "scale(" + 3 + ")");
// this line here is incorrect syntax and breaks the build, essentially stopping the script from running
// the graph renders correctly when this line is here
svgGroup.attr("transform", "translate(" + offsetX + "," + offsetY + ")").scale(2).event;
}
This is what the graph looks like with the line above that breaks the script included.
When I removed that line, it doesn't center, almost looking like over-renders the graph. Obviously I will need to remove the line of code above that is incorrect but does anybody no why the graph doesn't render correctly in this case?:
// get the user input and re-render the graph
elem.find(".search").bind("keyup", function (e:any) {
var searchInput;
if (e["keyCode"] === 13) {
searchedNodes = [];
searchInput = scope["searchInput"];
currentFilteredNode = null;
enterKeyPressed = true;
renderGraph(searchInput);
}
if (e["keyCode"] === 8) {
searchedNodes = [];
searchInput = scope["searchInput"];
currentFilteredNode = null;
renderGraph(searchInput);
}
});
// if there is searchInput and at least one matching node sort the nodes
// by id and then select and center the first matching one
if (searchInput && searchedNodes.length > 0) {
searchedNodes.sort(function (node1:any, node2:any) {
return node1.id - node2.id;
});
// make sure the noResultsMessage does not get shown on the screen if there are matching results
scope.$apply(function() {
scope["noResultsMessage"] = false;
});
ctrl.selectedNode = searchedNodes[0];
offsetX = centerX - searchedNodes[0].x;
offsetY = centerY - searchedNodes[0].y;
svgGroup.attr("transform", "translate(" + offsetX + "," + offsetY + ")" + "scale(" + 3 + ")");
}
// the only other zoom and this runs just on page load
zoom = d3.behavior.zoom();
zoom.on("zoom", function() {
svgGroup.attr("transform", "translate(" + (<any>d3.event).translate + ")" + "scale(" + (<any>d3.event).scale + ")");
// this scales the graph - it runs on page load and whenever the user enters a search input, which re-renders the whole graph
var scaleGraph = function(useAnimation:any) {
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 === "disabled") {
zoomScale = 1;
} else {
// always scale to canvas if set to fill or if auto (when larger than canvas)
if (ctrl.autoResizeGraph === "fill" || (graphWidth > width || graphHeight > height)) {
zoomScale = Math.min(width / graphWidth, height / graphHeight);
}
}
var translate;
if (direction.toUpperCase() === "TB") {
// Center horizontal + align top (offset 1px)
translate = [(width / 2) - ((graphWidth * zoomScale) / 2) + 2, 1];
} else if (direction.toUpperCase() === "BT") {
// Center horizontal + align top (offset 1px)
translate = [(width / 2) - ((graphWidth * zoomScale) / 4) + 2, 1];
} else if (direction.toUpperCase() === "LR") {
// Center vertical (offset 1px)
translate = [1, (height / 2) - ((graphHeight * zoomScale) / 2)];
} else if (direction.toUpperCase() === "RL") {
// Center vertical (offset 1px)
translate = [1, (height / 2) - ((graphHeight * zoomScale) / 4)];
} else {
// Center horizontal and vertical
translate = [(width / 2) - ((graphWidth * zoomScale) / 2), (height / 2) - ((graphHeight * zoomScale) / 2)];
}
zoom.center([width / 2, height / 2]);
zoom.size([width, height]);
zoom.translate(translate);
zoom.scale(zoomScale);
// If rendering the first time, then don't use animation
zoom.event(useAnimation ? svg.transition().duration(500) : svg);
};
CODE FOR FILTERING THE NODES:
// move to the left of the searchedNodes array when the left arrow is clicked
scope["filterNodesLeft"] = function () {
filterNodesIndex--;
if (filterNodesIndex < 0) {
filterNodesIndex = searchedNodes.length - 1;
}
currentFilteredNode = searchedNodes[filterNodesIndex];
runScaleGraph = true;
number = 1;
renderGraph();
};
// move to the right of the searchNodes array when the right arrow is clicked
scope["filterNodesRight"] = function () {
filterNodesIndex++;
if (filterNodesIndex > searchedNodes.length - 1) {
filterNodesIndex = 0;
}
currentFilteredNode = searchedNodes[filterNodesIndex];
runScaleGraph = true;
number = 1;
renderGraph();
};
// get the current filteredNode in the searchNodes array and center it
// when the graph is re-rendered
if (currentFilteredNode) {
ctrl.selectedNode = currentFilteredNode;
offsetX = centerX - currentFilteredNode.x;
offsetY = centerY - currentFilteredNode.y;
svgGroup.attr("transform", "translate(" + offsetX + "," + offsetY + ")");
runScaleGraph = false;
}
You will want to find the x and y coordinates of your target node, and set the transform attribute of your group with class 'output' accordingly. You will also need to know the width and height of 'output' in order to position it such that your target node is in the center.
//when diagram is initially displayed
var output = d3.select('.output');
var bbox = output.getBBox();
var centerX = bbox.width * .5;
var centerY = bbox.height * .5;
//in your block where you find a node matches the filter
if (node.label.toUpperCase().indexOf(searchString.toUpperCase()) > -1) {
var offsetX = centerX - node.x;
var offsetY = centerY - node.y;
output.attr('transform', 'translate(' + offsetX + ',' + offsetY + ')');
}
Depending on the node's registration point, you may also need to take in to account the node's width and height to make sure we are directly centered on the node. For example, if the registration point is the top left of the node, you would want to add half the nodes width and half the nodes height to the offset.
-- Edit --
In the following line:
svgGroup.attr("transform", "translate(" + offsetX + "," + offsetY + ")" + "scale(" + 3 + ")");
by including "scale(" + 3 + ")" so you are scaling your entire graph - you are not 'zooming in' on the place you have centered, rather the content itself is bigger and so offsetX and offsetY are not the correct cordinates to center on.
The reason things look better when you add that other line, is that you are removing the scale.
svgGroup.attr("transform", "translate(" + offsetX + "," + offsetY + ")");
So, we are back to the default scale, immediately prior to your error being thrown.
If you want to scale, you'll need to multiply offsetX and offsetY by whatever you want to scale by.
If you do not want to scale, just remove
"scale(" + 3 + ")"
Here's how I solved it:
// zoom in on the searched or filtered node
function zoomOnNode (node:any) {
// get the width and height of the svg
var svgWidth = parseInt(svg.style("width").replace(/px/, ""), 10);
var svgHeight = parseInt(svg.style("height").replace(/px/, ""), 10);
// loop through all the rendered nodes (these nodes have x and y coordinates)
for (var i = 0; i < renderedNodes.length; i++) {
// if the first matching node passed into the function
// and the renderedNode's id match get the
// x and y coordinates from that rendered node and use it to calculate the svg transition
if (node.id === renderedNodes[i].id) {
var translate = [svgWidth / 2 - renderedNodes[i].x, svgHeight / 2 - renderedNodes[i].y];
var scale = 1;
svg.transition().duration(750).call(zoom.translate(translate).scale(scale).event);
}
}
}
// listen for the enter key press, get all matching nodes and pass in the first matching node in the array to the zoomOnNode function
elem.find(".search").bind("keyup", function (e:any) {
var searchInput;
if (e["keyCode"] === 13) {
searchedNodes = [];
searchInput = scope["searchInput"];
enterKeyPressed = true;
if (searchInput) {
// recursively get all matching nodes based on search input
getMatchingNodes(ctrl.nodes, searchInput);
scope.$apply(function() {
// show the toggle icons if searchedNodes.length is greater then 1
scope["matchingNodes"] = searchedNodes.length;
scope["noResultsMessage"] = false;
if (searchedNodes.length > 0) {
var firstNode = searchedNodes[0];
ctrl.selectedNode = firstNode;
zoomOnNode(firstNode);
} else if (searchedNodes.length === 0) {
ctrl.selectedNode = null;
// add the noResultsMessage to the screen
scope["noResultsMessage"] = true;
}
});
}
}
}
I'm using d3 to animate a route (path) on a map. When the route reaches a point along the route I'd like to popup some information.
Most of my code is based on the following example. http://bl.ocks.org/mbostock/1705868. I'm really just trying to determine if there is a way to detect when the transitioning circle collides or overlaps any of the stationary circles in this example.
You can detect collision in your tween function. Define a collide function to be called from inside the tween function as follows:
function collide(node){
var trans = d3.transform(d3.select(node).attr("transform")).translate,
x1 = trans[0],
x2 = trans[0] + (+d3.select(node).attr("r")),
y1 = trans[1],
y2 = trans[1] + (+d3.select(node).attr("r"));
var colliding = false;
points.each(function(d,i){
var ntrans = d3.transform(d3.select(this).attr("transform")).translate,
nx1 = ntrans[0],
nx2 = ntrans[0] + (+d3.select(this).attr("r")),
ny1 = ntrans[1],
ny2 = ntrans[1] + (+d3.select(this).attr("r"));
if(!(x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1))
colliding=true;
})
return colliding;
}
Where points are the stationary points, and node is the transitioning element. What collide does is check whether node overlaps with any of the points (as shown in collision detection example here).
Because we need the node to be passed to the tween function, we replace attrTween used in Mike's example, with tween:
circle.transition()
.duration(10000)
.tween("attr", translateAlong(path.node()))
.each("end", transition);
Finally, the tween function calling our collide:
function translateAlong(path) {
var l = path.getTotalLength();
return function(d, i, a) {
return function(t) {
var p = path.getPointAtLength(t * l);
d3.select(this).attr("transform","translate(" + p.x + "," + p.y + ")");
if(collide(this))
d3.select(this).style("fill", "red")
else
d3.select(this).style("fill", "steelblue")
};
};
}
See the full demo here
The easiest way is to just check how "close" the transitioning circle is to the other points.
var pop = d3.select("body").append("div")
.style("position","absolute")
.style("top",0)
.style("left",0)
.style("display", "none")
.style("background", "yellow")
.style("border", "1px solid black");
// Returns an attrTween for translating along the specified path element.
function translateAlong(path) {
var l = path.getTotalLength();
var epsilon = 5;
return function(d, i, a) {
return function(t) {
var p = path.getPointAtLength(t * l);
points.forEach(function(d,i){
if ((Math.abs(d[0] - p.x) < epsilon) &&
(Math.abs(d[1] - p.y) < epsilon)){
pop.style("left",d[0]+"px")
.style("top",d[1]+20+"px")
.style("display","block")
.html(d);
return false;
}
})
return "translate(" + p.x + "," + p.y + ")";
};
};
}
The faster the circle moves the greater your epsilon will need to be.
Example here.
i'm struggling with positioning and scaling of a voronoi diagram on a leaflet map.
the voronoi polygons are correctly displayed after they're appended on the map, but
after resizing they don't scale and translate correctly. i tried to reset the path after
panning and zooming to the feature element. but it looks like the new values are passed to their parent's element. If i set the path on feature.selectAll('path).attr('d',path) the scaling and translation is absolutly correct, but it show's the voronoi means instead the voronoi polygons. Any Idea?
Best regards,
Flo
this.id = p_id;
this.data = p_data;
d3.select('#'+this.id).remove();
var svg = d3.select(map.getPanes().overlayPane).append("svg").attr('id', this.id);
var g = svg.append("g").attr("class", "leaflet-zoom-hide").attr("id", "cells");
var pointdata = this.data.features;
var positions = [];
pointdata.forEach(function(d){
positions.push(project(d.geometry.coordinates));
});
var polygons = d3.geom.voronoi(positions);
var bounds = d3.geo.bounds(this.data),
path = d3.geo.path().projection(project);
var feature = g.selectAll('g').data(pointdata).enter().append('g');
feature.append('path')
.attr('class', 'cell')
.attr({
"d":function(d, i) { return "M" + polygons[i].join("L") + "Z"},
stroke:"#43676b",
fill: "rgba(255,140,10,0.3)"
});
map.on("viewreset", reset);
reset();
function reset() {
scale = Math.pow(2, map.getZoom() - map.options.zoom);
var padding = 25;
var bottomLeft = project(bounds[0]),
topRight = project(bounds[1]);
bottomLeft = [bottomLeft[0]-padding, bottomLeft[1]+padding]
topRight = [topRight[0]+padding, topRight[1]-padding]
console.log(polygons);
svg.attr("width", topRight[0] - bottomLeft[0]).attr("height", bottomLeft[1] - topRight[1]).style("margin-left", bottomLeft[0] + "px").style("margin-top", topRight[1] + "px");
g.attr("transform", "translate(" + -bottomLeft[0] + "," + -topRight[1] + ")");
feature.attr('d', path);
}
function project(x) {
var point = map.latLngToLayerPoint(new L.LatLng(x[1], x[0]));
return [point.x, point.y];
}