I'm pretty new to D3, but all the examples I've seen redefine the creation of an element when it's updated. I can see an argument for that if you want to change the way the element is defined (e.g. change a circle to a rectangle), but in most case I've needed the definition is identical.
This example is a merge of this answer and this answer. It's closer to my actual use case, but it also highlights the amount of duplicaiton.
Hopefully I'm way off base in the way I've defined this and there is a much tidier way to do it. Alternatively, I guess the answer is "yes, this is the ideomatic way of doing it".
var svg = d3.select("svg");
d3.select("button").on("click", update);
let color = d3.scaleOrdinal().range(d3.schemeAccent);
let data;
update();
function update() {
updateData();
updateNodes();
}
function updateData() {
let numNodes = ~~(Math.random() * 4 + 10);
data = d3.range(numNodes).map(function(d) {
return {
size: ~~(Math.random() * 20 + 3),
x: ~~(Math.random() * 600),
y: ~~(Math.random() * 200)
};
});
}
function updateNodes() {
var node = svg.selectAll(".node").data(data);
node.exit().remove();
node
.enter()
.append("g")
.classed("node", true)
.append("circle")
.classed("outer", true)
.attr("fill", d => color(d.size))
.attr("opacity", 0.5)
.attr("r", d => d.size * 2)
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.select(function() { return this.parentNode; }) //needs an old style function for this reason: https://stackoverflow.com/questions/28371982/what-does-this-refer-to-in-arrow-functions-in-es6 .select(()=> this.parentNode) won't work
.append("circle")
.classed("inner", true)
.attr("fill", d => color(d.size))
.attr("r", d => d.size)
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.select(function() { return this.parentNode; })
.append("text")
.attr("x", d => d.x)
.attr("y", d => d.y)
.attr("text-anchor", "middle")
.text(d => d.size);
node
.select("circle.inner")
.transition()
.duration(1000)
.attr("fill", d => color(d.size))
.attr("r", d => d.size)
.attr("cx", d => d.x)
.attr("cy", d => d.y);
node
.select("circle.outer")
.transition()
.duration(1000)
.attr("fill", d => color(d.size))
.attr("opacity", 0.5)
.attr("r", d => d.size * 2)
.attr("cx", d => d.x)
.attr("cy", d => d.y);
node
.select("text")
.transition()
.duration(1000)
.attr("x", d => d.x)
.attr("y", d => d.y)
.attr("text-anchor", "middle")
.text(d => d.size);
}
<script src="https://d3js.org/d3.v5.min.js"></script>
<button>Update</button>
<br>
<svg width="600" height="200"></svg>
The simple answer to your question is "no, elements do not need to be redefined with repeated code." The longer answer (which I will try to keep short) concerns the d3 enter / update / exit paradigm and object constancy.
There is already a lot of documentation about d3's data binding paradigm; by thinking about data bound to DOM elements, we can identify the enter selection, new data/elements; the update selection, existing data/elements that have changed; and the exit selection, data/elements to be deleted. Using a key function to uniquely identify each datum when joining it to the DOM allows d3 to identify whether it is new, updated, or has been removed from the data set. For example:
var data = [{size: 8, id: 1}, {size: 10, id: 2}, {size: 24, id: 3}];
var nodes = svg.selectAll(".node").data(data, function (d) { return d.id });
// deal with enter / exit / update selections, etc.
// later on
var updated = [{size: 21, id: 1}, {size: 10, id: 4}, {size: 24, id: 3}];
var nodes_now = svg.selectAll(".node")
.data(updated, function (d) { return d.id });
// nodes_now.enter() will contain {size:10, id: 4}
// nodes_now.exit() will contain {size:10, id: 2}
Again, there is a lot of existing information about this; see the d3 docs and object constancy for more details.
If there are no data/elements in the chart being updated--e.g. if the visualisation is only being drawn once and animation is not desired, or if the data is being replaced each time the chart is redrawn, there is no need to do anything with the update selection; the appropriate attributes can be set directly on the enter selection. In your example, there is no key function so every update dumps all the old data from the chart and redraws it with the new data. You don't really need any of the code following the transformations that you perform on the enter selection because there is no update selection to work with.
The kinds of examples that you have probably seen are those where the update selection is used to animate the chart. A typical pattern is
// bind data to elements
var nodes = d3.selectAll('.node')
.data( my_data, d => d.id )
// delete extinct data
nodes.exit().remove()
// add new data items
var nodeEnter = nodes.enter()
.append(el) // whatever the element is
.classed('node', true)
.attr(...) // initialise attributes
// merge the new nodes into the existing selection to create the enter+update selection
// turn the selection into a transition so that any changes will be animated
var nodeUpdate = nodes
.merge(nodesEnter)
.transition()
.duration(1000)
// now set the appropriate values for attributes, etc.
nodeUpdate
.attr(...)
The enter+update selection contains both newly-initialised nodes and existing nodes that have changed value, so any transformations have to cover both these cases. If we wanted to use this pattern on your code, this might be a way to do it:
// use the node size as the key function so we have some data persisting between updates
var node = svg.selectAll(".node").data(data, d => d.size)
// fade out extinct nodes
node
.exit()
.transition()
.duration(1000)
.attr('opacity', 0)
.remove()
// save the enter selection as `nodeEnter`
var nodeEnter = node
.enter()
.append("g")
.classed("node", true)
.attr("opacity", 0) // set initial opacity to 0
// transform the group element, rather than each bit of the group
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')')
nodeEnter
.append("circle")
.classed("outer", true)
.attr("opacity", 0.5)
.attr("fill", d => color(d.size))
.attr("r", 0) // initialise radius to 0
.select(function() { return this.parentNode; })
.append("circle")
.classed("inner", true)
.attr("fill", d => color(d.size))
.attr("r", 0) // initialise radius to 0
.select(function() { return this.parentNode; })
.append("text")
.attr("dy", '0.35em')
.attr("text-anchor", "middle")
.text(d => d.size)
// merge enter selection with update selection
// the following transformations will apply to new nodes and existing nodes
node = node
.merge(nodeEnter)
.transition()
.duration(1000)
node
.attr('opacity', 1) // fade into view
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')') // move to appropriate location
node.select("circle.inner")
.attr("r", d => d.size) // set radius to appropriate size
node.select("circle.outer")
.attr("r", d => d.size * 2) // set radius to appropriate size
Only elements and attributes that are being animated (e.g. the circle radius or the opacity of the g element of new nodes) or that rely on aspects of the datum that may change (the g transform, which uses d.x and d.y of existing nodes) need to be updated, hence the update code is far more compact than that for the enter selection.
Full demo:
var svg = d3.select("svg");
d3.select("button").on("click", update);
let color = d3.scaleOrdinal().range(d3.schemeAccent);
let data;
update();
function update() {
updateData();
updateNodes();
}
function updateData() {
let numNodes = ~~(Math.random() * 4 + 10);
data = d3.range(numNodes).map(function(d) {
return {
size: ~~(Math.random() * 20 + 3),
x: ~~(Math.random() * 600),
y: ~~(Math.random() * 200)
};
});
}
function updateNodes() {
var node = svg.selectAll(".node").data(data, d => d.size)
node
.exit()
.transition()
.duration(1000)
.attr('opacity', 0)
.remove()
var nodeEnt = node
.enter()
.append("g")
.classed("node", true)
.attr("opacity", 0)
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')')
nodeEnt
.append("circle")
.classed("outer", true)
.attr("opacity", 0)
.attr("fill", d => color(d.size))
.attr("r", d => 0)
.select(function() { return this.parentNode; }) //needs an old style function for this reason: https://stackoverflow.com/questions/28371982/what-does-this-refer-to-in-arrow-functions-in-es6 .select(()=> this.parentNode) won't work
.append("circle")
.classed("inner", true)
.attr("fill", d => color(d.size))
.attr("r", 0)
.select(function() { return this.parentNode; })
.append("text")
.attr("dy", '0.35em')
.attr("text-anchor", "middle")
.text(d => d.size)
node = node
.merge(nodeEnt)
.transition()
.duration(1000)
node
.attr('opacity', 1)
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')')
node.select("circle.inner")
.attr('opacity', 1)
.attr("r", d => d.size)
node
.select("circle.outer")
.attr("opacity", 0.5)
.attr("r", d => d.size * 2)
}
<script src="https://d3js.org/d3.v5.min.js"></script>
<button>Update</button>
<br>
<svg width="600" height="200"></svg>
It is worth noting that there are plenty of d3 examples that have a lot of redundant code in them.
So much for keeping this short...
Related
I am having trouble with my force directed graph. I had to make some changed to my nodes for design purposes and ever since, my forces stoped working.
Each node is now a "g" element with two "circle" elements inside. One being the background of the node and the other being the partially transparent foreground.
Unlike before where I would apply ".call(drag(simulation))" to my node that used to be a "circle", I now need to apply it the the "g" element.
As seen on the screenshot, the nodes are not where they are supposed to be. They are detached from their respective links, and are all in the center of the map , on the top of each other.
Any clue on what I am doing wrong?
ForceGraph(
nodes, // an iterable of node objects (typically [{id}, …])
links // an iterable of link objects (typically [{src, target}, …])
){
var nodeId = d => d.id // given d in nodes, returns a unique identifier (string)
const nodeStrength = -450 // -1750
const linkDistance = 100
const linkStrokeOpacity = 1 // link stroke opacity
const linkStrokeWidth = 3 // given d in links, returns a stroke width in pixels
const linkStrokeLinecap = "round" // link stroke linecap
const linkStrength =1
var width = this.$refs.mapFrame.clientWidth // scale to parent container
var height = this.$refs.mapFrame.clientHeight // scale to parent container
const N = d3.map(nodes, nodeId);
// Replace the input nodes and links with mutable objects for the simulation.
nodes = nodes.map(n => Object.assign({}, n));
links = links.map(l => ({
orig: l,
//Object.assign({}, l)
source: l.src,
target: l.target
}));
// Construct the forces.
const forceNode = d3.forceManyBody();
const forceLink = d3.forceLink(links).id(({index: i}) => N[i]);
forceNode.strength(nodeStrength);
forceLink.strength(linkStrength);
forceLink.distance(linkDistance)
const simulation = d3.forceSimulation(nodes)
.force(link, forceLink)
.force("charge", forceNode)
.force("x", d3.forceX())
.force("y", d3.forceY())
.on("tick", ticked);
const svg = d3.create("svg")
.attr("id", "svgId")
.attr("preserveAspectRatio", "xMidYMid meet")
.attr("viewBox", [-width/2,-height/2, width,height])
.classed("svg-content-responsive", true)
const defs = svg.append('svg:defs');
defs.selectAll("pattern")
.data(nodes)
.join(
enter => {
// For every new <pattern>, set the constants and append an <image> tag
const patterns = enter
.append("pattern")
.attr("preserveAspectRatio", "none")
.attr("viewBox", [0,0, 100,100])
.attr("width", 1)
.attr("height", 1);
patterns
.append("image")
.attr("width", 80)
.attr("height", 80)
.attr("x", 10)
.attr("y", 10);
return patterns;
}
)
// For every <pattern>, set it to point to the correct
// URL and have the correct (company) ID
.attr("id", d => d.id)
.select("image")
.datum(d => {
return d;
})
.attr("xlink:href", d => {
return d.image
})
const link = svg.append("g")
.attr("stroke-opacity", linkStrokeOpacity)
.attr("stroke-width", linkStrokeWidth)
.attr("stroke-linecap", linkStrokeLinecap)
.selectAll("line")
.data(links)
.join("line")
;
link.attr("stroke", "white")
var node = svg
.selectAll(".circle-group")
.data(nodes)
.join(enter => {
node = enter.append("g")
.attr("class", "circle-group");
node.append("circle")
.attr("class", "background") // classes aren't necessary here, but they can help with selections/styling
.style("fill", "red")
.attr("r", 30);
node.append("circle")
.attr("class", "foreground") // classes aren't necessary here, but they can help with selections/styling
.style("fill", d => `url(#${d.id})`)
.attr("r", 30)
})
node.call(drag(simulation))
function ticked() {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
//.transform("translate", d => "translate("+[d.x,d.y]+")"); // triggers error
.attr("transform", d => "translate("+[d.x,d.y]+")");
//.attr("cx", d => d.x)
//.attr("cy", d => d.y);
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
return Object.assign(svg.node() );
}//forcegraph
EDIT 1
I updated the ticked() function with what was suggested but
".transform("translate", d => "translate("+[d.x,d.y]+")");" triggered the following error :
Mapping.vue?d90b:417 Uncaught TypeError: node.transform is not a function
at Object.ticked (Mapping.vue?d90b:417:1)
at Dispatch.call (dispatch.js?c68f:57:1)
at step (simulation.js?5481:32:1)
at timerFlush (timer.js?74f4:61:1)
at wake (timer.js?74f4:71:1)
So I changed it for ".attr("transform", d => "translate("+[d.x,d.y]+")");"
I don't get any error anymore but my nodes are still all in the center of the map as per the initial screenshot.
I am not quite sure what I am doing wrong. Perhaps I need to call ".call(drag(simulation))" on each of the two circles instead of calling it on node?
g elements don't have cx or cy properties. Those are specific to circle elements (and ellipses). This is why your positioning does not work. However, both circle and g can use a transform for positioning. Instead of:
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
You can use:
node
.transform("translate", d => "translate("+[d.x,d.y]+")");
In regards to your question title, d3 does not apply a force to the elements but rather the data itself. The forces continue to work regardless of whether you render the changes - as seen in your case by the links which move as they should.
I am trying to update the color of the graph, on updating, all the previous graphs are also visible
Here is my code:-
class BarChart extends Component {
state = {
color: "green",
};
componentDidUpdate = (prevProps, prevState) => {
if (prevState.color != this.props.color) {
this.drawChart();
}
};
drawChart() {
const data = [12, 5, 6, 6];
const svg = d3
.select("body")
.append("svg")
.attr("width", 400)
.attr("height", 400)
svg
.selectAll("rect")
.data(data)
.enter()
.append("rect")
.attr("x", (d, i) => i * 70)
.attr("y", (d, i) => 400 - 10 * d)
.attr("width", 65)
.attr("height", (d, i) => d * 10)
.attr("fill", this.props.color);
svg
.selectAll("text")
.data(data)
.enter()
.append("text")
.text((d) => d)
.attr("x", (d, i) => i * 70)
.attr("y", (d, i) => 400 - 10 * d - 3);
svg.data(data).exit().remove();
}
render() {
return <div>{this.drawChart}</div>
);
}
}
I've figured that I need to change the selectAll part, but don't know exactly how to change it?
You need to include an .exit declaration
svg
.selectAll("text")
.data(data)
.exit()
.remove()
svg
.selectAll("rect")
.data(data)
.exit()
.remove()
http://bl.ocks.org/alansmithy/e984477a741bc56db5a5
You may want to instead use a .selectAll("g") element/container to avoid having to maintain 'text' and 'rect' selections separately.
My goal is to have an animation appear after some arcs have animated on an arc graph. I first place the nodes, then on a user's click, I highlight the clicked (source) node, and animate the arcs. What I can't make work is the animation of the target nodes after the arcs have finished animating. I think what is happening is that the nodes get the new colors, but then get overwritten because on("end") is executed for all nodes and all arcs. I don't know how to make it run for just the targets of the selected source node. Here is my code so far.
// adapted from https://www.d3-graph-gallery.com/graph/arc_highlight.html
// and https://www.d3-graph-gallery.com/arc
animateArcsFromNodesForEach = {
const radius = 8;
const container = d3.select(DOM.svg(width+margin.left+margin.right,
height+margin.top+margin.bottom))
const arcGroup = container
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
// create the nodes
const nodes = arcGroup.selectAll("nodes")
.data(graphData.nodes)
.enter().append("circle")
.attr("cx", d => xScale(d.name))
.attr("cy", height-50)
.attr("r", radius)
.attr("fill", "steelblue")
// create the node labels
arcGroup.selectAll("nodeLabels")
.data(graphData.nodes)
.enter().append("text")
.attr("x", d => xScale(d.name))
.attr("y", height-20)
.attr("fill", "darkgrey")
.style("text-anchor", "middle")
.text(d => d.name)
// This code builds up the SVG path element; see nodesAndArcs for details
function buildArc(d) {
let start = xScale(idToNode[d.source].name);
let end = xScale(idToNode[d.target].name);
const arcPath = ['M', start, height-50, 'A', (start - end)/2, ',', (start-end)/2, 0,0,",",
start < end ? 1: 0, end, height-50].join(' ');
return arcPath;
}
// create the arcs
let arcs = arcGroup.selectAll("arcs")
.data(graphData.links)
.enter().append("path")
.style("fill", "none")
.attr("stroke", "none")
.attr("d", d => buildArc(d));
// When the user mouses over a node,
// add interactive highlighting to see connections between nodes
nodes.on('mouseover', function(d) {
// highlight only the selected node
d3.select(this).style("fill", "firebrick");
// next, style the arcs
arcs
// the arc color and thickness stays as the default unless connected to the selected node d
// notice how embedding the reference to arcs within nodes.on() allows the code to connect d to arcd
// this code iterates through all the arcs so we can compare each to the selected node d
.style('stroke', function (arcd) {
return arcd.source === d.id ? 'firebrick' : 'none';})
.style('stroke-width', function (arcd) {
return arcd.source === d.id ? 4 : 1;})
.attr("stroke-dasharray", function(arcd) {
return arcd.source === d.id ? this.getTotalLength() : 0;})
.attr("stroke-dashoffset", function(arcd)
{ return arcd.source === d.id ? this.getTotalLength() : 0;})
// reveal the arcs
.transition()
.duration(3000)
.attr("stroke-dashoffset", 0)
// this is the part that isn't working
.on("end", function(arcd) {
nodes
.transition().style("fill", function(nd) {
return ((d.id === arcd.source) && (nd.id === arcd.target)) ? 'firebrick' : 'steelblue';
})
})
});
// remove highlighting when user mouse moves out of node by restoring default colors and thickness
nodes.on('mouseout', function () {
nodes.style("fill", "steelblue");
arcs.style('stroke', 'none');
arcs.style('stroke-width', 1);
});
return container.node();
}
Anyone managed to make the example
Sankey diagram with horizontal and vertical node movement
work in v4
since sankey.relayout() is not available anymore
d3.drag has no .origin anymore.
My attempt do the wildest things while attempting to drag a node and since some behaviors have changed in both sankey and drag specifications I'm unable to figure how to migrate the example to v4.
var graph = { "nodes": [...], "links": [...] };
var layout = d3.sankey();
layout
.extent([[1, 1], [width - 1, height - 6]])
.nodeId(function (d) {
return d.id;
})
.nodeWidth(12)
.nodePadding(padding)
.nodeAlign(d3.sankeyRight)
layout(graph);
// Add Links
var link = svg.append("g")
.attr("fill", "none")
.selectAll(".link")
.data(graph.links)
.enter()
.append("g")
.attr("class", "link")
link.append("path")
.attr("d", d3.sankeyLinkHorizontal())
.attr("stroke", "#000")
.style("stroke-width", function (d) {
return d.width;
})
.append("title")
.text("Some text");
// Drag behavior for node elements
var drag = d3.drag()
//.origin(function (d) { return d; }) // Deprecated but maybe unnecessary
.on("drag", dragmove)
// Add nodes
var node = svg.append("g")
.selectAll(".node")
.data(graph.nodes)
.enter()
.append("g")
.attr("transform", function (d) {
return "translate(" + [d.x0, d.y0] + ")";
})
.call(drag) // The g element should be now draggable
// Add element inside g element
node.append("rect")
.attr("height", function (d) { return d.y1 - d.y0; })
.attr("width", function (d) { return d.x1 - d.x0; })
.attr("fill", ...)
node.append("text")
.attr("x", function (d) { return (d.x1 - d.x0) - 6; })
.attr("y", function (d) { return (d.y1 - d.y0) / 2; })
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.text(function (d) { return d.name; })
.filter(function (d) { return d.x0 < width / 2; })
.attr("x", function (d) { return (d.x1 - d.x0) + 6 })
.attr("text-anchor", "start");
// Called by d3.drag
function dragmove(d) {
var dx = Math.round(d.x = Math.max(0, Math.min(width , evt.x)));
var dy = Math.round(d.y = Math.max(0, Math.min(height, evt.y)));
d3.select(this).attr("transform", "translate(" + [dx, dy] + ")")
// Now should redraw the links but
sankey.relayout(); // not a function anymore.
// path references sankey.link() that is also deprecated in v4 in
// favour of d3.sankeyLinkHorizontal() ? (I'm not sure)
// link references the g element containing the path elements
// classed .link
link.attr("d", path);
};
I'm trying to get drag functionality to work on D3, and have copied the code directly from the developer's example.
However it seems the origin (what is being clicked) is not being passed correctly into the variable d, which leads to the error: 'Cannot read property 'x' of undefined'
The relevant code:
var drag = d3.behavior.drag()
.on("drag", function(d,i) {
d.x += d3.event.dx
d.y += d3.event.dy
d3.select(this).attr("transform", function(d,i){
return "translate(" + [ d.x,d.y ] + ")"
})
});
var svg = d3.select("body").append("svg")
.attr("width", 1000)
.attr("height", 300);
var group = svg.append("svg:g")
.attr("transform", "translate(10, 10)")
.attr("id", "group");
var rect1 = group.append("svg:rect")
.attr("rx", 6)
.attr("ry", 6)
.attr("x", 5/2)
.attr("y", 5/2)
.attr("id", "rect")
.attr("width", 250)
.attr("height", 125)
.style("fill", 'white')
.style("stroke", d3.scale.category20c())
.style('stroke-width', 5)
.call(drag);
Usually, in D3 you create elements out of some sort of datasets. In your case you have just one (perhaps, one day you'll want more than that). Here's how you can do it:
var data = [{x: 2.5, y: 2.5}], // here's a dataset that has one item in it
rects = group.selectAll('rect').data(data) // do a data join on 'rect' nodes
.enter().append('rect') // for all new items append new nodes with the following attributes:
.attr('x', function (d) { return d.x; })
.attr('y', function (d) { return d.y; })
... // other attributes here to modify
.call(drag);
As for the 'drag' event handler:
var drag = d3.behavior.drag()
.on('drag', function (d) {
d.x += d3.event.dx;
d.y += d3.event.dy;
d3.select(this)
.attr('transform', 'translate(' + d.x + ',' + d.y + ')');
});
Oleg's got it, I just wanted to mention one other thing you might do in your case.
Since you only have a single rect, you can bind data directly to it with .datum() and not bother with computing a join or having an enter selection:
var rect1 = svg.append('rect')
.datum([{x: 2.5, y: 2.5}])
.attr('x', function (d) { return d.x; })
.attr('y', function (d) { return d.y; })
//... other attributes here
.call(drag);