adityajain019
adityajain019

Reputation: 72

D3 combination of bar and area chart

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.

Aimed Display

Upvotes: 0

Views: 1958

Answers (2)

thibautg
thibautg

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.

Chart

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

mattbatman
mattbatman

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

Related Questions