I am new to JavaScript and not sure how to write this into a loop. How do I change the node width in the D3 library for Sankey diagrams so that it is not one constant value and instead updates based on the node names?
For example, I have this data:
"nodes": [
{"name":"A" },
{"name":"B" },
{"name":"C" }
],
And I want the node width of A=10, B=20, C=30.
Here is the relevant code from https://bl.ocks.org/tomshanley.
var sankey = d3.sankeyCircular()
.nodeWidth(10)
.nodePadding(40) //note that this will be overridden by nodePaddingRatio
.nodePaddingRatio(0.15)
.size([width, height])
.nodeId(function (d) {
return d.name;
})
.nodeAlign(d3.sankeyJustify)
.iterations(42)
.circularLinkGap(3);
Thank you!
To respect the data-driven approach it would be better to have the width inside your data array:
"nodes": [ {"name":"A", "width": 10 }, ...]
Then it will become:
.nodeWidth(d => d.width)
Otherwise you will need a way to map the name to the width, e.g. with:
var nameToWidth = {"A": 10, ...};
Then it will become:
.nodeWidth(d => nameToWidth[d.name])
Related
I am building a web application to display different trends and stats between countries in the world. With d3, I am able to load the topojson file and project the world map.
var countryStatistics = [];
var pathList = [];
function visualize(){
var margin = {top: 100, left: 100, right: 100, bottom:100},
height = 800 - margin.top - margin.bottom,
width = 1200 - margin.left - margin.right;
//create svg
var svg = d3.select("#map")
.append("svg")
.attr("height", height + margin.top + margin.bottom)
.attr("width", width + margin.left + margin.right)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
//load topojson file
d3.queue()
.defer(d3.json, "world110m.json")
.await(ready)
var projection = d3.geoMercator()
.translate([ width / 2, height / 2 ])
.scale(180)
//pass path lines to projection
var path = d3.geoPath()
.projection(projection);
function ready (error, data){
console.log(data);
//we pull the countries data out of the loaded json object
countries = topojson.feature(data, data.objects.countries).features
//select the country data, draw the lines, call mouseover event to change fill color
svg.selectAll(".country")
.data(countries)
.enter().append("path")
.attr("class", "country")
.attr("d", path)
.on('mouseover', function(d) {
d3.select(this).classed("hovered", true)
//this function matches the id property in topojson country, to an id in (see below)
let country = matchPath(this.__data__.id);
console.log(country)
})
.on('mouseout', function(d) {
d3.select(this).classed("hovered", false)
})
//here I push the country data into a global array just to have access and experimentalism.
for (var i = 0; i < countries.length; i++) {
pathList.push(countries[i]);
}
}
};
The matchPath() function is used to allow me to match the path data to countryStatistics for display when a certain country is mouseovered.
function matchPath(pathId){
//to see id property of country currently being hovered over
console.log("pathID:" + pathId)
//loop through all countryStatistics and return the country with matching id number
for(var i = 0; i < countryStatistics.length; i++){
if(pathId == countryStatistics[i].idTopo){
return countryStatistics[i];
}
}
}
The Problem: This works, but only in one direction. I can reach my statistical data from each topojson path... but I can't reach and manipulate individual paths based on the data.
What I would like to happen, is to have a button that can select a certain property from countryStatistics, and build a domain/range scale and set a color gradient based on the data values. The step I am stuck on is getting the stat data and path data interfacing.
Two potential solutions I see,
1:There is a way to connect the topo path data to the statistical data during the render, I could call a function to redraw sgv...
2:I build a new object that contains all of the path data and statistical data. In this case can I just pull out the topojson.objects.countries data and ignore the rest?
How should I achieve this? Any pointers, next step will be appreciated.
(where I am at with this project... http://conspiracytime.com/globeApp )
TopoJSON is a really powerfull tool. It has its own CLI (command line interface) to generate your own TopoJSON files.
That CLI allows you to create a unique file with the topology and the data you want to merge with.
Even though it is in its v3.0.2 the first versión looks the clear one to me. This is an example of how you can merge a csv file with a json through a common id attribute.
# Simplified versión from https://bl.ocks.org/luissevillano/c7690adccf39bafe583f72b044e407e8
# note is using TopoJSON CLI v1
topojson \
-e data.csv \
--id-property CUSEC,cod \
-p population=+t1_1,area=+Shape_area \
-o cv.json \
-- cv/shapes/census.json
There is a data.csv file with a cod column and census.json file with a property named CUSEC.
- Using the --id-property you can specify which attributes will be used in the merge.
- With the property -p you can create new properties on the fly.
This is the solid solution where you use one unique file (with one unique request) with the whole data. This best scenario is not always possible so another solution could be the next one.
Getting back to JavaScript, you can create a new variable accessible through the attribute in common the following way. Having your data the format:
// countryStatistics
{
"idTopo": "004",
"country": "Afghanistan",
"countryCode": "afg",
// ..
},
And your TopoJSON file the structure:
{"type":"Polygon","arcs":[[0,1,2,3,4,5]],"id":"004"},
{"type":"MultiPolygon","arcs":[[[6,7,8,9]],[[10,11,12]]],"id":"024"} // ...
A common solution to this type of situation is to create an Array variable accessible by that idTopo:
var dataById = [];
countryStatistics.forEach(function(d) {
dataById[d.idTopo] = d;
});
Then, that variable will have the next structure:
[
004:{
"idTopo": "004",
"country": "Afghanistan",
"countryCode": "afg",
//...
},
008: {
//...
}
]
From here, you can access the properties through its idTopo attribute, like this:
dataById['004'] // {"idTopo":"004","country":"Afghanistan","countryCode":"afg" ...}
You can decide to iterate through the Topo data and add these properties to each feature:
var countries = topojson
.feature(data, data.objects.countries)
.features.map(function(d) {
d.properties = dataById[d.id];
return d
});
Or use this array whenever you need it
// ...
.on("mouseover", function(d) {
d3.select(this).classed("hovered", true);
console.log(dataById[d.id]);
});
I have build a connection by using d3. The codes show the data and method of connection:
var places = {
TYO: [139.76, 35.68],
BKK: [100.48, 13.75],
BER: [13.40, 52.52],
NYC: [-74.00, 40.71],
};
var connections = {
CONN1: [places.TYO, places.BKK],
CONN2: [places.BER, places.NYC],
};
...
svg.append("path")
.datum({type: "LineString", coordinates: connections.CONN1})
.attr("class", "route")
.attr("d", path);
svg.append("path")
.datum({type: "LineString", coordinates: connections.CONN2})
.attr("class", "route")
.attr("d", path);
You can see my codes, that I use the two identical methods to build two connections. That is not good to build more connections.
I am wondering, if there is a loop function to interpret the connections by using data "connections" directly? I mean, I could get information for data "connections" and use them directly to build connections.
I have thought some ways, such as .datum({type: "LineString", function(d,i) {
return coordinates: connections[i];});. But it does not work.
Could you please tell me some way to solve it? Thanks.
Generally when you want to append many features in d3, you want to use an array not an object. With an array you can use a d3 enter selection which will then allow you to build as many features as you need (if sticking to an object, note that connections[0] is not what you are looking for, connections["conn1"] is).
Instead, use a data structure like:
var connections = [
[places.TYO, places.NYC],
[places.BKK, places.BER],
...
]
If you must have identifying or other properties for each datapoint use something like:
var connections = [
{points:[places.TYO, places.NYC],id: 1,...},
{points:[places.BKK, places.BER],id: 2,...},
...
]
For these set ups you can build your lines as follows:
paths = svg.selectAll(".connection")
.data(connections)
.enter()
.append("path")
.attr("class","connection")
.attr('d', function(d) {
return path ({
type:"LineString",
coordinates: d
});
})
See here. Or:
paths = svg.selectAll(".connection")
.data(connections)
.enter()
.append("path")
.attr("class","connection")
.attr('d', function(d) {
return path ({
type:"LineString",
coordinates: d.points
});
})
Alternatively, you can use a data set up like:
var connections = [
{target:"TYO", source:"NYC"},
{target:"BKK", source: "BER"},
...
]
paths = svg.selectAll(".connection")
.data(connections)
.enter()
.append("path")
.attr("class","connection")
.attr('d', function(d) {
return path ({
type:"LineString",
coordinates: [ places[d.source],places[d.target] ]
});
})
See here.
If selecting elements that don't yet exist, using these lines
d3.select("...")
.data(data)
.enter()
.append("path")
will append a path for each item in the data array - this means that d3 generally avoids the use of for loops as the desired behavior is baked right into d3 itself.
I want to apply several forces (forceX and forceY) respectively to several subparts of nodes.
To be more explanatory, I have this JSON as data for my nodes:
[{
"word": "expression",
"theme": "Thème 6",
"radius": 3
}, {
"word": "théorie",
"theme": "Thème 4",
"radius": 27
}, {
"word": "relativité",
"theme": "Thème 5",
"radius": 27
}, {
"word": "renvoie",
"theme": "Thème 3",
"radius": 19
},
....
]
What I want is to apply some forces exclusively to the nodes that have "Thème 1" as a theme attribute, or other forces for the "Thème 2" value, etc ...
I have been looking in the source code to check if we can assign a subpart of the simulation's nodes to a force, but I haven't found it.
I concluded that I would have to implement several secondary d3.simulation() and only apply their respective subpart of nodes in order to handle the forces I mentioned earlier.
Here's what I thought to do in d3 pseudo-code :
mainSimulation = d3.forceSimulation()
.nodes(allNodes)
.force("force1", aD3Force() )
.force("force2", anotherD3Force() )
cluster1Simulation = d3.forceSimulation()
.nodes(allNodes.filter( d => d.theme === "Thème 1"))
.force("subForce1", forceX( .... ) )
cluster2Simulation = d3.forceSimulation()
.nodes(allNodes.filter( d => d.theme === "Thème 2"))
.force("subForce2", forceY( .... ) )
But I think it's not optimal at all considering the computation.
Is it possible to apply a force on a subpart of the simulation's nodes without having to create other simulations ?
Implementing altocumulus' second solution :
I tried this solution like this :
var forceInit;
Emi.nodes.centroids.forEach( (centroid,i) => {
let forceX = d3.forceX(centroid.fx);
let forceY = d3.forceY(centroid.fy);
if (!forceInit) forceInit = forceX.initialize;
let newInit = nodes => {
forceInit(nodes.filter(n => n.theme === centroid.label));
};
forceX.initialize = newInit;
forceY.initialize = newInit;
Emi.simulation.force("X" + i, forceX);
Emi.simulation.force("Y" + i, forceY);
});
My centroids array may change, that's why I had to implement a dynamic way to implement my sub-forces.
Though, i end up having this error through the simulation ticks :
09:51:55,996 TypeError: nodes.length is undefined
- force() d3.v4.js:10819
- tick/<() d3.v4.js:10559
- map$1.prototype.each() d3.v4.js:483
- tick() d3.v4.js:10558
- step() d3.v4.js:10545
- timerFlush() d3.v4.js:4991
- wake() d3.v4.js:5001
I concluded that the filtered array is not assigned to nodes, and I can't figure why.
PS : I checked with a console.log : nodes.filter(...) does return an filled array, so this is not the problem's origin.
To apply a force to only subset of nodes you basically have to options:
Implement your own force, which is not as difficult as it may sound, because
A force is simply a function that modifies nodes’ positions or velocities;
–or, if you want to stick to the standard forces–
Create a standard force and overwrite its force.initialize() method, which will
Assigns the array of nodes to this force.
By filtering the nodes and assigning only those you are interested in, you can control on which nodes the force should act upon:
// Custom implementation of a force applied to only every second node
var pickyForce = d3.forceY(height);
// Save the default initialization method
var init = pickyForce.initialize;
// Custom implementation of .initialize() calling the saved method with only
// a subset of nodes
pickyForce.initialize = function(nodes) {
// Filter subset of nodes and delegate to saved initialization.
init(nodes.filter(function(n,i) { return i%2; })); // Apply to every 2nd node
}
The following snippet demonstrates the second approach by initializing a d3.forceY with a subset of nodes. From the entire set of randomly distributed circles only every second one will have the force applied and will thereby be moved to the bottom.
var width = 600;
var height = 500;
var nodes = d3.range(500).map(function() {
return {
"x": Math.random() * width,
"y": Math.random() * height
};
});
var circle = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height)
.selectAll("circle")
.data(nodes)
.enter().append("circle")
.attr("r", 3)
.attr("fill", "black");
// Custom implementation of a force applied to only every second node
var pickyForce = d3.forceY(height).strength(.025);
// Save the default initialization method
var init = pickyForce.initialize;
// Custom implementation of initialize call the save method with only a subset of nodes
pickyForce.initialize = function(nodes) {
init(nodes.filter(function(n,i) { return i%2; })); // Apply to every 2nd node
}
var simulation = d3.forceSimulation()
.nodes(nodes)
.force("pickyCenter", pickyForce)
.on("tick", tick);
function tick() {
circle
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
}
<script src="https://d3js.org/d3.v4.js"></script>
I fixed my own implementation of altocumulus' second solution :
In my case, I had to create two forces per centroid.
It seems like we can't share the same initializer for all the forceX and forceY functions.
I had to create local variables for each centroid on the loop :
Emi.nodes.centroids.forEach((centroid, i) => {
let forceX = d3.forceX(centroid.fx);
let forceY = d3.forceY(centroid.fy);
let forceXInit = forceX.initialize;
let forceYInit = forceY.initialize;
forceX.initialize = nodes => {
forceXInit(nodes.filter(n => n.theme === centroid.label))
};
forceY.initialize = nodes => {
forceYInit(nodes.filter(n => n.theme === centroid.label))
};
Emi.simulation.force("X" + i, forceX);
Emi.simulation.force("Y" + i, forceY);
});
I've picked apart a couple of different examples to get as far as I have. I have a simple two-line line chart being fed by an object. For testing, I created a function that increments the data by adding a new datapoint to the end. I would like my chart to reflect those additions.
What works:
The data gets added to the object.
When button clicked, YScale adjusts
What doesn't work:
XScale doesn't adjust for new data.
Team 1 line adapts Team2 line's data and then scales along the Y accordingly.
It's a mess. And Just when I think I understand D3, the data incorporation humbles me!
Here's some code and a mostly-working fiddle.
UPDATED: Here's an updated fiddle that has it working as expected. I know D3 makes a lot of this stuff easier so, while it works, I'm looking for a more graceful way to get the same outcome.
My data format...
//SET SAMPLE DATA
var data = [{
"team": "Team 1",
"score": 0,
"elapsedTime": 0
}, {
"team": "Team 1",
"score": 2,
"elapsedTime": 3
},
...
{
"team": "Team 2",
"score": 18,
"elapsedTime": 60
}];
My update function...
function updateChart(){
//SPLIT UPDATED DATA OBJECT INTO TWO TEAMS
dataGroup = d3.nest()
.key(function(d) {
return d.team;
})
.entries(data);
//DRAW LINES
dataGroup.forEach(function(d, i) {
// Select the section we want to apply our changes to
var vis = d3.select("#visualisation").transition();
xScale.domain([0, d3.max(data, function(d) {
return d.elapsedTime;
})]);
yScale.domain([0, d3.max(data, function(d) {
return d.score;
})]);
//PROBLEM: Team 1 line changes to Team 2's data
vis.select("#line_team1")
.duration(750)
.attr("d", lineGen(d.values));
});
vis.select("#line_team2")
.duration(750)
.attr("d", lineGen(data));
// X-SCALE NEVER ADJUSTS
vis.select(".x.axis") // change the x axis
.duration(750)
.call(xAxis);
// Y-AXIS SEEMS TO SCALE AS EXPECTED
vis.select(".y.axis") // change the x axis
.duration(750)
.call(yAxis);
};
Any insight would be greatly appreciated!
One way to further simplify your code is to change it to update based on the data itself.
A method that I often use is obtaining the selector for updating the data from the data itself. In your case you could use the key property of your dataGroup objects:
dataGroup.forEach(function(d, i) {
vis.select("#line_"+d.key.toLowerCase().split(' ').join(''))
.duration(750)
.attr("d", lineGen(d.values));
var maxi = d3.max(data, function(d) {
return d.elapsedTime;
});
...
});
Or you could do the preprocessing in your d3.nest() call in order to obtain the key in form of team1 and use it as a selector.
Here's a fork of your fiddle with working example.
I have the following object:
var dataInfo = d3.select("#" + chartRenderDiv)
.select(".labelLayer")
.selectAll(".labelNode")
.data(labelsData, function(d){return d.id;});
After that, I correspond it to create SVG elements based on it:
var labelContainer = dataInfo.enter()
.append("g")
.attr("class", "labelNode")
.attr("cursor","pointer")
.call(dragInteractive);
And finally I let go of the variable:
labels.exit().remove();
The code works perfectly fine, however when I am re-drawing, while label keeps getting the updates from updated data source, labelContainer will be an array of null elements.
What is the cause of this null return value and how it should be done so that it always gets the updates and does not return an array of nulls?
UPDATE: LabelsData is an array of object with size 183. Here is a sample of one entry of the array:
id: "AF"
labelPos: Array[2]
labelPosition: "top-right"
name: "Afghanistan"
neighbourhood: Object
size: 33397058
x: 1349.696941
y: 60.524
It gets updated frequently and based on its changed, it should change the SVG elements actually.