Independent scales for small multiple line chart - javascript

Link to the notebook.
I'm working on a small multiple line chart using d3.v5 on Observable, with the dataset structured like follows:
For visualization, the y scale takes num from the values array for the domain. There are several rows with unique key values, which I wanted to use to produce the small multiples. The image above shows the first key.
After visualizing the small multiple, I noticed that all the line charts are using the same y scale, which is not what I intended to do. This is what I currently have:
const y_scale = d3
.scaleLinear()
.domain([0, d3.max(series, d => d3.max(d.values, m => m.num))])
.range([width/2, width/2 - start_y - margin.bottom]);
Is there a way to adjust the domain so that each chart would have its own scale based on its own num values?
Edit 1: Notebook link added on top

The idiomatic D3 solution here would be using local variables. However, there are several different working alternatives.
For using local variables, we first declare them...
const localScale = d3.local();
const localLine = d3.local();
Then, we set the different scales in the "enter" selection:
var enter = my_group
.enter()
.append("g")
.attr("class", "chart_group")
.each(function(d) {
const yScale = localScale.set(this, d3
.scaleLinear()
.domain([0, d3.max(d.values, d => d.num)])
.range([panel_width / 2, panel_width / 2 - start_y - margin]));
localLine.set(this, d3
.line()
.x(d => x_scale(d.date))
.y(d => yScale(d.num)));
});
Finally, we get those scales:
sub_group
.select(".chart_line")
.attr("d", function(d) {
return localLine.get(this)(d)
})
Here is the whole cell, copy/paste this in your notebook, replacing your cell:
chart = {
const panels_per_row = 4;
const panel_width = (width - margin * 8) / panels_per_row;
const height =
margin + (panel_width + margin) * (parseInt(my_data.length / 2) + 1);
const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
const start_x = 2;
const start_y = panel_width / 3 + margin;
const x_scale = d3
.scaleBand()
.domain(d3.set(series[0].values, d => d.date).values())
.range([0, panel_width]);
const localScale = d3.local();
const localLine = d3.local();
//join
var my_group = svg.selectAll('.chart_group').data(series, d => d.key);
//exit and remove
my_group.exit().remove();
//enter new groups
var enter = my_group
.enter()
.append("g")
.attr("class", "chart_group")
.each(function(d) {
const yScale = localScale.set(this, d3
.scaleLinear()
.domain([0, d3.max(d.values, d => d.num)])
.range([panel_width / 2, panel_width / 2 - start_y - margin]));
localLine.set(this, d3
.line()
.x(d => x_scale(d.date))
.y(d => yScale(d.num)));
});
//append elements to new group
enter.append("rect").attr("class", "group_rect");
enter.append("text").attr("class", "group_text");
enter.append("g").attr("class", "sub_chart_group");
//merge
my_group = my_group.merge(enter);
position_group_elements(my_group);
//join
var sub_group = my_group
.select(".sub_chart_group")
.selectAll('.sub_chart_elements_group')
.data(d => [d.values]); // data is wrapped in an array because this is a line/area chart
//exit and remove
sub_group.exit().remove();
//enter new groups
var sub_enter = sub_group
.enter()
.append("g")
.attr("class", "sub_chart_elements_group");
//append elements to new group
sub_enter.append("path").attr("class", "chart_line");
//merge
sub_group = sub_group.merge(sub_enter);
sub_group
.select(".chart_line")
.attr("d", function(d) {
return localLine.get(this)(d)
})
.attr("fill", "none")
.attr("stroke", "black")
.attr("stroke-width", 1)
.attr("transform", "translate(" + start_x + "," + start_y + ")");
function position_group_elements(my_group) {
//position rectangle
my_group
.select(".group_rect")
.attr("x", function(d, i) {
//two groups per row so
var position = i % panels_per_row;
d.x_pos = position * (panel_width + margin) + margin;
d.y_pos =
parseInt(i / panels_per_row) * (panel_width + margin) + margin;
return d.x_pos;
})
.attr("y", d => d.y_pos)
.attr("fill", "#eee")
.attr("stroke", "#aaa")
.attr("stroke-width", 1)
.attr("width", panel_width)
.attr("height", panel_width);
//then position sub groups
my_group
.select(".sub_chart_group")
.attr("id", d => d.key)
.attr("transform", d => "translate(" + d.x_pos + "," + d.y_pos + ")");
}
return svg.node();
}

