D3 - How to update force simulation when data values change - javascript

I'm stuck on a small problem regarding force simulation in D3.
I have data representing poverty rates for each country, from 1998 to 2008. It's a bubble chart that's split into three clusters, representing poor countries, not-poor countries, and countries with no information.
When the app is initially loaded, it's loaded with the 1998 data. However, I have some buttons at the top, that, when clicked, will change the year, and subsequently the bubbles should rearrange themselves. All I've been able to do, is when the button is clicked, I change a variable year. However, there are functions and variables that use year throughout the code. When year changes, I want to recalculate all the node properties and force parameters that are depending on year
Here's my code. I've included all of it in case you want to try it out. The data file is at the end of this post.
async function init() {
// Set up the canvas
var height = 1000, width = 2000;
var svg = d3.select("#panel1").append("svg")
.attr("height", height)
.attr("width", width)
.attr("class", "bubblePanel");
var canvas = svg.append("g")
.attr("transform", "translate(0,0)");
// Choose what year to look at, based on button clicks.
var year = "X1998"
d3.select("#b1998").on("click", function() {
year = "X1998"
console.log(year)
// NOTIFY SIMULATION OF CHANGE //
})
d3.select("#b1999").on("click", function() {
year = "X1999"
console.log(year)
// NOTIFY SIMULATION OF CHANGE //
})
d3.select("#b2000").on("click", function() {
year = "X2000"
console.log(year)
// NOTIFY SIMULATION OF CHANGE //
})
// Implement the physics of the elements. Three forces act according to the poverty level (poor, not poor, and no info)
var simulation = d3.forceSimulation()
.force("x", d3.forceX(function(d) {
if (parseFloat(d[year]) >= 10) {
return 1700
} else if (parseFloat(d[year]) === 0) {
return 1000
} else {
return 300
}
}).strength(0.05))
.force("y", d3.forceY(300).strength(0.05))
.force("collide", d3.forceCollide(function(d) {
return radiusScale(d[year])
}));
// Function to pick colour of circles according to region
function pickColor(d) {
if (d === "East Asia & Pacific") {
return "red"
} else if (d === "Europe & Central Asia") {
return "orange"
} else if (d === "Latin America & Caribbean") {
return "yellow"
} else if (d === "Middle East & North Africa") {
return "green"
} else if (d === "North America") {
return "blue"
} else if (d === "South Asia") {
return "indigo"
} else {
return "violet"
}
}
// Set the scales for bubble radius, and text size.
var radiusScale = d3.scaleSqrt().domain([0, 50]).range([20,80]);
var labelScale = d3.scaleSqrt().domain([0,50]).range([10,40]);
// Read the data
await d3.csv("wd3.csv").then(function(data) {
// Assign each data point to a circle that is colored according to region and has radius according to its poverty level
var bubbles = svg.selectAll("circle")
.data(data)
.enter().append("circle")
.attr("cx", 100)
.attr("cy", 100)
.attr("fill", function(d) {
return pickColor(d.Region)
})
.attr("r", function(d) {
return radiusScale(d[year])
});
// Assign each ddata point to a text element that shows the counry code of the data point. The text is scaled according to the poverty level
var labels = svg.selectAll("text")
.data(data)
.enter().append("text")
.attr("x", 100)
.attr("y", 100)
.attr("dominant-baseline", "central")
.text(function(d) { return d.XCountryCode })
.style("stroke", "black")
.style("text-anchor", "middle")
.style("font-size", function(d) { return labelScale(d[year]); });
// Code to handle the physics of the bubble and the text
simulation.nodes(data)
.on("tick", ticked)
function ticked() {
bubbles.attr("transform", function(d) {
var k = "translate(" + d.x + "," + d.y + ")";
return k;
})
labels.attr("transform", function(d) {
var k = "translate(" + d.x + "," + d.y + ")";
return k;
})
}
});
}
When year changes, the data values will change for each country. I want the following parts of my code to be updated.
The x forces on the nodes: Countries can go from poor in one year to not-poor in another year, so their cluster will change
The radius of the circles: The radius represents poverty level. These change from year to year, so the size of the circles will change when a button is clicked
The coordinates of the country labels: These labels are attached to the data as well. So when the x forces on the circles causes the circles to move, the labels should move as well.
I'd greatly appreciate the help.
The data file can be found here. I accidentally named it povertyCSV, but in the code, it's referenced as "wd3.csv"

