Could someone explain to me the right way to create custom event listeners in AngularJS that resist DOM modifications?
My problem is that I need to plot 2 different datasets according to a dropdown menu. Each element of the datasets has to respond to an event (mouseover/mousemove etc...). But when switching from one dataset to another, the event listener is lost.
This behavior is well explained here:
Injecting HTML into the DOM destroys the AngularJS events
but I do not understand the answer given to bypass it. The post mentions that manually avoiding ng-app can help but it seems like a bad workaround.
I would rather redefine my own event listeners such that they would not be lost during DOM modification. Can someone tell me how to correctly use addEventListener in the directive/controller model?
Here is an SSCCE html/javascript file that illustrates how my program is organized
angular.module("testUpdate", [])
.directive("shape", function($parse) {
return {
restrict: "E",
replace: false,
scope: false,
link: function(scope, element, attrs) {
// draw dataset according to #shapeSelection choice
d3.select("#shapeSelection")
.on("change", scope.redraw);
// change color on mouseover
d3.selectAll("circle,rect,polygon")
.on("mouseover", function() {
d3.select(this).style("fill", "#0f0");
});
}
};
})
.controller("testController", function($scope) {
$scope.shape = "none";
//create datasets
$scope.dataset1 = [{
cx: 10,
cy: 20
}, {
cx: 30,
cy: 20
}];
$scope.dataset2 = [{
w: 20,
h: 20
}];
// the triangle actually responds to the event
d3.selectAll("svg")
.append("polygon")
.attr("points", "15,20 0,40 30,40")
.style("fill", "#f00");
// draw dataset depending on the user choice
$scope.draw = function() {
if ($scope.shape === "circle") {
d3.selectAll("svg").selectAll("circle")
.data($scope.dataset1).enter()
.append("circle")
.attr("cx", function(d) { return d.cx; })
.attr("cy", function(d) { return d.cy; })
.attr("r", 10)
.style("fill", "#f00");
}
else if ($scope.shape === "rect") {
d3.selectAll("svg").selectAll("rect")
.data($scope.dataset2).enter()
.append("rect")
.attr("width", function(d) { return d.w; })
.attr("height", function(d) { return d.h; })
.attr("x", 0)
.attr("y", 10)
.style("fill", "#f00");
}
else {}
}
// once removed is called, the event listener is destroyed
$scope.redraw = function() {
d3.selectAll("circle,rect,polygon").remove();
$scope.draw()
}
});
<script src="https://code.angularjs.org/1.2.15/angular.js"></script>
<script src="http://d3js.org/d3.v3.min.js"></script>
<div ng-app="testUpdate" ng-controller="testController">
<select name="shapeSelector" ng-model="shape" id="shapeSelection">
<option value="none">--none--</option>
<option value="circle">dataset1</option>
<option value="rect">dataset2</option>
</select>
<shape id="shapeTag">
<svg id="shapeSVG" display="block"></svg>
</shape>
</div>
The d3.selection.remove() function modifies the DOM and nullifies the event listeners so that the triangle responds correctly but not the circles and rectangle.
A dirty workaround would be to create the DOM for both datasets and set the visibility of the non-selected one to hidden, but both datasets are pretty big so it is not a viable option.
Thank you in advance
From the code, it appears that you only want to have the mouseover event captured on the elements inside the shape directive.
I think the best solution for you would be to move the code which binds the event handler to the elements into $scope.redraw or the $scope.draw function:
$scope.draw = function() {
if ($scope.shape === "circle") {
/* ... */
} else {}
// change color on mouseover
d3.selectAll("circle,rect,polygon")
.on("mouseover", function() {
d3.select(this).style("fill", "#0f0");
});
}
While you are manipulating the DOM in D3-land, you should not rely on event-handlers defined in Angular's link function. In the directives I have written, I merely used $watch in the directives to trigger the redraw function when the data updated, just as you are doing with the change function.
Alternate solution
On the other hand, if you want to handle the events in the directive (e.g. if you do not have to pass d3 the $scope), and if you are using jQuery, then you can use the .on method on a parent with proper selectors. Note that jQlite (Angular's internal implementation of jQuery), does not supposed .on with selectors.
In this case, the event handler will be called on the parent even if you change the DOM underneath. If you want access to the data associated with the node inside the event handler, you can use var data = d3.select(this).data().
Related
I have a graph made with d3.js and I have the following attributes and properties for the nodes:
// Enter any new nodes at the parent's previous position
var nodeEnter = node.enter().append("g")
.attr("class", "node")
.attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")"; })
.on("click", click)
.on("dblclick", dblclick)
I would like to add the ability to underline the node title when hovering over it. Something like this which unfortunately doesn't work:
var nodeEnter = node.enter().append("g")
.on("mouseover").style("text-decoration","underline")
.on("mouseout").style("text-decoration","none")
EDIT: I would prefer to put a condition to make this happen only for some nodes of the graph.
You aren't using the selection.on() method correctly. In order to do something on an event you need to provide the method with a second parameter: a function that describes the action taken on the event:
D3v6+
.on("mouseover", function(event, datum) { ... })
D3v5 and before
.on("mouseover", function(datum, index, nodes) { ... })
In all versions of D3 this will be the target element (unless using arrow notation). The datum is the data bound to the target element, one item in the array passed to selection.data().
If you only provide one parameter it returns the current event handling function assigned to that event. In your case you likely haven't done this already (because you are attempting to do so), so .on("mouseover").style(...) will return an error such as "Cannot find property style of null" because .on("mouseover") will return null: there is no event handling function assigned to this event to return.
So, to highlight nodes on mouseover with some logic so we can have different outcomes for different nodes, we can use something like:
selection.on("mouseover", function(event, datum) {
if(datum.property == "someValue") {
d3.select(this).style("text-decoration","underline");
}
})
.on("mouseout", function(event,datum) {
d3.select(this).style("text-decoration","none");
})
Where the if statement can be replaced with whatever logic you prefer.
I see you are probably using a hierarchical layout generator, D3's hierarchical layout generators nest the original datum's properties into a data property so that layout properties and non layout properties do not collide, so datum.property may reside at datum.data.property (log the datum if you are having trouble).
var svg = d3.select("body")
.append("svg");
var data = [
"Underline on mouse",
"Never underline"
];
svg.selectAll("text")
.data(data)
.enter()
.append("text")
.attr("x", 20)
.attr("y", (d,i)=>i*50+40)
.text(d=>d)
.on("mouseover", function(event, datum) {
if(datum == "Underline on mouse") {
d3.select(this).style("text-decoration","underline");
}
})
.on("mouseout", function(event,datum) {
d3.select(this).style("text-decoration","none");
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
You can add an underline on hover using CSS
.node:hover{
text-decoration: underline;
}
I have two identical charts. The graphics for them are built like so:
circles.enter().append("circle")
.attr("r", 0)
.attr("fill", function(d) { return fill_color; })
.attr("class", function(d) { return "circle_" + d.id; })
.on("mouseover", function(d, i) { build_tooltip(d, i, this); })
.on("mouseout", function(d, i) { hide_tooltip(d, i, this); });
On mouseover, it triggers the following function:
build_tooltip = function(data, i, element) {
var content = "Title: " + data.title;
show_tooltip(content, d3.event);
}
My question is: How can I make it so mousing over a circle in Chart #1 triggers the same mouseover event in Chart #2, but with unique data for each chart? Chart #2 must generate its own set of data (in this example, just a title). So, how can I make Chart #2's mouseover event fire whenever Chart #1's does?
In jQuery, this would be quite simple -- there is a literal .trigger() event. But how can I go about accomplishing the same with D3?
Have you tried using D3's dispatch? If not, see through this example for more details on how to use it.
I'm trying to create a d3 chart using an Angular directive. I manage to create it, but the problem is that I want some ng-click events on the chart elements and I'm not really sure how this should be done.
Here is my directive:
.directive('circleChart', function($parse, $window, $compile) {
return {
restrict: 'A',
scope: {
datajson: '='
},
link: function(scope, elem, attrs) {
var circleChart = new CircleChart(scope.datajson);
circleChart.initialise(scope);
var svg = circleChart.generateGraph();
svg = angular.element(svg);
console.log(svg);
//scope.$apply(function() {
var content = $compile(svg)(scope);
elem.append(content);
//});
}
}
});
The CircleChart object creates my d3 chart and there is the place I attach an ng-click attribute to the chart elements (doesn't seem to be a proper Angular way of doing it):
var CircleChart = Class.create({
initialise: function(scope) {
this.datajson = scope.datajson;
},
generateGraph: function() {
.............
var chartContent = d3.select("div#chart-content");
var svg = chartContent.append("svg")
.attr("id", "circle")
.attr("width", diameter)
.attr("height", diameter)
.style("border-radius", "50px")
.append("g")
.attr("transform", "translate(" + diameter / 2 + "," + diameter / 2 + ")");
...............
var circle = svg.selectAll("circle")
.data(nodes)
.enter().append("circle")
.attr("class", function(d) {
return d.parent ? d.children ? "node" : "node node--leaf" : "node node--root";
})
.attr("id", function(d) {
return d.id;
})
.attr("value", function(d) {
return d.name
})
.attr("parent", function(d) {
if (d.parent) return d.parent['id']
})
.attr("ng-click", function(d) {
return "getEgs('" + d.id + "')";
})
...............
The d3 code is working fine because if I remove the compile code from the directive, the chart gets drawn, but the ng-clicks don't fire.
If I leave the compile code there, it goes into an infinite loop and the browser is building up memory until I have to kill it.
Obviously, my method is not working and it will be awesome if someone can help me out
Since you are modifying the DOM from inside the CircleChart.generateGraph and re-appending the contents of the element in you directive: elem.append(content) after compilation, you (probably) end up in an infinite loop.
If you do want to compile the template thus produced, you can simple call $compile(elem.contents())(scope); without having to clear and append the content again. $compile will work Angular magic on the DOM in-place.
However, as I suggested in the comments, since you have access to the whole scope in CircleChart (which in my opinion is probably an overkill), you should capture the click event in d3 and invoke the getErgs function from code instead of $compile-ing the code and delegating the task to Angular.
Using D3, I'm trying to bind a drag event to a 'g', group element, and a separate drag event to a child of this group. This seems to be causing issues as only group's drag event fires.
I've read through the specs a bit but don't see anything relating to this. Here's the code:
var group = that.vis.append('g')
.classed('dragger', true)
.attr('transform', 'translate(100, 0)')
.call(drag.on( 'drag', function() {...} )),
box = group.append('rect')
.attr('width', that.width * options.width)
.attr('height', that.height)
.classed('box', true);
var left = group.append('rect')
.attr('width', 4).attr('x', 0)
.attr('height', that.height)
.classed('drag-extend right', true)
.call(drag2.on('drag', function(){...}));
'that.vis' refers to the d3 selection containing the svg element. The d3 drag behaviors were created like so:
var drag = d3.behavior.drag()
.origin(function() {
var string = d3.select(this).attr('transform'),
//string = string.replace(/translate\(/, '');
array = string.match( /translate\((\d+), (\d+)\)/ );
return {
x : parseInt(array[1]),
y : parseInt(array[2])
}
}),
drag2 = d3.behavior.drag()
.origin(function() {
return {
x : d3.select(this).attr('x'),
y : 0
};
});
I need the elements to be grouped in order to move the whole group. My only thought is that when you attach an event handler to a SVG group it attaches that handler to all elements inside it? I'd like to be able to just stop propagation on the second element's drag handler so it doesn't bubble up to the 'g' parent however the second event doesn't seem to be attached at all.
Kind of at loss here, any help would be much appreciated...
UPDATE
So I was able to get this working but not in a way that I would of expected and I'm still interested in what exactly is going on here:
drag2 = d3.behavior.drag()
.origin(function() {
return {
x : d3.select(this).attr('x'),
y : 0
};
}).on( 'drag', shapeBox );
var left = group.append('rect')
.attr('width', 4).attr('x', 0)
.attr('height', that.height)
.classed('drag-extend right', true)
.call(drag2.on('dragstart', function(){...}));
function shapeBox() { ... }
For some reason adding the 'dragstart' handler somehow got the drag handler attached directly to the behavior to fire. This is strange because in looking at the d3 docs and from my knowledge of the DOM (which may be somewhat limited), I should have just been able to pass the 'drag2' variable to call.
Not sure what binding the extra listener did but somehow that got the drag to work.
Note: My first StackOverFlow post, please bear with me.
Aim of my code:
Take a connected graph as an input in a user friendly way ( I mean that user should be able to add a node on say, a double click. And when the user wants to connect two nodes, he/she may just select two nodes and there should be a way for the user to input distance between the two selected nodes).
After successfully taking the graph as input, I want to perform various operations on the graph like telling what is the shortest path from node ‘A’ to node ‘B’, for example.
Approach:
I have decided to use D3.js for the purpose. I am a newbie in JavaScript/jQuery but still I learnt D3.js from https://www.dashingd3js.com/table-of-contents .
I have seen people create amazingly beautiful projects involving graph using D3.js but I am kind of struggling even in the basics.
Attempt:
I wrote the following code to connect two nodes:
<!DOCTYPE html>
<html>
<head>
<title>D3 Try</title>
<script type="text/javascript" src="d3.min.js"></script>
</head>
<style>
.node {
fill: #ccc;
stroke: #fff;
stroke-width: 2px;
}
.link {
stroke: #777;
stroke-width: 2px;
}
</style>
<body style="background-color: red">
<h2>Hello world</h2>
<script type="text/javascript">
var height=500,width=960;
// If we leave out the x and y coordinates of the visualization, d3 selects
// random positions for the nodes
// nodes are arbitrary objects
var nodes = [ {x: width/3, y: height/2 } , {x: 2*width/3, y: height/2} ];
// Define the array which contains information about the links
var links= [ {source: 0, target: 1}];
// container to hold the visualisation
var svg=d3.select('body').append('svg').attr('width',width).attr('height',height);
// Create force layout object which contains dimensions of the container and
// the arrays of nodes and links
var force=d3.layout.force().size([width,height]).nodes(nodes).links(links);
// Documentation says that define a function in place of width/2 but fir bhi , how?
force.linkDistance(width/2);
var link=svg.selectAll('.link').data(links).enter().append('line').attr('class','link');
var node=svg.selectAll('.node').data(nodes).enter().append('circle').attr('class','node');
force.on('end',function(){
node.attr('r', width/25)
.attr('cx', function(d) { return d.x; })
.attr('cy', function(d) { return d.y; });
link.attr('x1', function(d) { return d.source.x; })
.attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; })
.attr('y2', function(d) { return d.target.y; });
});
force.start();
</script>
</body>
</html>
Problems I faced and what I really want to ask:
The above code connects two nodes, okay great, but how do I dynamically create such nodes on user's double click ? It may here be noted that not only a node has to be drawn on SVG, but the nodes and links arrays are to be updated too. How do I update those hard-coded arrays dynamically so that they store the most recent information in them?
The distance between the two nodes must be entered by the user, here it is a constant width/2 . I checked out the API on github, they say that define a function instead of a constant.I searched but couldn't find any example of that. Even if I use the D3 supplied variable d, its of no use since it has to be user dependent.
Any help? Thank You.
To do this, you can rely on the magic of the d3 .enter() method. This is a data that operates on a selection, and returns a placeholder for every piece of data assigned to the selection that doesn't currently map to an SVG element in the DOM.
What this means is that if you modify the data, you can then append that data to a selection and have any changes in the data represented by changes in the selection. If you want to add a node to your data, it would work like this:
//Modify data
nodes.push({x : d3.mouse(svg)[0], y : d3.mouse(svg)[1]});
//Modify data of node selection
node.data(nodes);
//Operate on new nodes
node.enter()
.append('circle')
.attr('class','node')
.attr('r', width/25)
.attr('cx', function(d) { return d.x; })
.attr('cy', function(d) { return d.y; });
.call(force.end);
Since your first two data points already have DOM nodes assigned to them from when you first created them, this will only append a new circle element for the new data point.
All of this would be called from within an event handler for mousedown or click, and would take user input in the form of mouse position.