Related

How to parse new Date.now() object with D3.js to generate a linechart

I'm trying to make a linechart with D3 and React where the x axis is based on Date.now() object and all the ticks are a minute apart on a 10mn window.
I can't generate the line because I get "NaNNaNNaN" in my svg path;
Can't seem to figure out how to have ticks minutes apart on my x axis;
Here's how the data looks like
// data state
data = [
{"loadAverage":0.008333333333333333,"timestamp":1632740462342},
{"loadAverage":0.008333333333333333,"timestamp":1632740459323},
{"loadAverage":0.013333333333333334,"timestamp":1632740471400}
];
the timestamp key is a new Date.now() coming from the server
useEffect(() => {
const svg = d3.select(d3Container.current);
let margin = { top: 20, right: 20, bottom: 50, left: 70 },
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
// set the ranges
let x = d3
.scaleTime()
.domain(d3.extent(data, (d) => timeFormat(d.timestamp)))
.range([0, width]);
let y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.loadAverage)])
.nice()
.range([height, 0]);
// Parse the date
let parseTime = d3.timeParse("%s");
let timeFormat = d3.timeFormat("%M:%S");
// Constructing the line
const myLine = d3
.line()
.x((d) => {
const convertedTime = parseTime(d.timestamp);
return x(convertedTime);
})
.y((d) => {
return y(d.loadAverage);
});
svg
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg
.select("svg")
.selectAll("path")
.data([data])
.join("path")
.attr("d", (value) => myLine(value))
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round");
// Add the x Axis
svg
.select("svg")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x));
// Add the y Axis
svg
.select("svg")
.append("g")
.call(d3.axisLeft(y).tickFormat(timeFormat).ticks(10));
}, [data]);
This is my first time using D3, any help would be greatly appreciated !
Edit: here's what I tried so far
// Constructing the line
const myLine = d3
.line()
.x((d) => {
const convertedTime = new Date(d.timestamp);
return x(convertedTime);
})
.y((d) => {
return y(d.loadAverage);
});
Even tried to return convertedTime wrapped up by parsetime like so parsetime(convertedTime) Didn't work either.
I think you have a problem in Initializing x scale domain
// set the ranges
let x = d3
.scaleTime()
// ⬇️ here is the issue, just get rid of timeFormat
.domain(d3.extent(data, (d) => timeFormat(d.timestamp)))
.range([0, width]);
the scaleTime expect the domain to be a [Date|number, Date|number], you are using timeFormat which convert number|Date into a string based on the given format.
Try to use this instead:
let x = d3
.scaleTime()
.domain(d3.extent(data, (d) => d.timestamp))
.range([0, width]);
// The short vesion
let x = d3.scaleTime(d3.extent(data, (d) => d.timestamp), [0, width])
Constructing the line
const myLine = d3
.line()
.x((d) => x(d.timestamp))
.y((d) => y(d.loadAverage));
If you need to convert timestamps into Dates, you can map the whole data array
data = data.map(d=> d.timestamp = new Date(d.timestamp), d)

Choppy transitions: translate multiple paths (and xaxis) concurrently

