Understanding Accessor Function (d) - d3.js

var cs1 = drawCircles(svg1,data,d=>d["A"],d=>d["B"],sc1);
Trying to get an intuitive understanding for how d is used when binding.
The above function calls .append('circle') and uses the two access functions d=>d["A"],d=>d["B"] to place the circles.
I understand that d essentially acts as an iterable on 'data' and that here we are implicitly returning d['B']. What I don't understand is why we cant simply pass d in a non function way e.g. drawCircles(...,d['A'] ,...). When I've tried using d in this manner it returns d is not defined.
Using a simpler example,
svg.selectAll('circle').data(data).enter.append('circle')
.attr('id', (d) => {return d['name']})
Why can't I simply supply d['name'] for the 2nd arg of .attr()
How can I reason about the use of d solely as a function argument?
Thanks!

The 2nd Parameter of .style()/.attr()
When using something like:
.attr("fill", someParameter);
D3 checks to see if someParameter is a function or a constant.
If someParameter is a constant, "all elements are given the same attribute value", afterall we're using a constant. (see also, D3 documentation).
If someParameter is a function, D3 uses function.apply() (see also, MDN's function.apply() documentation) to call that function for each item in the selection, providing it with 3 parameters:
the current datum (by convention d),
the index of the current item (by convention i),
and the group of nodes in the selection (inconsistent convention, I'll use nodes here).
The use of apply also allows specification of this, which is the current element (which is also: nodes[i]).
The use of function.apply() defines d, i, and nodes only within the function provided.
This makes sense, if you provide a constant, there is nothing to apply, er, apply to and no need to.
What happens when you supply d['name'] as the 2nd argument for .style()/.attr()
If using:
.attr("fill", d.color)`
d in the above has no relation to the datum. If you haven't declared d and given it the property color yourself, it'll be undefined here. D3 doesn't call a function here with apply to define d - you aren't providing a function to do so with.
Only if d.color evaluates to function(d) { return d.color; } would you be able to do what you are asking about. This would be a very unusual form with D3.
If d is undefined you'll likely throw an error when accessing d.color as you've seen. If d is defined, but d.color isn't a function, it'll be treated as a constant and every element will gain a property with the same value.
Consequently, this is why we see the format:
.attr("fill", function(d,i,nodes) { return ... });
Stop Here
It is possible in theory, but not advisable, to accomplish what you think should be possible.
I'm only sharing because
I've had this lying around for a while
It shows how much of a workaround is required to achieve the pattern you are asking about (without pre-defining your functions as properties of some object named d).
Again it's not advisable to use - but you can, technically, dynamically create an accessor function for a given property, or nested property, of the datum with a proxy.
With this you could use the form:
.attr("fill", d.color)
When accessing any of the proxy (d) properties (here color) it would need to return the appropriate accessor function (function(d) { return d.color;}) which would then be passed to .attr() and have the appropriate datum bound to it. You can only use the property, you wouldn't be able to use d.x + 2.
// create a proxy `d` to return accessor functions,
var d = new Proxy({},{ get: f })
var data = [
{style:{fill:"steelblue",stroke:{color:"crimson", width:4}},width: 30, y: 50, x: 10},
{style:{fill:"yellow",stroke:{color:"orange", width:2}},width: 20, y: 50, x: 50},
{style:{fill:"crimson",stroke:{color:"steelblue", width:8}},width: 30, y: 50, x: 80}
]
var svg = d3.select("body").append("svg");
svg.selectAll(null)
.data(data)
.enter()
.append("rect")
.attr("x", d.x)
.attr("y", d.y)
.attr("width", d.width)
.attr("height",d.width)
.attr("fill",d.style.fill)
.attr("stroke-width",d.style.stroke.width)
.attr("stroke", d.style.stroke.color);
// To resolve paths (https://stackoverflow.com/a/45322101/7106086):
function resolve(path, obj) {
return path.reduce(function(prev, curr) {
return prev ? prev[curr] : null
}, obj || self)
}
// Proxy to dynamically access properties of any depth:
function f(obj, prop) {
if(prop in obj) return obj[prop];
else {
var g = function() {
var accessor = function(d) {
return resolve(accessor._path_,d);
}
// keep track of path:
if(obj._path_) accessor._path_ = [...obj._path_,prop];
else (accessor._path_) = [prop];
return accessor;
}
return obj[prop] = new Proxy(g(), {get:f});
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

Related

Merge selection with groups

I've worked out a consistent pattern for using the new selection merge which is brilliant for reusable charts where data and/or scales may change.
I've also been using the key function successfully.
However, I seem to get a problem when entering and appending a group with multiple elements. The data is successfully updated in the group but not the appended elements.
I've got round it by adding a fix (see below) but I'm sure it is something really obvious that needs to be changed to resolve it.
Any thoughts?
//define data group
var my_group = svg.selectAll(".data_group")
.data(my_data,function(d){return d.id});
//enter new groups
var enter = my_group.enter()
.append("g")
.attr("class","data_group");
//append items to group
enter.append("text").attr("class","group_item group_text")
enter.append("circle").attr("class","group_item group_circle");
//merge and remove
my_group.merge(enter);
my_group.exit().remove();
//fix added to reset changing data for bars.
d3.selectAll(".group_item").each(function(d){
d3.select(this)._groups[0][0].__data__ = d3.select(this)._groups[0][0].parentElement.__data__;
});
d3.selectAll(".group_text")
.... add properties to text - ie x,y,fill,text-anchor,text
d3.selectAll(".group_circle")
.... add properties to circle - ie cx,cy,fill,stroke,radius
There is absolutely no need for selecting the parent group, getting its data and rebinding it to the child elements, as the code in your question and the other answer do. This is bending over backwards. Also, do not delete/re-append elements, as suggested, which is not an idiomatic D3 approach.
The thing is simple: the new data is there for the children elements in the "enter" selection. You just need to use the parent's selection (with select()) to propagate them.
Here is a basic demo, using (most of) your code. The code generates from 1 to 5 data objects, with a random property called someProperty. You'll see that, using your each(), only the children elements in the "enter" selection are changed:
var svg = d3.select("svg");
d3.interval(function() {
var data = d3.range(1 + ~~(Math.random() * 4)).map(function(d) {
return {
id: "id" + d,
"someProperty": ~~(Math.random() * 100)
}
});
update(data);
}, 2000);
function update(my_data) {
var my_group = svg.selectAll(".data_group")
.data(my_data, function(d) {
return d.id
});
my_group.exit().remove();
var enter = my_group.enter()
.append("g")
.attr("class", "data_group");
enter.append("text").attr("class", "group_item group_text")
enter.append("circle").attr("class", "group_item group_circle");
my_group = my_group.merge(enter);
console.log("---")
d3.selectAll(".group_text").each(function(d) {
console.log(JSON.stringify(d))
});
}
.as-console-wrapper { max-height: 100% !important;}
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg></svg>
Now, if we use your parent's selection...
my_group.select(".group_text").each(function(d) {
console.log(d)
})
... you'll see that all properties are updated:
var svg = d3.select("svg");
d3.interval(function() {
var data = d3.range(1 + ~~(Math.random() * 4)).map(function(d) {
return {
id: "id" + d,
"someProperty": ~~(Math.random() * 100)
}
});
update(data);
}, 2000);
function update(my_data) {
var my_group = svg.selectAll(".data_group")
.data(my_data, function(d) {
return d.id
});
my_group.exit().remove();
var enter = my_group.enter()
.append("g")
.attr("class", "data_group");
enter.append("text").attr("class", "group_item group_text")
enter.append("circle").attr("class", "group_item group_circle");
my_group = my_group.merge(enter);
console.log("---")
my_group.select(".group_text").each(function(d) {
console.log(d)
})
}
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg></svg>
Finally, in your now deleted answer you're using my_group.selectAll(). The problem is that selectAll() does not propagate the data.
Have a look at this table I made:
Method
select()
selectAll()
Selection
selects the first element that matches the selector string
selects all elements that match the selector string
Grouping
Does not affect grouping
Affects grouping
Data propagation
Propagates data
Doesn't propagate data
Pay attention to the propagates data versus doesn't propagate data.
The more d3 way of copying the data bound to the parent g elements
No need to add the fix
d3.selectAll(".group_text")
.datum(function () { return d3.select(this.parentNode).datum(); } )
// .... add properties to text - ie x,y,fill,text-anchor,text
d3.selectAll(".group_circle")
.datum(function () { return d3.select(this.parentNode).datum(); } )
// .... add properties to circle - ie cx,cy,fill,stroke,radius

d3 append invisible if data is not same as previous

d3 newb is trying something else:
I want to add a date label to my bar chart only if the date is not the same as in the previous bar.
bar.append("text")
.attr("visibility", function(d,i){
if(d.Datum == data[i-1].Datum) return "hidden"})
.attr("x", padding)
.attr("y", barHeight-2)
.attr("fill", "Gainsboro")
.text(function(d){ return d.Datum})
So I thought I add a visibility to my text label. however I cannot access the previous date from the data-object... probably this is an easy one for someone not newbie as me...
data example (before CSV import)
Datum,Name,Kategorie,Betrag
01/10/15,,Lohn,1586.7
02/10/15,,lunch,-4.55
So assuming that after d3 parses the text file you are left with data like:
var data = [
{
Datum: "01/10/15",
Name: "",
Kategorie: "Lohn",
Betrag: 1586.7
},{
...
}
];
I'd pre-process the data to contain a bool about whether or not it is the first instance of that date:
data.forEach(function(d,i){
d.isFirstInstanceOfDate = (i === 0 || d.Dataum !== data[i-1].Datum);
});
Then assuming that bar is a selection of gs elements (which already contain a rect), I'd filter them and only append the text on the first instance:
bar
.filter(function(d){
return d.isFirstInstanceOfDate
})
.append('text')
...
The index starts from 0. By subtracting 1 from it the first time you get an error. You need to check whether i > 0 so that you don't do out of bounds.
For example:
.style('visibility', function (d, i) {
if (i > 0) {
if (d.datetime === data[i - 1].datetime) {
return 'hidden';
}
}
return 'visible';
});
Here are two sample fiddles:
http://jsfiddle.net/00drii/y079vw7e/
http://jsfiddle.net/00drii/dmhs0gza/

How to set the width and height of a dcjs Heatmap based on the filtered group?

In the sample of my code below, I want to set the width and height based on the number of values in the filtered group. Is there a best way to do this? Should I be tracking a count in the group add/remove functions?
myHeatMap
.width(45 * 20)
.height(45 * 30)
.dimension(heatDim)
.group(heatGroup)
.title(function (d) {
return "Value: " + d.value;
})
.keyAccessor(function (d) { return d.key[0]; })
.valueAccessor(function (d) { return d.key[1]; })
.colorAccessor(function (d) { return d.value; })
.colors(["#ffffd9", "#edf8b1", "#c7e9b4", "#7fcdbb", "#41b6c4", "#1d91c0", "#225ea8", "#253494", "#081d58"])
.calculateColorDomain();
This is an interesting question. In the general case, one would want the heatmap to change size when the number of rows or columns changes.
If you're not concerned with that, I think doing a count of of the elements is about the best you can do, with d3.set one easy way to get this:
var data = heatGroup.all();
var ncols = d3.set(data.map(function(x) { return x.key[0]; })).size();
var nrows = d3.set(data.map(function(x) { return x.key[1]; })).size();
myHeatMap.width(ncols*45).height(nrows*45)
Feel free to file a feature request, or better yet, a PR, if you'd like to see the more general case. The heatmap is already doing something like this internally, although it doesn't have to compute the unique set since it relies on the ordinal scale for that.

Append Circles to 1 Line in a D3 Multi-Line chart

I have a multi-line chart representing 8 different series of values, for a given date:
http://bl.ocks.org/eoiny/8548406
I have managed to filter out series1 and append circles for each data-point for series1 only, using:
var filtered = city
.filter(function(d){
return d.name == "series1"
})
filtered
.selectAll('circle')
.data(
function(d){return d.values}
)
.enter().append('circle')
.attr({
cx: function(d,i){
return x(d.date)
},
cy: function(d,i){
return y(d.pindex)
},
r: 5
})
However I am trying to append 4 circles to my series1 line, one for each of the following values only:
min value in series1,
max value in series1,
1st value in series1,
last value in series1.
I approached this problem by looking at the "filtered" array and I tried using something like this to catch the min & max values to start with:
.attr("visibility", function(d) {
if (d.pindex == d3.max(filtered, function(d) { return d.pindex; })) {return "visible"}
if (d.pindex == d3.min(filtered, function(d) { return d.pindex; })) {return "visible"}
else { return "hidden" }
;})
But I'm somehow getting muddled up by the fact that the data I need is in an object within the filtered array. I know that filtered should look like this:
[{
name: "series1",
values: [{date: "2005-01-01",
pindex: "100"},
{date: "2005-02-01"
pindex: "100.4"}, ...etc for all data points i.e. dates
]
}]
So I tried something like this:
d.pindex == d3.max(filtered, function(d) { return d.values.pindex; })
but I'm still getting a bit lost. Does anyone have any ideas?
In general, you probably want to filter your data rather than DOM elements. So instead of using city.filter you might use cities.filter to get the data array you're interested in. More importantly, you probably want to filter the data passed to the new circle selection, rather than creating all circles and then selectively showing or hiding them. I might try:
filtered
.selectAll('circle')
.data(function(d){
var points = d.values;
// create the array of desired points, starting with the easy ones
var circleData = [
// first
points[0],
// last
points[points.length - 1]
];
// now find min and max
function getValue(d) { return d.pindex; }
// d3.max returns the max value, *not* the object that contains it
var maxVal = d3.max(points, getValue);
// Note that you might have multiple points with the max. If you
// don't want them all, just take maxPoints[0]
var maxPoints = points.filter(function(d) { return d.pindex === maxVal; });
// same for min
var minVal = d3.min(points, getValue);
var minPoints = points.filter(function(d) { return d.pindex === minVal; });
// stick them all together
return circleData.concat(maxPoints).concat(minPoints);
})
.enter().append('circle')
// etc
Key points:
Filter your data, not your DOM. It's less expensive processing, easier to debug, and generally much easier to get your head around.
d3.min and d3.max don't return the object with the max value, they return the value itself, hence your TypeError.

apply several mouseover events to neighboring (connected) nodes

I have a network diagram (force-directed graph), a scatterplot, and a table that are all interconnected (see jsFiddle). I have the interconnections working the way I want them for mouseover events. I would like to modify my code so that when I mouseover a node in the network diagram, not only is the moused-over node highlighted (and its connections in the scatterplot and table), but its immediate neighbor nodes are also highlighted (as well as their connections in the scatterplot and table).
I looked at the information in Highlight selected node, its links, and its children in a D3 force directed graph for help. Somewhere along the way (not exactly sure where) I found an example of a function that helps define the connected nodes, isConnected().
function isConnected(a, b) {
return linkedByIndex[a.index + "," + b.index] || linkedByIndex[b.index + "," + a.index] || a.index == b.index;
}
I'd like to incorporate this function into my mouseover events, perhaps with an if() statement, so that I can do all of the "highlighting" that I want. But, I'm new to D3 and js and am not sure how to set it up.
Below is the snippet of code (from the jsFiddle) that I would like to modify. I would appreciate any suggestions or pointers to other examples.
var node = svg.selectAll(".node")
.data(graph.nodes)
.enter().append("g")
.attr("class", function(d) { return "node " + d.name + " " + d.location; })
.call(force.drag)
.on("mouseover", function(d) {
// I would like to insert an if statement to do all of these things to the connected nodes
// if(isConnected(d, o)) {
d3.select(this).select("circle").style("stroke-width", 6);
d3.select(this).select("circle").style("stroke", "orange");
d3.select(this).select("text").style("font", "20px sans-serif");
d3.selectAll("rect." + d.location).style("stroke-width", 6);
d3.selectAll("rect." + d.location).style("stroke", "orange");
d3.selectAll("text." + d.location).style("font", "20px sans-serif");
d3.selectAll("tr." + d.name).style("background-color", "orange");
//}
})
.on("mouseout", function(d) {
// if(isConnected(d, o)) {
d3.select(this).select("circle").style("stroke-width", 1.5);
d3.select(this).select("circle").style("stroke", "gray");
d3.select(this).select("text").style("font", "12px sans-serif");
d3.selectAll("rect." + d.location).style("stroke-width", 1.5);
d3.selectAll("rect." + d.location).style("stroke", "gray");
d3.selectAll("text." + d.location).style("font", "12px sans-serif");
d3.selectAll("tr." + d.name).style("background-color", "white");
//}
});
In another scenario I would put my visual objects into a graph data structure and navigate that to efficiently update the appropriate items. But this is d3, but so we will do the same thing but instead of a graph data structure that we create we will use d3 selections (which can be like graphs but for this they will look a lot more like arrays). Algorithmically this approach will not be as efficient, but our graphs are small.
So working backwards I will want a selection that includes only the picked node's neighboring
nodes. I will do this by selecting all the circles and then using the d3 selection filter method to reduce that to only those circles that are neighbors.
Of course then I need the list of neighbors, but a few nice js array methods make short work of that. The final relevant code (in mouseover) is not even that long - but I've added a bunch of comments:
// Figure out the neighboring node id's with brute strength because the graph is small
var nodeNeighbors = graph.links.filter(function(link) {
// Filter the list of links to only those links that have our target
// node as a source or target
return link.source.index === d.index || link.target.index === d.index;})
.map(function(link) {
// Map the list of links to a simple array of the neighboring indices - this is
// technically not required but makes the code below simpler because we can use
// indexOf instead of iterating and searching ourselves.
return link.source.index === d.index ? link.target.index : link.source.index; });
// Reset all circles - we will do this in mouseout also
svg.selectAll('circle').style('stroke', 'gray');
// now we select the neighboring circles and apply whatever style we want.
// Note that we could also filter a selection of links in this way if we want to
// Highlight those as well
svg.selectAll('circle').filter(function(node) {
// I filter the selection of all circles to only those that hold a node with an
// index in my listg of neighbors
return nodeNeighbors.indexOf(node.index) > -1;
})
.style('stroke', 'orange');
You can also try the fiddle
I think the important d3 concept relevant here is that when you associate data with an element (usually using the data() or datum() methods on selections) then that data sticks with that element and any future selections will always use it.
To link other aspects you can pull those attributes in a similar way and link them through d3. For example for the location rectangles you could add to mouseover:
var nodeLocations = graph.links.filter(function(link) {
return link.source.index === d.index || link.target.index === d.index;})
.map(function(link) {
return link.source.index === d.index ? link.target.location : link.source.location; });
d3.selectAll("rect").filter(function(node) { return nodeLocations.indexOf(node.location) > -1; }) .style("stroke", "cyan");
This thing I built does that with the Ego Network feature:
https://gist.github.com/emeeks/4588962
Add a .on("mouseover", findEgo) to your nodes and the following should work, as long as you have some kind of identifying uid attribute, which you could generate when you load the nodes if one isn't handy. It's a bit of overkill, since it allows for n-degree ego networks, and creates an aggregated table for other network analysis functions, but the basic functionality will give you what you want and you or other users might find that aspect useful:
function findEgo(d) {
var computedEgoArray = findEgoNetwork(d.id, 1, false,"individual");
d3.selectAll("circle.node").style("fill", function(p) {return p.id == d.id ? "purple" : computedEgoArray.indexOf(p.id) > -1 ? "blue" : "pink"})
}
function findEgoNetwork(searchNode, egoNetworkDegree, isDirected, searchType) {
var egoNetwork = {};
for (x in nodes) {
if (nodes[x].id == searchNode || searchType == "aggregate") {
egoNetwork[nodes[x].id] = [nodes[x].id];
var z = 0;
while (z < egoNetworkDegree) {
var thisEgoRing = egoNetwork[nodes[x].id].slice(0);
for (y in links) {
if (thisEgoRing.indexOf(links[y].source.id) > -1 && thisEgoRing.indexOf(links[y].target.id) == -1) {
egoNetwork[nodes[x].id].push(links[y].target.id)
}
else if (isDirected == false && thisEgoRing.indexOf(links[y].source.id) == -1 && thisEgoRing.indexOf(links[y].target.id) > -1) {
egoNetwork[nodes[x].id].push(links[y].source.id)
}
}
z++;
}
}
}
if (searchType == "aggregate") {
//if it's checking the entire network, pass back the entire object of arrays
return egoNetwork;
}
else {
//Otherwise only give back the array that corresponds with the search node
return egoNetwork[searchNode];
}
}

Resources