If I understand the question correctly:
Re-initializing Forces
The functions provided to set parameters of d3 forces such as forceX or forceCollision are executed once per node at initialization of the simulation (when nodes are originally assigned to the layout). This saves a lot of time once the simulation starts: we aren't recalculating force parameters every tick.
However, if you have an existing force layout and want to modify forceX with a new x value or new strength, or forceCollision with a new radius, for example, we can re-initialize the force to perform the recalculation:
// assign a force to the force diagram:
simulation.force("someForce", d3.forceSomeForce().someProperty(function(d) { ... }) )
// re-initialize the force
simulation.force("someForce").initialize(nodes);
This means if we have a force such as:
simulation.force("x",d3.forceX().x(function(d) { return fn(d["year"]); }))
And we update the variable year, all we need to do is:
year = "newValue";
simulation.force("x").initialize(nodes);
Positioning
If the forces are re-initialized (or re-assigned), there is no need to touch the tick function: it'll update the nodes as needed. Labels and circles will continue to be updated correctly.
Also, non-positional things such as color need to be updated in the event handler that also re-initializes the forces. Other than radius, most things should either be updated via the force or via modifying the elements directly, not both.
Radius is a special case:
With d3.forceCollide, radius affects positioning
Radius, however, does not need to be updated every tick.
Therefore, when updating the radius, we need to update the collision force and modify the r attribute of each circle.
If looking for a smooth transition of radius that is reflected graphically and in the collision force, this should be a separate question.
Implementation
I've borrowed from your code to make a fairly generic example. The below code contains the following event listener for some buttons where each button's datum is a year:
buttons.on("click", function(d) {
// d is the year:
year = d;
// reheat the simulation:
simulation
.alpha(0.5)
.alphaTarget(0.3)
.restart();
// (re)initialize the forces
simulation.force("x").initialize(data);
simulation.force("collide").initialize(data);
// update altered visual properties:
bubbles.attr("r", function(d) {
return radiusScale(d[year]);
}).attr("fill", function(d) {
return colorScale(d[year]);
})
})
The following snippet uses arbitrary data and due to its size may not allow for nodes to re-organize perfectly every time. For simplicity, position, color, and radius are all based off the same variable. Ultimately, it should address the key part of the question: When year changes, I want to update everything that uses year to set node and force properties.
var data = [
{year1:2,year2:1,year3:3,label:"a"},
{year1:3,year2:4,year3:5,label:"b"},
{year1:5,year2:9,year3:7,label:"c"},
{year1:8,year2:16,year3:11,label:"d"},
{year1:13,year2:25,year3:13,label:"e"},
{year1:21,year2:36,year3:17,label:"f"},
{year1:34,year2:1,year3:19,label:"g"},
{year1:2,year2:4,year3:23,label:"h"},
{year1:3,year2:9,year3:29,label:"i"},
{year1:5,year2:16,year3:31,label:"j"},
{year1:8,year2:25,year3:37,label:"k"},
{year1:13,year2:36,year3:3,label:"l"},
{year1:21,year2:1,year3:5,label:"m"}
];
// Create some buttons:
var buttons = d3.select("body").selectAll("button")
.data(["year1","year2","year3"])
.enter()
.append("button")
.text(function(d) { return d; })
// Go about setting the force layout:
var svg = d3.select("body")
.append("svg")
.attr("width", 500)
.attr("height", 300);
var radiusScale = d3.scaleSqrt()
.domain([0, 40])
.range([5,30]);
var colorScale = d3.scaleLinear()
.domain([0,10,37])
.range(["#c7e9b4","#41b6c4","#253494"]);
var year = "year1";
var simulation = d3.forceSimulation()
.force("x", d3.forceX(function(d) {
if (parseFloat(d[year]) >= 15) {
return 100
} else if (parseFloat(d[year]) > 5) {
return 250
} else {
return 400
}
}).strength(0.05))
.force("y", d3.forceY(150).strength(0.05))
.force("collide", d3.forceCollide()
.radius(function(d) {
return radiusScale(d[year])
}));
var bubbles = svg.selectAll("circle")
.data(data)
.enter().append("circle")
.attr("r", function(d) {
return radiusScale(d[year])
})
.attr("fill", function(d) {
return colorScale(d[year]);
});
var labels = svg.selectAll("text")
.data(data)
.enter()
.append("text")
.text(function(d) {
return d.label;
})
.style("text-anchor","middle");
simulation.nodes(data)
.on("tick", ticked)
function ticked() {
bubbles.attr("cx", function(d) {
return d.x;
}).attr("cy", function(d) {
return d.y;
})
labels.attr("x", function(d) {
return d.x;
})
.attr("y", function(d) {
return d.y +5;
})
}
buttons.on("click", function(d) {
// d is the year:
year = d;
simulation
.alpha(0.5)
.alphaTarget(0.3)
.restart();
simulation.force("x").initialize(data);
simulation.force("collide").initialize(data);
bubbles.attr("r", function(d) {
return radiusScale(d[year]);
}).attr("fill", function(d) {
return colorScale(d[year]);
})
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

Related

Updating chart data, but not all bars redrawing

I'm working on a bar chart that updates its data based on the mouseover of another element. When the chart updates, if there are less bars in the new chart, the chart permanently has fewer bars and changing the data back does not add them back in. I've added a gif to show this - when it gets down to 3 bars, they never come back.
Here's my code:
var scatter_versus_dataset; // the main set
var scatter_versus_dataset_filtered;
// set versus y scale
scatter_versus_y = d3.scaleBand().range([0, SCATTER_VERSUS_HEIGHT])
// set versus x scale
scatter_versus_x_fatal = d3.scaleLinear().range([0, SCATTER_VERSUS_WIDTH / 3]);
scatter_versus_x_nonfatal = d3.scaleLinear().range([-1 * SCATTER_VERSUS_WIDTH / 3, 0 ])
// set the versus colors
scatter_versus_z = d3.scaleOrdinal().range(STACK_COLOURS);
...
function updateScatterVersus(code){
// filter the set
scatter_versus_dataset_filtered = scatter_versus_dataset.filter(function (d) { return (d.majorOccCodeGroup == code) })
scatter_versus_y.domain(scatter_versus_dataset_filtered.map(function (d) { return d.occupation; })).padding(BAR_PADDING);
scatter_versus_x_fatal.domain([0, d3.max(scatter_versus_dataset_filtered, function (d) { return d.f_total_rate; })]).nice();
scatter_versus_x_nonfatal.domain([d3.min(scatter_versus_dataset_filtered, function (d) { return +-1 * d.nf_total_rate; }), 0]).nice();
var bars = d3.selectAll("#scatter_versus_fatal_rect")
.data(scatter_versus_dataset_filtered)
bars.exit()
.remove()
bars.transition()
.duration(600)
.attr("y", function (d) {
return scatter_versus_y(d.occupation);
})
.attr("x", function (d) {
return scatter_versus_x_fatal(0) + SCATTER_VERSUS_GAP_HALF;
})
.attr("width", function (d) {
return scatter_versus_x_fatal(d.f_total_rate);
})
.attr("height", scatter_versus_y.bandwidth())
bars.enter()
.append("rect")
.attr('id', 'scatter_versus_fatal_rect')
.classed("bar", true)
.attr("y", function (d) {
return scatter_versus_y(d.occupation);
})
.attr("x", function (d) {
return scatter_versus_x_fatal(0) + SCATTER_VERSUS_GAP_HALF;
})
.attr("width", function (d) {
return scatter_versus_x_fatal(d.f_total_rate);
})
.attr("height", scatter_versus_y.bandwidth())
}
The process for redrawing the other side of the chart is exactly the same. The problem is still there if i only draw one of the sides.
The data is just from a csv, and I don't think it's the problem - the filtered set has the right number of entries and it's fine in other charts. It's probably something to do with the removal and redrawing but I can't find many examples of this. Or perhaps a key? I can upload some data if needed but it's a pretty big CSV.
id in HTML is unique, only 1 tag should have it.
Select the div for the bars, then selectAll tags with class is bar and bind data.
Remove the id you add to the rects.
var bars = d3.select("#scatter_versus_fatal_rect")
.selectAll(".bar")
.data(scatter_versus_dataset_filtered);
bars.enter()
.append("rect")
// .attr('id', 'scatter_versus_fatal_rect')
.classed("bar", true)
......

D3 stops plotting points when data source is modified

I am plotting points on a UK map using D3 off a live data stream. When the data points exceed 10,000 the browser becomes sluggish and the animation is no longer smooth. So I modify the dataPoints array to keep only the last 5000 points.
However when I modify the dataPoints the first time using splice() D3 stops rendering any new points. The old points gradually disappear (due to a transition) but there are no new points. I am not sure what I am doing wrong here.
I have simulated the problem by loading data of a CSV as well storing it in memory and plotting them at a rate of 1 point every 100ms. Once the number of dots goes above 10 I splice to retain the last 5 points. I see the same behaviour. Can someone review the code and let me know what I am doing wrong?
Setup and the plotting function:
var width = 960,
height = 1160;
var dataPoints = []
var svg = d3.select("#map").append("svg")
.attr("width", width)
.attr("height", height);
var projection = d3.geo.albers()
.center([0, 55.4])
.rotate([4.4, 0])
.parallels([40, 70])
.scale(5000)
.translate([width / 2, height / 2]);
function renderPoints() {
var points = svg.selectAll("circle")
.data(dataPoints)
points.enter()
.append("circle")
.attr("cx", function (d) {
prj = projection([d.longitude, d.latitude])
return prj[0];
})
.attr("cy", function (d) {
prj = projection([d.longitude, d.latitude])
return prj[1];
})
.attr("r", "4px")
.attr("fill", "blue")
.attr("fill-opacity", ".4")
.transition()
.delay(5000)
.attr("r", "0px")
}
/* JavaScript goes here. */
d3.json("uk.json", function(error, uk) {
if (error) return console.error(error);
console.log(uk);
var subunits = topojson.feature(uk, uk.objects.subunits);
var path = d3.geo.path()
.projection(projection);
svg.selectAll(".subunit")
.data(subunits.features)
.enter().append("path")
.attr("class", function(d) { return "subunit " + d.id })
.attr("d", path);
svg.append("path")
.datum(topojson.mesh(uk, uk.objects.subunits, function(a,b) {return a!== b && a.id !== 'IRL';}))
.attr("d", path)
.attr("class", "subunit-boundary")
svg.append("path")
.datum(topojson.mesh(uk, uk.objects.subunits, function(a,b) {return a=== b && a.id === 'IRL';}))
.attr("d", path)
.attr("class", "subunit-boundary IRL")
svg.selectAll(".place-label")
.attr("x", function(d) { return d.geometry.coordinates[0] > -1 ? 6 : -6; })
.style("text-anchor", function(d) { return d.geometry.coordinates[0] > -1 ? "start": "end"; });
svg.selectAll(".subunit-label")
.data(topojson.feature(uk, uk.objects.subunits).features)
.enter().append("text")
.attr("class", function(d) { return "subunit-label " + d.id })
.attr("transform", function(d) { return "translate(" + path.centroid(d) + ")"; })
.attr("dy", ".35em")
.text(function(d) { return d.properties.name; })
// function applyProjection(d) {
// console.log(d);
// prj = projection(d)
// console.log(prj);
// return prj;
// }
lon = -4.6
lat = 55.45
dataPoints.push([lon,lat])
renderPoints()
});
Function to cleanup old points
var cleanupDataPoints = function() {
num_of_elements = dataPoints.length
console.log("Pre:" + num_of_elements)
if(num_of_elements > 10) {
dataPoints = dataPoints.splice(-5, 5)
}
console.log("Post:" + dataPoints.length)
}
Loading data from CSV and plotting at a throttled rate
var bufferedData = null
var ptr = 0
var renderNext = function() {
d = bufferedData[ptr]
console.log(d)
dataPoints.push(d)
ptr++;
renderPoints()
cleanupDataPoints()
if(ptr < bufferedData.length)
setTimeout(renderNext, 100)
}
d3.csv('test.csv', function (error, data) {
bufferedData = data
console.log(data)
setTimeout(renderNext, 100)
})
In the lines
points = svg.selectAll("circle")
.data(dataPoints)
points.enter() (...)
d3 maps each element in dataPoints (indexed from 0 to 5000) to the circle elements (of which there should be 5000 eventually). So from its point of view, there is no enter'ing data: there are enough circles to hold all your points.
To make sure that the same data point is mapped to the same html element after it changed index in its array, you need to use an id field of some sort attached to each of your data point, and tell d3 to use this id to map the data to elements, instead of their index.
points = svg.selectAll("circle")
.data(dataPoints, function(d){return d.id})
If the coordinates are a good identifier for your point, you can directly use:
points = svg.selectAll("circle")
.data(dataPoints, function(d){return d.longitude+" "+d.latitude})
See https://github.com/mbostock/d3/wiki/Selections#data for more details.

Updating bar chart in d3 generating multiple charts

I'm following the given tutorial on D3
bar chart -2
I've setup my code in two functions one is init and one is update
var xScale = null;
var chart = null;
function init(w, c) {
xScale = d3.scale.linear()
.range([0, w]);
chart = d3.select(c)
.append('svg')
.attr('width', w);
function update(data) {
xScale.domain([0, d3.max(data, function(d) { return +d.value; })]);
chart.attr('height', 20 * data.length);
var bars = chart.selectAll('g')
.data(data);
bars.exit().remove();
bars.enter().append('g')
.attr('transform', function(d, i) { return 'translate(0,' + i * 20 + ')'; });
bars.append('rect')
.attr('width', function(d) { return xScale(+d.value); })
.attr('height', 18);
bars.append('text')
.attr('x', function(d) { return xScale(+d.value); })
.attr('y', 10)
.attr('dy', '.45em')
.text(function (d) { return d.name; });
}
When I call update first time, the bar chart is created correctly, on subsequenet update calls, it creates rect and text elements under tags instead of updating
My data is a dict {'name': a, 'value': 12, .....} The number of elements per update can be different. There might be same keys(names) with different values in each update
bars = chart.selectAll('g')
You are selecting all of the g elements (both new and existing).
bars.append('rect');
bars.append('text');
As a result, when you call append on bars, you are appending rect and text elements to both the new and existing g elements.
/* Enter */
enter = bars.enter().append('g');
enter.append('rect');
enter.append('text');
/* Update */
bars.attr('transform', function(d, i) {
return 'translate(0,' + i * 20 + ')';
});
bars.select('rect')
.attr('width', function(d) { return xScale(+d.value); })
.attr('height', 18);
bars.select('text')
.attr('x', function(d) { return xScale(+d.value); })
.attr('y', 10)
.attr('dy', '.45em')
.text(function (d) { return d.name; });
This allows you to append rect and text elements only to the enter selection, yet still allows you to update all the elements using your new data.
Note:
The enter selection merges into the update selection when you append or insert. Rather than applying the same operators to the enter and update selections separately, you can now apply them only once to the update selection after entering the nodes.
See: https://github.com/mbostock/d3/wiki/Selections#data

Plotting svg circles based off csv data

I'm trying to plot circles from data in my csv file, but the circles are not appearing on the svg canvas. I believe the problem stems from how I load in the data (it gets loaded as an array of objects), but I'm not quite sure how to figure out what to do next.
Based off this tutorial: https://www.dashingd3js.com/svg-text-element
D3.js code:
var circleData = d3.csv("files/data.csv", function (error, data) {
data.forEach(function (d) {
d['KCComment'] = +d['KCComment'];
d['pscoreResult'] = +d['pscoreResult'];
d['r'] = +d['r'];
});
console.log(data);
});
var svg = d3.select("body").append("svg")
.attr("width", 480)
.attr("height", 480);
var circles = svg.selectAll("circle")
.data(circleData)
.enter()
.append("circle");
var circleAttributes = circles
.attr("cx", function (d) { return d.KCComment; })
.attr("cy", function (d) { return d.pscoreResult; })
.attr("r", function (d) { return d.r; })
.style("fill", "green");
var text = svg.selectAll("text")
.data(circleData)
.enter()
.append("text");
var textLabels = text
.attr("x", function(d) { return d.KCComment; })
.attr("y", function(d) { return d.pscoreResult; })
.text(function (d) { return "( " + d.KCComment + ", " + d.pscoreResult + " )"; })
.attr("font-family", "sans-serif")
.attr("font-size", "20px")
.attr("fill", "red");
What the CSV looks like:
fmname, fmtype, KCComment, pscoreResult, r
test1, type1, 7.1, 8, 39
test2, type2, 1.2, 3, 12
You should have the circle-drawing code within the d3.csv function's callback, so it's only processed when the data is available.
d3.csv("data.csv", function (error, circleData) {
circleData.forEach(function (d) {
d['KCComment'] = +d['KCComment'];
d['pscoreResult'] = +d['pscoreResult'];
d['r'] = +d['r'];
});
console.log(circleData);
// Do the SVG drawing stuff
...
// Finished
});
Also note that instead of setting var circleData = d3.csv(... you should just define it in the callback function.
Here's a plunker with the working code: http://embed.plnkr.co/fzBX0o/preview
You'll be able to see a number of further issues now: both circles are overlapping and only one quarter is visible. That's because your KCComment and pscoreResult values used to define the circles' cx and cy are too small. Try multiplying them up so that the circles move right and down and are a bit more visible! Same is true of the text locations, but I'll leave those problems for you to solve

How to Limit Size of Radius With d3.js

I'm sure the solution is straight forward, but I'm trying to find out how to limit the range of radius when I plot circles onto a geomap. I have values that range in size significantly, and the larger values end up covering a significant amount of the map.
d3.csv("getdata.php", function(parsedRows) {
data = parsedRows
for (i = 0; i < data.length; i++) {
var mapCoords = this.xy([data[i].long, data[i].lat])
data[i].lat = mapCoords[0]
data[i].long = mapCoords[1]
}
vis.selectAll("circle")
.data(data)
.enter().append("svg:circle")
.attr("cx", function(d) { return d.lat })
.attr("cy", function(d) { return d.long })
.attr("stroke-width", "none")
.attr("fill", function() { return "rgb(255,148,0)" })
.attr("fill-opacity", .4)
.attr("r", function(d) { return Math.sqrt(d.count)})
})
This is what I have right now.
You'll probably want to use d3 scales by setting the domain (min/max input values) and the range (min/max output allowed values). To make this easy, don't hesitate to use d3.min and d3.max to setup the domain's values.
d3.scale will return a function that you can use when assigning the r attribute value. For example:
var scale = d3.scale.linear.domain([ inputMin, inputMax ]).range([ outputMin, outputMax ]);
vis.selectAll("circle")
// etc...
.attr( 'r', function(d) { return scale(d.count) });

Categories

Resources