The goal is to create a smooth scrolling real time plot with multiple traces.
I was able to do this for a single trace, but when I add more lines to transition, the animation seems to get messed up. I have a feeling that transitions are being looped through and colliding, but I can't figure out how to prevent this.
If you set N_CH = 1 in the snippet, things run smoothly. When it's set to N_CH = 4 then the animation becomes jerky (seems like the transitions aren't fully completing) and also (interestingly) the x-axis scrolling appears to become 4 times faster than when N_CH = 1.
You can recover the smoothness by changing the transform in the tick() function to match the number of channels (i.e. iScale(-4) for N_CH = 4) but this isn't "correct" as the translation speed is artificially fast. In the end, I need accurate time measurement in real-time.
I've tried various different approaches including:
adding traces to a group and trying to translate the group
refactoring the data object and allowing d3 to iterate through the data structure with a selectAll() call
... the results always seem to be the same.
// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']
// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
channelData = [];
for (let i = 0; i < N_PTS; i++) {
channelData.push({
x: Date.now() + i * 1000,
y: ch + Math.random()
})
}
data.push({
name: "CH" + ch,
values: channelData
});
}
// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// set index scale for data buffer position/transition
var iScale = d3.scaleLinear()
.range([0, width - margin.right])
.domain([0, data[0].values.length - 1]);
// set up x-axis scale for data x units (time)
var xScale = d3.scaleUtc()
.range([margin.left, width - margin.right])
// add x-axis to svg
var xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0, ${height - margin.top})`)
.call(d3.axisBottom(xScale));
// set up y-axis
var yScale = d3.scaleLinear()
.range([height - margin.top, margin.bottom]);
// add y-axis to svg
var yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));
// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));
// define the line
var line = d3.line()
.x((d, i) => iScale(i))
.y(d => yScale(d.y));
// make a group where we will append our paths
traces = svg.append("g")
.attr("clip-path", "url(#clip)")
for (let ch=0; ch<N_CH; ch++) {
traces.append("path")
.datum(data[ch].values)
.attr("id", `trace-${ch}`)
.attr("class", "trace")
.attr("d", line)
.attr("stroke", colors[ch])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform", "translate(0)")
}
// end initialize ////////////////////
// animate
tick();
function tick() {
// add data to buffer
let lastData;
for (let ch = 0; ch < N_CH; ch++) {
lastData = data[ch].values[data[ch].values.length - 1];
data[ch].values.push({
x: lastData.x + 1000,
y: ch + Math.random()
});
}
// update individual trace path data
for (let ch = 0; ch < N_CH; ch++) {
traces.select(`#trace-${ch}`)
.attr("d", line)
}
// animate transition
traces
.selectAll('.trace')
.attr("transform", "translate(0)")
.transition().duration(1000).ease(d3.easeLinear)
.attr("transform", `translate(${iScale(-1)}, 0)`)
.on("end", tick)
// update the domain
xScale.domain(d3.extent(data[0].values, d => d.x));
// animate/redraw axis
xAxis
.transition().duration(1000).ease(d3.easeLinear)
.call(d3.axisBottom(xScale));
for (let ch=0; ch<N_CH; ch++) {
data[ch].values.shift();
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="chart"></div>
There are a few issues here:
xScale vs iScale:
You draw your data based on iScale, but draw your axis based on xScale: there's a discrepancy here right away: the ranges of each scale are different. But there is no reason why you shouldn't use the same scale for both: this way you'll never have any discrepancy between drawing and axis. If you remove the clip path and remove the tick function, you'll notice your lines aren't initially rendered where you expect them:
Misuse of transition.end()
D3's transition event listeners are for each transition. You are transitioning many elements, this is triggered when every line finishes. So after the four lines finish transitioning the first time, you trigger the tick function four times: this results in all sorts of chaos since the function is intended to be called once to transition all lines at once.
On re-read of the question, you've spotted this issue of calling the tick function 4x instead of once:
You can recover the smoothness by changing the transform in the tick()
function to match the number of channels (i.e. iScale(-4) for N_CH =
4) but this isn't "correct" as the translation speed is artificially
fast.
If we fix this so that we call the tick function once, when all line transitions are complete, we address the smoothness issue:
// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']
// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
channelData = [];
for (let i = 0; i < N_PTS; i++) {
channelData.push({
x: Date.now() + i * 1000,
y: ch + Math.random()
})
}
data.push({
name: "CH" + ch,
values: channelData
});
}
// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// set index scale for data buffer position/transition
var iScale = d3.scaleLinear()
.range([0, width - margin.right])
.domain([0, data[0].values.length - 1]);
// set up x-axis scale for data x units (time)
var xScale = d3.scaleUtc()
.range([margin.left, width - margin.right])
// add x-axis to svg
var xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0, ${height - margin.top})`)
.call(d3.axisBottom(xScale));
// set up y-axis
var yScale = d3.scaleLinear()
.range([height - margin.top, margin.bottom]);
// add y-axis to svg
var yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));
// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));
// define the line
var line = d3.line()
.x((d, i) => iScale(i))
.y(d => yScale(d.y));
// make a group where we will append our paths
traces = svg.append("g")
.attr("clip-path", "url(#clip)")
for (let ch=0; ch<N_CH; ch++) {
traces.append("path")
.datum(data[ch].values)
.attr("id", `trace-${ch}`)
.attr("class", "trace")
.attr("d", line)
.attr("stroke", colors[ch])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform", "translate(0)")
}
// end initialize ////////////////////
// animate
tick();
function tick() {
// add data to buffer
let lastData;
for (let ch = 0; ch < N_CH; ch++) {
lastData = data[ch].values[data[ch].values.length - 1];
data[ch].values.push({
x: lastData.x + 1000,
y: ch + Math.random()
});
}
// update individual trace path data
for (let ch = 0; ch < N_CH; ch++) {
traces.select(`#trace-${ch}`)
.attr("d", line)
}
// animate transition
traces
.selectAll('.trace')
.attr("transform", "translate(0)")
.transition().duration(1000).ease(d3.easeLinear)
.attr("transform", `translate(${iScale(-1)}, 0)`)
.end().then(tick);
// update the domain
xScale.domain(d3.extent(data[0].values, d => d.x));
// animate/redraw axis
xAxis
.transition().duration(1000).ease(d3.easeLinear)
.call(d3.axisBottom(xScale));
for (let ch=0; ch<N_CH; ch++) {
data[ch].values.shift();
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="chart"></div>
In the above I use transition.end() to return a promise when all selected elements finish transitioning. I have upped your version of D3 as this is a newer function:
.end().then(tick);
Improvements:
Your code makes use of loops to append and modify elements. This creates additional overhead: selecting elements in the DOM takes time, you have to identify each line so you can reselect it again, and you have to do some extra legwork in binding the data. Let's simplify this with the d3 enter/update cycle:
Create the lines to start:
let lines = traces.selectAll(null)
.data(data)
.enter()
.append("path")
.attr("d", d=>line(d.values))
.attr("stroke", (d,i)=>colors[i])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform","translate(0,0)");
And now in the update/tick function we can modify the bound data easily:
lines.each(function(d,i) {
d.values.push({
x: d.values[d.values.length-1].x + dt,
y: i + Math.random()
})
})
.attr("d", d=>line(d.values))
We can remove the first data point of each line with:
lines.each(d=>d.values.shift());
Generally speaking (explicit) loops are very rare in manipulating SVG elements with D3, as it runs counter to principles that D3 was designed with. See here for some discussion on why that might matter and how it might be useful.
Together with removing the iScale and using transition.end(), we might get something like:
// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']
// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
channelData = [];
for (let i = 0; i < N_PTS; i++) {
channelData.push({
x: Date.now() + i * 1000,
y: ch + Math.random()
})
}
data.push({
name: "CH" + ch,
values: channelData
});
}
// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// set up x-axis scale for data x units (time)
var xScale = d3.scaleTime()
.range([margin.left, width - margin.right])
.domain(d3.extent(data[0].values,d=>d.x))
// add x-axis to svg
var xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0, ${height - margin.top})`)
.call(d3.axisBottom(xScale));
// set up y-axis
var yScale = d3.scaleLinear()
.range([height - margin.top, margin.bottom]);
// add y-axis to svg
var yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));
// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));
// define the line
var line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y));
// make a group where we will append our paths
traces = svg.append("g")
.attr("clip-path", "url(#clip)")
// Create lines:
let lines = traces.selectAll(null)
.data(data)
.enter()
.append("path")
.attr("d", d=>line(d.values))
.attr("stroke", (d,i)=>colors[i])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform","translate(0,0)");
transition();
function transition() {
let dt = 1000; // difference in time.
let dx = xScale(d3.timeMillisecond.offset(xScale.domain()[0],dt)) - xScale.range()[0]; // difference in pixels.
lines.each(function(d,i) {
d.values.push({
x: d.values[d.values.length-1].x + dt,
y: i + Math.random()
})
})
.attr("d", d=>line(d.values))
.transition()
.duration(1000)
.attr("transform",`translate(${-dx}, 0)`)
.ease(d3.easeLinear)
.end().then(function() {
lines.each(d=>d.values.shift())
.attr("transform","translate(0,0)")
transition();
})
xScale.domain(xScale
.domain()
.map(d=>d3.timeMillisecond.offset(d,dt)))
xAxis
.transition().duration(1000).ease(d3.easeLinear)
.call(d3.axisBottom(xScale))
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="chart"></div>

How do I create a multi-line chart in d3 with a dropdown menu?

I posted a previous question with perhaps too much specificity. I'm trying to create a multi-line chart in d3 with a dropdown, similar to this.
I've switched out the obvious changes needed for v7 but am still running into trouble, I believe in the if/else statement right after var initialGraph but I'm not 100% sure. It may also be because my d3.groups isn't set up / referenced correctly.
The current error I receive is:
Uncaught (in promise) TypeError: Cannot read property 'year' of undefined
at <anonymous>:23:33
at a (d3.v7.min.js:2)
at initialGraph (<anonymous>:83:18)
at <anonymous>:89:3
My dataset has four values: year, state, wvalues, and lvalues. state and lvalues are strings, year and wvalues are numeric. Here's my code so far:
var margin = { top: 50, right: 50, bottom: 50, left: 50 }
var h = 500 - margin.top - margin.bottom
var w = 700 - margin.left - margin.right
var formatDecimal = d3.format('.2')
d3.csv('15/data.csv').then(function (data) {
// Scales
var x = d3.scaleLinear()
.range([0,w])
var y = d3.scaleLinear()
.range([h,0])
y.domain([
d3.min([0,d3.min(data,function (d) { return d.wvalue })]),
d3.max([0,d3.max(data,function (d) { return d.wvalue })])
]);
x.domain([1968, 2016])
// Define the line
var valueLine = d3.line()
.x(function(d) { return x(d.year); })
.y(function(d) { return y(d.wvalue); })
// Create the svg canvas in the "d3block" div
var svg = d3.select("#d3block")
.append("svg")
.style("width", w + margin.left + margin.right + "px")
.style("height", h + margin.top + margin.bottom + "px")
.attr("width", w + margin.left + margin.right)
.attr("height", h + margin.top + margin.bottom)
.append("g")
.attr("transform","translate(" + margin.left + "," + margin.top + ")")
.attr("class", "svg");
//nest variable
var nest = d3.groups(data,
d => d.state, d => d.lvalue)
// X-axis
var xAxis = d3.axisBottom()
.scale(x)
.tickFormat(formatDecimal)
.ticks(7)
// Y-axis
var yAxis = d3.axisLeft()
.scale(y)
.tickFormat(formatDecimal)
.ticks(5)
// Create a dropdown
var legisMenu = d3.select("#legisDropdown")
legisMenu
.append("select")
.selectAll("option")
.data(nest)
.enter()
.append("option")
.attr("value", ([key, ]) => key)
.text(([key, ]) => key)
// Function to create the initial graph
var initialGraph = function(legis){
// Filter the data to include only state of interest
var selectLegis = nest.filter(([key, ]) => key == legis)
var selectLegisGroups = svg.selectAll(".legisGroups")
.data(selectLegis, function(d){
return d ? d.key : this.key;
})
.enter()
.append("g")
.attr("class", "legisGroups")
var initialPath = selectLegisGroups.selectAll(".line")
.data(([, values]) => values)
.enter()
.append("path")
initialPath
.attr("d", valueLine(([, values]) => values))
.attr("class", "line")
}
// Create initial graph
initialGraph("Alabama")
// Update the data
var updateGraph = function(legis){
// Filter the data to include only state of interest
var selectLegis = nest.filter(([key, ]) => key == legis)
// Select all of the grouped elements and update the data
var selectLegisGroups = svg.selectAll(".legisGroups")
.data(selectLegis)
// Select all the lines and transition to new positions
selectLegisGroups.selectAll("path.line")
.data(([, values]) => values)
.transition()
.duration(1000)
.attr("d", valueLine(([, values ]) => values))
}
// Run update function when dropdown selection changes
legisMenu.on('change', function(){
// Find which state was selected from the dropdown
var selectedLegis = d3.select(this)
.select("select")
.property("value")
// Run update function with the selected state
updateGraph(selectedLegis)
});
// X-axis
svg.append('g')
.attr('class','axis')
.attr('id','xAxis')
.attr('transform', 'translate(0,' + h + ')')
.call(xAxis)
.append('text') // X-axis Label
.attr('id','xAxisLabel')
.attr('fill','black')
.attr('y',-10)
.attr('x',w)
.attr('dy','.71em')
.style('text-anchor','end')
.text('')
// Y-axis
svg.append('g')
.attr('class','axis')
.attr('id','yAxis')
.call(yAxis)
.append('text') // y-axis Label
.attr('id', 'yAxisLabel')
.attr('fill', 'black')
.attr('transform','rotate(-90)')
.attr('x',0)
.attr('y',5)
.attr('dy','.71em')
.style('text-anchor','end')
.text('wvalue')
})
I did more digging and found the answer here.
I had to replace the initial path attribute .attr("d", valueLine(([, values]) => values)) with .attr('d', (d) => valueLine(Array.from(d.values())[1])). I also had to replace the code further down within the updateGraph function under selectLegisGroups .attr for it to update properly.

D3.js v5 modular swarm clusters (variable radius?)

I want to create a visual whereby a swarm contains one big circle and a bunch of satellite circles clinging around it. For a simple demonstration, I have prepared a small version of the data set; each item in the array should have one big circle and then however many smaller circles clinging to it:
var data = [
{'wfoe':'wfoe1','products':d3.range(20)},
{'wfoe':'wfoe2','products':d3.range(40)},
{'wfoe':'wfoe3','products':d3.range(10)}
];
Here is a snippet of my progress:
var margins = {
top: 100,
bottom: 300,
left: 100,
right: 100
};
var height = 250;
var width = 900;
var totalWidth = width + margins.left + margins.right;
var totalHeight = height + margins.top + margins.bottom;
var svg = d3.select('body')
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight);
var graphGroup = svg.append('g')
.attr('transform', "translate(" + margins.left + "," + margins.top + ")");
var data = [
{'wfoe':'wfoe1','products':d3.range(20)},
{'wfoe':'wfoe2','products':d3.range(40)},
{'wfoe':'wfoe3','products':d3.range(10)}
];
var columns = 4;
var spacing = 250;
var vSpacing = 250;
var fmcG = graphGroup.selectAll('.fmc')
.data(data)
.enter()
.append('g')
.attr('class', 'fmc')
.attr('id', (d, i) => 'fmc' + i)
.attr('transform', (d, k) => {
var horSpace = (k % columns) * spacing;
var vertSpace = ~~((k / columns)) * vSpacing;
return "translate(" + horSpace + "," + vertSpace + ")";
});
var xScale = d3.scalePoint()
.range([0, width])
.domain([0, 100]);
var rScale = d3.scaleThreshold()
.range([50,5])
.domain([0,1]);
data.forEach(function(d, i) {
d.x = (i % columns) * spacing;
d.y = ~~((i / columns)) * vSpacing;
});
var simulation = d3.forceSimulation(data)
.force("x", d3.forceX(function(d,i) {
return (i % columns) * spacing;
}).strength(0.1))
.force("y", d3.forceY(function(d,i) {
return ~~((i / columns)) * vSpacing;
}).strength(0.01))
.force("collide", d3.forceCollide(function(d,i) { return rScale(i)}))
.stop();
simulation.tick(75);
fmcG.selectAll(null)
.data(data)
.enter()
.append("circle")
.attr("r", function(d,i) {
return rScale(i)
})
.attr("cx", function(d) {
return d.x;
})
.attr("cy", function(d) {
return d.y;
})
.style('fill',"#003366");
<script src="https://d3js.org/d3.v5.min.js"></script>
I want to quickly point out that the big circle doesn't represent any data point (they are just going to house a name / logo). I just thought that including it in the simulation data would be the easiest way to introduce the needed force logic for the swarm circles. I thought that an elegant solution would be to use a threshold scale and let the first (i=0) datum always be the biggest circle. Here is what I mean:
var rScale = d3.scaleThreshold()
.range([0, 1])
.domain([50, 5]);
fmcG.selectAll(null)
.data(data)
.enter()
.append("circle")
.attr("r", function(d,i) {
return rScale(i)
})
.attr("cx", function(d) {
return d.x;
})
.attr("cy", function(d) {
return d.y;
})
.style('fill',"#003366");
The result I mentioned above (three big circles with little circles all around them) was not achieved, and in fact very few circles were appended and the variable radius component didn't seem to be working as I thought it would. (also no errors displayed in the log).
Question
How can I iteratively create swarms that start with one big circle and append subsequent smaller circles around the initial big circle, as applicable to the sample data set?
You could use a force simulation, like below, only this gives non-deterministic results. However, it's really good when you want to gradually add more nodes. In the below solution, I gave all related nodes a link to the center node, but didn't draw it. This made it possible for linked nodes to attract heavily.
On the other hand, you could also use a bubble chart if you want D3 to find the optimal packing solution for you, without the force working on them. Only downside is you'd have to call the packing function with all nodes every time, and the other nodes might shift because of the new one.
var margins = {
top: 100,
bottom: 300,
left: 100,
right: 100
};
var height = 250;
var width = 900;
var totalWidth = width + margins.left + margins.right;
var totalHeight = height + margins.top + margins.bottom;
var svg = d3.select('body')
.append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight);
var graphGroup = svg.append('g')
.attr('transform', "translate(" + margins.left + "," + margins.top + ")");
var data = [{
'wfoe': 'wfoe1',
'products': d3.range(20).map(function(v) {
return v.toString() + '_wfoe1';
})
},
{
'wfoe': 'wfoe2',
'products': d3.range(40).map(function(v) {
return v.toString() + '_wfoe2';
})
},
{
'wfoe': 'wfoe3',
'products': d3.range(10).map(function(v) {
return v.toString() + '_wfoe3';
})
}
];
var columns = 4;
var spacing = 250;
var vSpacing = 250;
function dataToNodesAndLinks(d) {
// Create one giant array of points and
// one link between each wfoe and each product
var nodes = [{
id: d.wfoe,
center: true
}];
var links = [];
d.products.forEach(function(p) {
nodes.push({
id: p,
center: false
});
links.push({
source: d.wfoe,
target: p
});
});
return {
nodes: nodes,
links: links
};
}
var fmcG = graphGroup.selectAll('.fmc')
.data(data.map(function(d, i) {
return dataToNodesAndLinks(d, i);
}))
.enter()
.append('g')
.attr('class', 'fmc')
.attr('id', (d, i) => 'fmc' + i)
.attr('transform', (d, k) => {
var horSpace = (k % columns) * spacing;
var vertSpace = ~~((k / columns)) * vSpacing;
return "translate(" + horSpace + "," + vertSpace + ")";
});
var xScale = d3.scalePoint()
.range([0, width])
.domain([0, 100]);
var rScale = d3.scaleThreshold()
.range([50, 5])
.domain([0, 1]);
fmcG.selectAll("circle")
.data(function(d) {
return d.nodes;
})
.enter()
.append("circle")
.attr("id", function(d) {
return d.id;
})
.attr("r", function(d, i) {
return d.center ? rScale(i) * 5 : rScale(i);
})
.style('fill', function(d) { return d.center ? "darkred" : "#003366"; })
fmcG
.each(function(d, i) {
d3.forceSimulation(d.nodes)
.force("collision", d3.forceCollide(function(d) {
return d.center ? rScale(i) * 5 : rScale(i);
}))
.force("center", d3.forceCenter(0, 0))
.force("link", d3
.forceLink(d.links)
.id(function(d) {
return d.id;
})
.distance(0)
.strength(2))
.on('tick', ticked);
});
function ticked() {
fmcG.selectAll("circle")
.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
}
<script src="https://d3js.org/d3.v5.js"></script>

d3 path.line stroke-width with IF statement / ternary operator

Trying to change line stroke-width with an IF statement / ternary operator e.g. if d.country === "China" stroke-width: 2. This has to be a path.line attribute so this is what I'm attaching it to after line is called.
I've added countryName to the emissions object, I have also noticed that condition is always FALSE so the stroke-width is 0.5. Why its not TRUE?
Codepen
//Define line chart with and height
const width = fullWidth - margin.left - margin.right;
const height = fullHeight - margin.top - margin.bottom;
//Define x and y scale range
let xScale = d3.scaleLinear()
.range([0, width])
let yScale = d3.scaleLinear()
.range([0, height])
//Define x and y axis
let xAxis = d3.axisBottom(xScale)
.ticks(15)
let yAxis = d3.axisLeft(yScale)
.ticks(10)
//Draw svg
let svg = d3.select("body")
.append("svg")
.attr("width", fullWidth)
.attr("height", fullHeight)
.append("g")
.attr("transform", "translate(" + 53 + "," + 0 +")");
d3.json("https://api.myjson.com/bins/izmg6").then(data => {
console.log(data);
//Structure data so should be an array of arrays etc [[x,y], [x,y], [x,y]]
let years = d3.keys(data[0]).slice(0, 50);
console.log(years);
let dataset = [];
data.forEach((d, i) => {
let myEmissions = [];
years.forEach(y => {
if (d[y]) {
myEmissions.push({
country: d.countryName,
year: y,
amount: d[y]
})
}
})
dataset.push({
country: d.countryName,
emissions: myEmissions
});
})
console.log(dataset);
//Define x and y domain
xScale
.domain(d3.extent(years, d =>d))
yScale
.domain([d3.max(dataset, d =>
d3.max(d.emissions, d =>
+d.amount)), 0])
//Generate line
let line = d3.line()
.curve(d3.curveBasis)
.x(d =>
xScale(d.year))
.y(d =>
yScale(d.amount));
let groups = svg.selectAll("g")
.data(dataset)
.enter()
.append("g")
groups.append("title")
.text(d => d.country)
groups.selectAll("path")
.data(d => [d.emissions])
.enter()
.append("path").classed("line", true)
.attr("d", line)
.style("stroke-width", d =>
d.country === "China" ? 10 : 0.5
)
}).catch(error => console.log(error))
What rioV8 meant is that you already have your group selection, so you just need to use groups to append new elements.
groups is a selection of all your g, it's where you want to append your paths. The same way you're not selecting again to add the titles.
groups
.append("path").classed("line", true)
.attr("d", d=> line(d.emissions))
.style("stroke-width", d =>
d.country === "China" ? 5 : 0.5
)

Categories