Beautify scaleBand - javascript

I just tried out d3js for some days and I want to beautify the x and y scales of my graph to be something like this
But this is what I got so far.
I have tried changing from scaleBand() to scaleLinear() and fix the normally bandwidth() method to a constant value, the graph just would not show.
This is the code
mounted () {
this.generateChart()
},
methods: {
generateChart () {
// set the dimensions and margins of the graph
const margin = { top: 20, right: 20, bottom: 30, left: 30 }
const width = 1850 - margin.left - margin.right
const height = 200 - margin.top - margin.bottom
// make the area for the graph to stay
const svg = d3.select('#heatmap')
.append('svg') // svg area can include headers and color scales
.attr('width', width + margin.left + margin.right) // set width
.attr('height', height + margin.top + margin.bottom) // set height
.append('g') // new g tag area for graph only
.attr('transform', `translate(${margin.left}, ${margin.bottom})`)
// stick g tag to the bottom
// range function generate graph scales
// TODO: make a range using date and time
const xLabel = d3.range(259)
const yLabel = d3.range(23, -1, -1)
// create x, y scales and axes
const x = d3.scaleBand()
.domain(xLabel)
.range([0, width])
.padding(0.05)
svg.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
const y = d3.scaleBand()
.domain(yLabel)
.range([height, 0])
.padding(0.05)
svg.append('g').call(d3.axisLeft(y))
d3.json('../predictions.json').then(function (data) {
svg.selectAll()
.data(data.heatmaps.kw.Sand_Heads)
.enter()
.append('rect')
.attr('x', function (d) {
return x(d[1]) // return cell's position
})
.attr('y', function (d) {
return y(d[0])
})
.attr('cx', 1)
.attr('cy', 1)
.attr('width', x.bandwidth()) // return cell's width
.attr('height', y.bandwidth()) // return cell's height
.style('fill', function (d) {
return rgbaToHex(0, 128, 255, 100 * d[2])
})
.on('mouseover', function () { // box stroke when hover
d3.select(this)
.style('stroke', 'black')
.style('opacity', 1)
})
.on('mouseout', function () { // fade block stroke when mouse leave the cell
d3.select(this)
.style('stroke', 'none')
.style('opacity', 0.8)
})
})
}
Note: I have to make it work with date selection in the future too.
This is the structure of the data I'm working on.
{
"days": ["2019-04-11", "2019-04-12", ..., "2019-12-25"],
"heatmaps": {
"kw": {
"Tilly_Point": [[5, 112, 0.0012], [6, 112, 0.0016], ...],
"Mouat_Point": [...]
},
"hw": {
...
}
}
}
Explanation:
the first element of subarray in Tilly_Point is the time of the whale found. ranging from 0 to 23 (midnight to next midnight) and 5 means 05:00 A.M. to 06:00 A.M.
the second element is the nth day of the operation. It's 112 meaning it's the 112th day of the operation. which is 1 August 2019
the last element is the real data being plotted on the graph. the higher -> darker colour towards the real color with 1 opacity

By looking at the desired design we can understand what you mean by "beautify" is reducing the number of ticks. And you are absolutely correct: in very few and specific situations we need to show all of them; most of the times, the design is cleaner and the user benefits from a more tidy dataviz if we choose what ticks to display.
That's clear if we look at this basic example I wrote, simulating your axes:
const svg = d3.select("svg");
const yScale = d3.scaleBand()
.domain(d3.range(25))
.range([10, 80])
.paddingInner(1);
const xScale = d3.scaleBand()
.domain(d3.range(261))
.range([25, 490])
.paddingInner(1);
d3.axisLeft(yScale)(svg.append("g").attr("transform", "translate(25,0)"));
d3.axisBottom(xScale)(svg.append("g").attr("transform", "translate(0,80)"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg width="500" height="100"></svg>
There are different approaches for reducing the number of ticks here: you can explicitly chose the ticks to show by value or, as I'll do in this answer, you can simply choose how many of them to show. Here, I'll do this using the remainder operator (%) filtering the scale's domain and passing it to tickValues (since you have a band scale we cannot use ticks), for instance showing every 6th value for the y axis:
.tickValues(yScale.domain().filter((_, i) => !(i % 6)))
Here is the result:
const svg = d3.select("svg");
const yScale = d3.scaleBand()
.domain(d3.range(25))
.range([10, 80])
.paddingInner(1);
const xScale = d3.scaleBand()
.domain(d3.range(261))
.range([25, 490])
.paddingInner(1);
d3.axisLeft(yScale).tickValues(yScale.domain().filter((_, i) => !(i % 6)))(svg.append("g").attr("transform", "translate(25,0)"));
d3.axisBottom(xScale).tickValues(xScale.domain().filter((_, i) => !(i % 20)))(svg.append("g").attr("transform", "translate(0,80)"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg width="500" height="300"></svg>

Related

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>

What is the best way to create small multiples in d3.js v6?

I am trying to create small multiple bar charts that have different y-axis scales using d3 v6. There are a few examples out there (https://flowingdata.com/2014/10/15/linked-small-multiples/) of small multiples for previous versions of d3, but not v6, which seems to have a good number of changes implemented.
I don't have much experience with d3, so I am probably missing something obvious, but I can't get the bars to properly generate, the axes are generating (though I think I am generating them too many times on top of each other).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Small multiple bar charts</title>
<script src="https://d3js.org/d3.v6.min.js"></script>
</head>
<body>
<div id='vis'></div>
<script type="text/javascript">
// Set the sizing metrics
var width = 150;
var height = 120;
var margin = {top: 15, right: 10, bottom: 40, left: 35};
// Create the axes
var xScale = d3.scaleBand()
.range([0, width])
.padding(0.1);
var yScale = d3.scaleLinear()
.range([height, 0]);
var xAxis = d3.axisBottom()
.scale(xScale);
// Load the data
d3.csv('data.csv').then(function(data) {
data.forEach(function(d) {
d.segment = d.segment;
d.parameter = d.parameter;
d.the_value = +d.the_value;
});
// set the x domain
xScale.domain(data.map(function(d) { return d.segment; }));
// group the data
metrics = Array.from(
d3.group(data, d => d.parameter), ([key, value]) => ({key, value})
);
// create a separate SVG object for each group
var svg = d3.select('#vis').selectAll('svg')
.data(metrics)
.enter()
.append('svg');
// loop over the data and create the bars
metrics.forEach(function(d, i) {
console.log(d);
console.log(metrics);
yScale.domain([0, d3.max(metrics, function(c) { return c.the_value; })]);
svg.selectAll('.bar')
.data(d)
.enter().append('rect')
.attr('class', 'bar')
.attr('x', function(c) { return xScale(c.segment); })
.attr('width', xScale.bandwidth())
.attr('y', function(c) { return yScale(c.the_value); })
.attr('height', function(c) { return height - yScale(c.the_value); })
.attr('fill', 'teal');
svg.append('g')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis)
});
});
</script>
</body>
</html>
Here is the data file:
segment,parameter,the_value
A,one,33
A,two,537723
A,three,14
A,four,5
A,five,0.093430759
B,one,76
B,two,137110
B,three,16
B,four,20
B,five,0.893868331
C,one,74
C,two,62020
C,three,25
C,four,14
C,five,0.862952872
Eventually I would also like to get the charts linked so that when series A is hovered on the first graph the value will display for each series on all of the graphs, but the first step is to get the visuals properly working.
There's a few small changes to get it working:
When you set the domain on the x scale, you just need the unique segments e.g. A, B, C and not the full list of segments from the data.
When you create the 5 SVGs you can class them so that you can refer to each separately when you loop through the values of the metrics. So the first small multiple has a class of one, the second small multiple has a class of two etc
Reset the y domain using the set of the_values from the metrics you're charting - i.e. use d not metrics
When you loop metrics first select the small multiple for that metric and then selectAll('.bar')
Pass d.value to data as this makes the references to c.the_value etc work properly
To prevent adding the x axis multiple times, again select the SVG for the specific small multiple before call(xAxis) otherwise you add as many axes as there are parameters to each small multiple.
I faked up your data to include random data.
See the example below - maybe there's a smarter way to do it:
// fake data
var data = ["A", "B", "C"].map(seg => {
return ["one", "two", "three", "four", "five"].map((par, ix) => {
return {
"segment": seg,
"parameter": par,
"the_value": (Math.floor(Math.random() * 10) + 1) * (Math.floor(Math.random() * 10 * ix) + 1)
}
});
}).flat();
// Set the sizing metrics
var width = 150;
var height = 120;
var margin = {top: 15, right: 10, bottom: 40, left: 35};
// Create the axes
var xScale = d3.scaleBand()
.range([0, width])
.padding(0.1);
var yScale = d3.scaleLinear()
.range([height, 0]);
var xAxis = d3.axisBottom()
.scale(xScale);
// set the x domain
// put unique segments into the domain e.g. A, B, C
var uniqueSegments = Array.from(new Set(data.map(function(d) {return d.segment;} )));
xScale.domain(uniqueSegments);
// group the data
var metrics = Array.from(
d3.group(data, d => d.parameter), ([key, value]) => ({key, value})
);
// create a separate SVG object for each group
// class each SVG with parameter from metrics
var svg = d3.select('#vis').selectAll('svg')
.data(metrics)
.enter()
.append('svg')
.attr("class", function(d) { return d.value[0].parameter;});
// loop over the data and create the bars
metrics.forEach(function(d, i) {
//console.log(d);
//console.log(metrics);
// reset yScale domain based on the set of the_value's for these metrics
yScale.domain([0, d3.max(d.value, function(c) { return c.the_value; })]);
// select the right svg for this set of metrics
d3.select("svg." + d.value[0].parameter)
.selectAll('.bar')
.data(d.value) // use d.value to get to the the_value
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', function(c) { return xScale(c.segment); })
.attr('width', xScale.bandwidth())
.attr('y', function(c) { return yScale(c.the_value); })
.attr('height', function(c) { return height - yScale(c.the_value); })
.attr('fill', 'teal');
// call axis just on this SVG
// otherwise calling it 5 times for 5 metrics...
d3.select("svg." + d.value[0].parameter)
.append('g')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis)
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.5.0/d3.min.js"></script>
<div id='vis'></div>

D3 - Chart with positive and negative values

I am trying to build the d3 chart with the positive and negative number values as below
and I found some examples of this and this. I am facing difficulties in customizing it because I have no prior experience in d3 and I think it would need some time for learning. I tried that as well. Created some simple chart examples but could not achieve the above. So I thought of reaching for help. Maybe someone can help with this if they have already done a similar chart or some guidance would be greatly appreciated. Thanks in advance.
The first step would be to identify how this chart can be simplified. Removing features until the most basic thing remains. Then, build that and gradually add features until it resembles what you want.
In your case, that'd be a horizontal bar chart. Then, add some negative values and a centred zero-line. Finally, make the height of the bars less so they become nodes, and add the text.
I'll try to add something like this, in these steps, without the layout and everything, but hopefully you'll be able to see my logic.
The basic vertical bar chart
// Some fake data
const data = ['SaaS', 'Sales', 'Fruits & Veggies', 'IT'].map((v, i) => ({
name: v,
value: 3 * i + 2
}));
const width = 600,
height = 300
margin = {
top: 20,
left: 100,
right: 40,
bottom: 40
};
// Process it to find the x and y axis domains
// scaleLinear because it considers numbers
const x = d3.scaleLinear()
.domain([0, d3.max(data.map(d => d.value))]) // the possible values
.range([0, width]); // the available screen space
// scaleBand because it's just categorical data
const y = d3.scaleBand()
.domain(data.map(d => d.name)) // all possible values
.range([height, 0]) // little weird, y-axis is always backwards, because (0,0) is the top left
.padding(0.1);
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg
// Append a container element. This will hold the chart
.append('g')
// Move it a little to account for the axes and labels
.attr('transform', `translate(${margin.left} ${margin.right})`);
// Draw the bars
// First, assign the data to the bar objects, this will decide which to remove, update, and add
const bars = g.append('g')
.selectAll('rect')
.data(data);
// Good practice: always call remove before adding stuff
bars.exit().remove();
// Add the new bars and assign any attributes that do not depend on the data
// for example, font for texts
bars.enter()
.append('rect')
.attr('fill', 'steelblue')
// Now merge it with the existing bars
.merge(bars)
// From now on we operate on both the old and the new bars
// Bars are weird, first we position the top left corner of each bar
.attr('x', 0)
.attr('y', d => y(d.name))
// Then we determine the width and height
.attr('width', d => x(d.value))
.attr('height', y.bandwidth())
// Draw the x and y axes
g.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
g.append('g')
.classed('y-axis', true)
.call(d3.axisLeft(y))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
Now I'll remove all old comments and explain what I'm doing differently.
The negative horizontal bar chart
// Now, the data can also be negative
const data = ['SaaS', 'Sales', 'Fruits & Veggies', 'IT'].map((v, i) => ({
name: v,
value: 3 * i - 5
}));
const width = 600,
height = 300,
margin = {
top: 20,
left: 100,
right: 40,
bottom: 40
};
// Now, we don't use 0 as a minimum, but get it from the data using d3.extent
const x = d3.scaleLinear()
.domain(d3.extent(data.map(d => d.value)))
.range([0, width]);
const y = d3.scaleBand()
.domain(data.map(d => d.name))
.range([height, 0])
.padding(0.1);
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg
.append('g')
.attr('transform', `translate(${margin.left} ${margin.right})`);
const bars = g.append('g')
.selectAll('rect')
.data(data);
bars.exit().remove();
bars.enter()
.append('rect')
.merge(bars)
// All the same until here
// Now, if a bar is positive it starts at x = 0, and has positive width
// If a bar is negative it starts at x < 0 and ends at x = 0
.attr('x', d => d.value > 0 ? x(0) : x(d.value))
.attr('y', d => y(d.name))
// If the bar is positive it ends at x = v, but that means it's x(v) - x(0) wide
// If the bar is negative it ends at x = 0, but that means it's x(0) - x(v) wide
.attr('width', d => d.value > 0 ? x(d.value) - x(0) : x(0) - x(d.value))
.attr('height', y.bandwidth())
// Let's color the bar based on whether the value is positive or negative
.attr('fill', d => d.value > 0 ? 'darkgreen' : 'darkred')
g.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
g.append('g')
.classed('y-axis', true)
.call(d3.axisLeft(y))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
And now, I'll change the bars to the nodes you have in your example code.
The horizontal chart with nodes
const data = ['SaaS', 'Sales', 'Fruits & Veggies', 'IT'].map((v, i) => ({
name: v,
value: 3 * i - 5
}));
// We want to center each rect around the value it's supposed to have.
// That means that we need to have a node width
const nodeWidth = 60;
const width = 600,
height = 300,
margin = {
top: 20,
left: 100,
right: 40,
bottom: 40
};
// We also need to make sure there is space for all nodes, even at the edges.
// One way to get this is by just extending the domain a little.
const domain = d3.extent(data.map(d => d.value));
const x = d3.scaleLinear()
.domain([domain[0] - 1.5, domain[1] + 1.5])
.range([0, width]);
const y = d3.scaleBand()
.domain(data.map(d => d.name))
.range([height, 0])
.padding(0.1);
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
const g = svg
.append('g')
.attr('transform', `translate(${margin.left} ${margin.right})`);
const bars = g.append('g')
.selectAll('rect')
.data(data);
bars.exit().remove();
// All the same until here
bars.enter()
.append('rect')
// width has become a constant
.attr('width', nodeWidth)
// Now, transform each node so it centers around the value it's supposed to have
.attr('transform', `translate(${-nodeWidth / 2} 0)`)
// Round the corners for aesthetics
.attr('rx', 15)
.merge(bars)
// `x` denotes the placement directly again
.attr('x', d => x(d.value))
.attr('y', d => y(d.name))
.attr('height', y.bandwidth())
.attr('fill', d => d.value > 0 ? 'darkgreen' : 'darkred');
// Now one more thing, we want to add labels to each node.
// `<rect>` can't have children, we we add them to the plot seperately
// using the same `data` as for the bars
const labels = g.append('g')
.selectAll('text')
.data(data);
labels.exit().remove();
labels.enter()
.append('text')
.attr('fill', 'white')
.attr('text-anchor', 'middle') // center-align the text
.attr('dy', 5) // place it down a little so it middles nicely in the node.
.merge(bars)
// `x` denotes the placement directly
.attr('x', d => x(d.value))
// Add half a bar's height to target the center of each node
.attr('y', d => y(d.name) + y.bandwidth() / 2)
// Actually fill in the text
.text(d => d.value);
g.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
g.append('g')
.classed('y-axis', true)
.call(d3.axisLeft(y))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>
I hope you can follow this. Let me know if anything about this tutorial is unclear.

Offset x-axis ticks with scaleTime

I'm trying to figure out how I can offset the x-axis ticks, as shown in this example, to be in the center of the bar when the x-axis uses scaleTime.
Right now, I'm applying a transform to the axis when I append it to the svg as such:
// x-axis code
const x = d3.scaleTime()
.domain([d3.min(dates), d3.max(dates)])
.range([margin.left, width - margin.right]);
const x_axis = d3.axisBottom()
.scale(x);
...
// offsetting the axis horizontally when I append it with bandwidth / 2
svg.append('g')
.attr('transform', `translate(${bandwidth / 2},${height - margin.bottom})`)
.call(x_axis);
But this feels hacky and leaves space between the x-axis and the y-axis.
It seems like the example I mentioned has this right because it's not using scaleTime but once scaleTime comes into the picture then things get bad. How can offset my scaleTime ticks so they line up with the middle of my bars?
Full code below:
import * as d3 from 'd3';
import rawData from './data/readings.json';
import {
barSpacing,
margin,
getBandwidth,
} from './helpers';
const width = 1000;
const height = 500;
const animationDurationRatio = 5;
const barStyle = {
color: 'steelblue',
opacity: {
default: .9,
hover: 1
}
};
const getStepData = (data, stepNum) => {
return data.map((item, i) => {
const value = i < stepNum ? item.value : 0
return {
...item,
value
};
});
};
const data = rawData.map(item => {
return {
date: new Date(item.date),
value: item.breakfast
}
});
const dates = data.map(d => d.date);
const x = d3.scaleTime()
.domain([d3.min(dates), d3.max(dates)])
.range([margin.left, width - margin.right]);
const y = d3.scaleLinear()
.domain([0, d3.max(data.map(d => d.value))]).nice()
.range([height - margin.bottom, margin.top]);
const color = d3.scaleSequential(d3.interpolateRdYlGn)
.domain([140, 115]);
// Got these values using trial and error
// Still not 100% sure how this domain works
const x_axis = d3.axisBottom()
.scale(x);
const y_axis = d3.axisLeft()
.scale(y);
const chartWidth = x.range()[1];
const bandwidth = getBandwidth(chartWidth, data, barSpacing);
const svg = d3.create('svg')
.attr('width', chartWidth)
.attr('height', height)
.attr('font-family', 'sans-serif')
.attr('font-size', 10)
.attr('text-anchor', 'end');
const bar = svg.selectAll('g')
.data(getStepData(data, 0))
.join('g');
bar.append('rect')
.attr('fill', d => {
return color(d.value);
})
.attr('opacity', barStyle.opacity.default)
.attr('x', d => {
return x(d.date)
})
.attr('y', d => y(d.value))
.attr('width', bandwidth)
.attr('height', d => y(0) - y(d.value))
.on('mouseover', function() {
d3.select(this)
.transition(30)
.attr('opacity', barStyle.opacity.hover);
})
.on('mouseout', function() {
d3.select(this)
.transition()
.attr('opacity', barStyle.opacity.default);
});
bar.append('text')
.attr('fill', 'white')
.attr('x', (d, i) => x(d.date) + bandwidth / 2)
.attr('y', d => y(0) - 10)
.attr('dx', d => `0.${d.value.toString().length * 50}em`)
.text((d, i) => data[i].value);
svg.append('g')
.attr('transform', `translate(${bandwidth / 2},${height - margin.bottom})`)
.call(x_axis);
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(y_axis);
document.querySelector('body').appendChild(svg.node());
function animateBars (data) {
const bars = svg.selectAll('rect')
.data(data);
bars
.transition()
.ease(d3.easeLinear)
.duration(d => animationDurationRatio * d.value)
.attr('y', d => y(d.value))
.attr('fill', d => {
return color(d.value);
})
.attr('height', d => y(0) - y(d.value));
}
animateBars(data)
Because scaleTime is a continuous scale, not a banded one, this isn't supported without some sort of hack or workaround (see workarounds below).
Note: Perhaps one reason for this is that some think bar charts aren't a good fit for time scales. Instead time is better visualized with a line chart or an area chart.
Solution
The best solution for this is to switch to scaleBand or something else that supports discrete bands (here's an example).
Workarounds
One workaround is to make the first bar half the size and then offset all bars by half their width:
.attr('x', d => {
return x(d.date) - bandwidth / 2
})
.attr('width', (d, i) => i === 0 ? bandwidth / 2 : bandwidth)
Other hacks/workarounds might include adding an extra day, hiding the first tick, or messing with the axis offset.
Source: https://observablehq.com/#d3/stacked-bar-chart#comment-af5453e2ab24d987
You need to translate both the axes with the same x units.
svg.append('g')
.attr('transform', `translate(${margin.left},${height-margin.bottom})`)
.call(x_axis);
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(y_axis);
const numberOfTicks = 5;
const x_axis = d3.axisBottom()
.scale(x)
.ticks(numberOfTicks - 1);
You can set the number of ticks in x-axis. The number of ticks generated on graph will be numberOfTicks + 1

Changing direction of D3 animation / transition?

I've recently built a small heatmap whose individual cells appear through a fade-in one after another after the page is loaded:
https://codepen.io/ChrisBean/pen/KKwpmjb
The fade-in animation is triggered by setting the initial opacity value of the cells to 0
squares.transition()
.duration(1000)
.delay((_d, i) => i * 200)
.style('opacity', 1);
As of now, the cells fade in from the bottom to the top column by column. I want them to fade in from left to right, row by row. This is the succession that I'm aiming at, quickly visualized with a pen drawing on a thinkpad:
Can anyone push me in the right direction of what to change in the transition trigger to change the direction?
There is no such a thing as the "direction of a transition" in D3. The whole issue here is that you're using the indices of the elements to set the delay. That being said, just change the order of the objects inside the data array, so the indices match the direction you want.
For instance:
data.sort(function(a,b){
return myVars.indexOf(b.variable) - myVars.indexOf(a.variable) ||
myGroups.indexOf(a.group) - myGroups.indexOf(b.group)
});
Here is the code with that change:
// set the dimensions and margins of the graph
const margin = {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
const width = 400 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
// append the svg object to the body of the page
// eslint-disable-next-line no-undef
const svg = d3.select('#my_dataviz')
.append('svg')
.attr('viewBox', '0 0 900 320')
.append('g')
.attr('transform',
`translate(${margin.left},${margin.top})`);
// Labels of row and columns
const myGroups = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
const myVars = ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10'];
// Build X scales and axis:
const x = d3.scaleBand()
.range([0, width])
.domain(myGroups)
.padding(0.00);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x));
// Build X scales and axis:
const y = d3.scaleBand()
.range([height, 0])
.domain(myVars)
.padding(0.00);
svg.append('g')
.call(d3.axisLeft(y));
// Build color scale
const myColor = d3.scaleLinear()
.range(['white', '#363636'])
.domain([1, 100]);
// Read the data
d3.csv('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/heatmap_data.csv', (data) => {
data.sort(function(a, b) {
return myVars.indexOf(b.variable) - myVars.indexOf(a.variable) || myGroups.indexOf(a.group) - myGroups.indexOf(b.group)
});
// create a tooltip
const tooltip = d3.select('#my_dataviz')
.append('div')
.style('opacity', 0)
.attr('class', 'tooltip')
.style('background-color', 'white')
.style('border', 'solid')
.style('border-width', '2px')
.style('border-radius', '5px')
.style('padding', '5px');
// Three function that change the tooltip when user hover / move / leave a cell
const mouseover = function() {
tooltip.style('opacity', 1);
};
const mousemove = function(d) {
tooltip
.html(`Client Branch:${d.value} <br>
Project: <br>`)
.style('left', `${d3.mouse(this)[0] + 70}px`)
.style('top', `${d3.mouse(this)[1]}px`);
};
const mouseleave = function() {
tooltip.style('opacity', 0);
};
// add the squares
const squares = svg.selectAll()
.data(data, (d) => `${d.group}:${d.variable}`)
.enter()
.append('rect')
.attr('x', (d) => x(d.group))
.attr('y', (d) => y(d.variable))
.attr('width', x.bandwidth())
.attr('height', y.bandwidth())
.style('fill', (d) => myColor(d.value))
.style('opacity', 0)
.on('mouseover', mouseover)
.on('mousemove', mousemove)
.on('mouseleave', mouseleave);
squares.transition()
.duration(1000)
.delay((_d, i) => i * 200)
.style('opacity', 1);
});
<!DOCTYPE html>
<meta charset="utf-8">
<!-- Load d3.js -->
<script src="https://d3js.org/d3.v4.js"></script>
<!-- Create a div where the graph will take place -->
<div id="my_dataviz"></div>

Categories