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.
Related
I would like to make so that my nodes fit a rectangle shaped space instead of a circle (default gravity).
I have read a similar topic here but was unable to make it work with my code.
// Construct the forces.
const forceNode = d3.forceManyBody();
const forceLink = d3.forceLink(links).id(({index: i}) => N[i]);
forceNode.strength(-150);
forceLink.strength(1);
forceLink.distance(50)
const simulation = d3.forceSimulation(nodes)
.force(link, forceLink)
.force("charge", d3.forceManyBody().strength(-150))
.force("collide", d3.forceCollide(10).strength(10).iterations(1))
.force('x', d3.forceX(width/4).strength(1))
.force('y', d3.forceY(height/4).strength(10))
.on("tick", ticked);
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
.attr("transform", function (d) {
return "translate(" + d.x + "," + d.y + ")";});
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart(); //comment to remove sim
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); //comment to remove sim
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
Then I apply ".call(drag(simulation));" to my node element
When the map loads, the nodes and links are all cramed into a vertical line, then after a few seconds and a few lag spikes they end up forming a sort of oval. The map remains messy and unreadable.
Any clue what I am doing wrong?
I just mimic the code d3 update pattern trying to render some rect with updated data
here is my code.
function update(data){
var r = g.selectAll("rect").data(data,function(d){return (d)});
r.exit().attr("class","exit").remove()
r
.attr("class","update")
.attr("x",(d, i) =>{return i* (50+interval)})
.attr("y", (d)=>{ return y(d)})
.attr("width", "20px")
.transition(t)
.attr("height",( d => { return height-padding.top-padding.bottom-y(d);}))
r.enter()
.append("rect")
.attr("width", "20px")
.attr("class","new")
.attr("x",(d, i) =>{ return i * (50+interval)})
.attr("y", (d)=>{return y(d)})
.attr("height",( d => { return height-padding.top-padding.bottom-y(d);}))
}
then I call the update function twice
update([3,2,1,5,4,10,9,7,8,6])
setTimeout(()=>{update([2,3,1,5,4,10,9,7,8,6])},1000)
Expected: only the first and second rect will be rerendered and set class "new", but in fact, all the rect will be set class "new" .
Codepen
The enter/exit pattern works when the data is an array of identified objects.
Replace this code:
var r = g.selectAll("rect").data(data,function(d){return (d)});
with:
const _data = data.map((v,i) => ({id: i, value: v}));
const r = g.selectAll("rect").data(_data,d => d.id);
The D3 will identify each object and update it accordingly instead of replacing with a new one.
See it's working in a pen
UPD:
If you want to highlight the items whose values have been changed, you can save the current value in an attribute of a newly added item:
r.enter()
.append("rect")
.attr('cur-value', d => d.value)
...
then, on update, query the value and compare with the one in datum:
r.attr("class","update")
...
.each(function(d) {
const rect = d3.select(this);
const prevValue = parseInt(rect.attr('cur-value'));
rect.attr('cur-value', d.value);
rect.style('fill', prevValue === d.value ? 'black' : 'red')
});
You can see it's working in the updated pen.
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();
}
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...
A D3 bubblechart. Group and position svg:circles and svg:text elements
the function render() creates an svg element, circle and text as usual. This function includes .exit.remove update patterns.
runSimulation() is executed after page opening and a createChart() function.
click on a circle executes runSimulation() again, removing the circle
with .exit().remove() etc.
Simplified code:
fundtion render (){
const nodeEnter = this.nodeElements
.enter()
.append('svg:svg');
nodeEnter
.append('circle')
.on('click',runSimulation);
const textEnter = this.textElements
.enter()
.append('svg:text');
}
this.runSimulation(){
this.render();
function ticked(){
this.nodeElements
.attr('cx', cxValue)
.attr('cy', cyValue):
}
this.simulation.nodes.on.('tick',ticked);
}
On the first run the cx and cy attributes are appended to the svg:svg while the circles do not have the attributes and everything is rendered in the top left corner ( also with using svg:g)
on the click action the runSimulation is executed a second time; now the circle gets the cx and cy attributes attached and all elements move into the expected position.
-I am looking for a way to get the cx cy attributes to the circle on the first rendering so that the parent elements do not cluster at x=0 y =0, or to get x and y to svg:svg; the shown pattern is not working and I appreciate your help.
this.nodeElements = this.nodeGroup.selectAll('g')
.data(this.data, node => node.nodeName);
this.nodeElements.exit().remove();
const nodeEnter = this.nodeElements
.enter()
.append('g');
nodeEnter
.append('circle')
.attr('fill', node => this.color(node.familyType))
.attr('r', function (node) {
return (node.nodeName.length > 10) ? "75" : node.nodeName.length*6;
})
.on('click', this.runSimulation);
let wrap = (text) => {
text.each(function() {
let text = d3.select(this),
const textnode = nodeEnter
.append('text')
.attr('id', 'text')
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'middle')
.attr('dy', '0.1em')
.attr('font-size', '1.2em');
this.settings.getValue('option1').then((option) => {
if (option === true) {
let type = node => node.nodeName;
textnode
.text(type);
}
if (option === false) {
let type = node => node.otherName;
textnode
.text(type);
}
})
Thank you for your answer, Steven. runSimulation executes a tick function that does add the proper d.x and d.y now. I am not sure why it did not work - ionic lifecycle events ?