Reputation: 72
I am wondering is it possible to achieve the combination of area
and bar chart
in the way shown in the screenshot below?
Along with making the area in between clickable for some other action. It would be really helpful if you can guide me to some of the examples to get an idea how to achieve the same.
Upvotes: 0
Views: 1958
Reputation: 2052
In the example below, I have combined a simple bar chart (like in this famous bl.lock) with some polygons in between. I guess it could also be achieved with a path
.
const data = [
{ letter: "a", value: 9 },
{ letter: "b", value: 6 },
{ letter: "c", value: 3 },
{ letter: "d", value: 8 }
];
const svg = d3.select("#chart");
const margin = { top: 20, right: 20, bottom: 30, left: 40 };
const width = +svg.attr("width") - margin.left - margin.right;
const height = +svg.attr("height") - margin.top - margin.bottom;
const xScale = d3.scaleBand()
.rangeRound([0, width]).padding(0.5)
.domain(data.map(d => d.letter));
const yScale = d3.scaleLinear()
.rangeRound([height, 0])
.domain([0, 10]);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
g.append("g")
.attr("class", "axis axis--x")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(xScale));
g.append("g")
.attr("class", "axis axis--y")
.call(d3.axisLeft(yScale));
g.selectAll(".bar")
.data(data)
.enter().append("rect")
.attr("class", "bar")
.attr("x", d => xScale(d.letter))
.attr("y", d => yScale(d.value))
.attr("width", xScale.bandwidth())
.attr("height", d => height - yScale(d.value));
// Add polygons
g.selectAll(".area")
.data(data)
.enter().append("polygon")
.attr("class", "area")
.attr("points", (d,i,nodes) => {
if (i < nodes.length - 1) {
const dNext = d3.select(nodes[i + 1]).datum();
const x1 = xScale(d.letter) + xScale.bandwidth();
const y1 = height;
const x2 = x1;
const y2 = yScale(d.value);
const x3 = xScale(dNext.letter);
const y3 = yScale(dNext.value);
const x4 = x3;
const y4 = height;
return `${x1},${y1} ${x2},${y2} ${x3},${y3} ${x4},${y4} ${x1},${y1}`;
}
})
.on("click", (d,i,nodes) => {
const dNext = d3.select(nodes[i + 1]).datum();
const pc = Math.round((dNext.value - d.value) / d.value * 100.0);
alert(`${d.letter} to ${dNext.letter}: ${pc > 0 ? '+' : ''}${pc} %`);
});
.bar {
fill: steelblue;
}
.area {
fill: lightblue;
}
.area:hover {
fill: sandybrown;
cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<svg width="400" height="300" id="chart"></svg>
Upvotes: 1
Reputation: 451
I posted a codepen here. That creates a bar chart, and then separate area charts between each bar chart.
const BarChart = () => {
// set data
const data = [
{
value: 48,
label: 'One Rect'
},
{
value: 32,
label: 'Two Rect'
},
{
value: 40,
label: 'Three Rect'
}
];
// set selector of container div
const selector = '#bar-chart';
// set margin
const margin = {top: 60, right: 0, bottom: 90, left: 30};
// width and height of chart
let width;
let height;
// skeleton of the chart
let svg;
// scales
let xScale;
let yScale;
// axes
let xAxis;
let yAxis;
// bars
let rect;
// area
let areas = [];
function init() {
// get size of container
width = parseInt(d3.select(selector).style('width')) - margin.left - margin.right;
height = parseInt(d3.select(selector).style('height')) - margin.top - margin.bottom;
// create the skeleton of the chart
svg = d3.select(selector)
.append('svg')
.attr('width', '100%')
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');
xScale = d3.scaleBand().padding(0.15);
xAxis = d3.axisBottom(xScale);
yScale = d3.scaleLinear();
yAxis = d3.axisLeft(yScale);
svg.append('g')
.attr('class', 'x axis')
.attr('transform', `translate(0, ${height})`);
svg.append('g')
.attr('class', 'y axis');
svg.append('g')
.attr('class', 'x label')
.attr('transform', `translate(10, 20)`)
.append('text')
.text('Value');
xScale
.domain(data.map(d => d.label))
.range([0, width])
.padding(0.3);
yScale
.domain([0, 75])
.range([height, 0]);
xAxis
.scale(xScale);
yAxis
.scale(yScale);
rect = svg.selectAll('rect')
.data(data);
rect
.enter()
.append('rect')
.style('fill', d => '#00BCD4')
.attr('y', d => yScale(d.value))
.attr('height', d => height - yScale(d.value))
.attr('x', d => xScale(d.label))
.attr('width', xScale.bandwidth());
// call the axes
svg.select('.x.axis')
.call(xAxis);
svg.select('.y.axis')
.call(yAxis);
// rotate axis text
svg.select('.x.axis')
.selectAll('text')
.attr('transform', 'rotate(45)')
.style('text-anchor', 'start');
if (parseInt(width) >= 600) {
// level axis text
svg.select('.x.axis')
.selectAll('text')
.attr('transform', 'rotate(0)')
.style('text-anchor', 'middle');
}
data.forEach(
(d, i) => {
if (data[i + 1]) {
areas.push([
{
x: d.label,
y: d.value
},
{
x: data[i + 1].label,
y: data[i + 1].value
}
]);
}
}
);
areas = areas.filter(
d => Object.keys(d).length !== 0
);
areas.forEach(
a => {
const area = d3.area()
.x((d, i) => {
return i === 0 ?
xScale(d.x) + xScale.bandwidth() :
xScale(d.x);
})
.y0(height)
.y1(d => yScale(d.y));
svg.append('path')
.datum(a)
.attr('class', 'area')
.style('fill', d => '#B2EBF2')
.attr('d', area)
.on('click', d => {
console.log('hello click!');
});
}
)
}
return { init };
};
const myChart = BarChart();
myChart.init();
#bar-chart {
height: 500px;
width: 100%;
}
<script src="https://unpkg.com/[email protected]/dist/d3.min.js"></script>
<div id="bar-chart"></div>
After creating the bar chart, I repackage the data to make it conducive to creating an area chart. I created an areas
array where each item is going to be a separate area chart. I'm basically taking the values for the first bar and the next bar, and packaging them together.
data.forEach(
(d, i) => {
if (data[i + 1]) {
areas.push([
{
x: d.label,
y: d.value
},
{
x: data[i + 1].label,
y: data[i + 1].value
}
]);
}
}
);
areas = areas.filter(
d => Object.keys(d).length !== 0
);
I then iterate through each element on areas
and create the area charts.
The only tricky thing here, I think, is getting the area chart to span from the end of the first bar to the start of the second bar, as opposed to from the end of the first bar to the end of the second bar. To accomplish this, I added a rectangle width from my x-scale to the expected x value of the area chart when the first data point is being dealt with, but not the second.
I thought of this as making two points on a line: one for the first bar and one for the next bar. D3's area function can shade all the area under a line. So, the first point on my line should be the top-right corner of the first bar. The second point should be the top-left corner of the next bar.
Attaching a click event at the end is pretty straightforward.
areas.forEach(
a => {
const area = d3.area()
.x((d, i) => {
return i === 0 ?
xScale(d.x) + xScale.bandwidth() :
xScale(d.x);
})
.y0(height)
.y1(d => yScale(d.y));
svg.append('path')
.datum(a)
.attr('class', 'area')
.style('fill', d => '#B2EBF2')
.attr('d', area)
.on('click', d => {
console.log('hello click!');
});
}
)
Upvotes: 1