Create a scale for bands with different width in D3.js

Tags: , , ,



We have projects, which are assigned to different teams. Now I have to create project timelines.

For the purposes of this question I have created a dummy in jsfiddle.net. https://jsfiddle.net/cezar77/6u1waqso/2

The “dummy” data look like this:

const projects = [
    {
        'name': 'foo',
        'team': 'operations',
        'start_date': '2018-01-01',
        'end_date': '2019-12-31'
    },
    {
        'name': 'bar',
        'team': 'operations',
        'start_date': '2017-01-01',
        'end_date': '2018-12-31'
    },
    {
        'name': 'abc',
        'team': 'operations',
        'start_date': '2018-01-01',
        'end_date': '2018-08-31'
    },
    {
        'name': 'xyz',
        'team': 'devops',
        'start_date': '2018-04-01',
        'end_date': '2020-12-31'
    },
    {
        'name': 'wtf',
        'team': 'devops',
        'start_date': '2018-01-01',
        'end_date': '2019-09-30'
    },
    {
        'name': 'qwerty',
        'team': 'frontend',
        'start_date': '2017-01-01',
        'end_date': '2019-01-31'
    },
    {
        'name': 'azerty',
        'team': 'marketing',
        'start_date': '2016-01-01',
        'end_date': '2019-08-31'
    },
    {
        'name': 'qwertz',
        'team': 'backend',
        'start_date': '2018-05-01',
        'end_date': '2019-12-31'
    },
    {
        'name': 'mysql',
        'team': 'database',
        'start_date': '2015-01-01',
        'end_date': '2017-09-15'
    },
    {
        'name': 'postgresql',
        'team': 'database',
        'start_date': '2016-01-01',
        'end_date': '2018-12-31'
    }
];

The time is displayed on the x axis and there is a horizontal bar for every project stretching from the start_date to the end_date.

On the left side, on the y axis, I’d like to display the teams (see the labels on the left side in the jsfiddle) and create a gridline for each team, separating the groups of projects. Because each team has a different number of projects, the gridlines should be placed at different distances.

I tried to use a threshold scale on the off chance:

const yScale = d3.scaleThreshold()
  .domain(data.map(d => d.values.length))
  .range(data.map(d => d.key));

const yAxis = d3.axisLeft(yScale);

but when I call it:

svg.append('g')
  .attr('class', 'y-axis')
  .call(yAxis);

it throws an error.

Is it appropriate to use a scale and axis for this purpose? If yes, how should I approach the problem?

If using a scale and axis is a wrong approach, are there any other methods provided by D3.js for this purpose?

Answer

Yeah you can use a scale to handle that, if the data is always grouped you can try saving the offset of each grouped value. We can do it with the scale or just using the data.

Creating a scale would be something like this:

const yScale = d3.scaleOrdinal()
  .range(data.reduce((acc, val, index, arr) => {
    if (index > 0) {
      acc.push(arr[index - 1].values.length + acc[acc.length - 1]);
    } else {
      acc.push(0);
    }
    return acc;
  }, []))
  .domain(data.map(d => d.key));

With this we can get the offset using a scale. We are using scaleOrdinal since we want a 1-to-1 mapping. From the docs:

Unlike continuous scales, ordinal scales have a discrete domain and range. For example, an ordinal scale might map a set of named categories to a set of colors, or determine the horizontal positions of columns in a column chart.

If we check our new yScale we can see the following:

console.log(yScale.range());       // Array(6) [ 0, 4, 5, 8, 9, 11 ]
console.log(yScale.domain());      // Array(6) [ "database", "marketing", "operations", "frontend", "devops", "backend" ]
console.log(yScale("database"));   // 0
console.log(yScale("marketing"));  // 4

We could also try just adding the offset into the data and achieve the same:

const teams = svg.selectAll('g.group__team')
  .data(d => {
    let offset = 0;
    return data.map((d, i) => {
      if(i > 0) offset+= data[i - 1].values.length;
      return {
        ...d,
        offset
      };
    })
  })

With that we just simply create groups and translate them using the offset:

const teams = svg.selectAll('g.group__team')
  .data(d => {
    let offset = 0;
    return data.map((d, i) => {
      if (i > 0) offset += data[i - 1].values.length;
      return {
        ...d,
        offset
      };
    })
  })
  .join('g')
  .attr('class', d => 'group__team ' + d.key)
  .attr('transform', d => `translate(${[0, yScale(d.key) * barHeight]})`) // using scale
  .attr('transform', d => `translate(${[0, d.offset * barHeight]})`)      // using our data

Now lets render each project:

teams.selectAll('rect.group__project')
  .data(d => d.values)
  .join('rect')
  .attr('class', d => 'group__project ' + d.team)
  .attr('x', d => margin.left + xScale(d3.isoParse(d.start_date)))
  .attr('y', (d, i) => margin.top + i * barHeight)
  .attr('width', d => xScale(d3.isoParse(d.end_date)) - xScale(d3.isoParse(d.start_date)))
  .attr('height', barHeight);

This should render all our rects relative to our group. Now lets deal with the labels:

teams.selectAll('text.group__name')
  .data(d => [d])
  .join('text')
  .attr('class', 'group__name')
  .attr('x', 5)
  .attr('y', (d, i) => margin.top + (d.values.length * barHeight) / 2) // Get half of the sum of the project bars in the team
  .attr('dy', '6px')

And lastly render a delimiter of teams:

teams.selectAll('line.group__delimiter')
  .data(d => [d])
  .join('line')
  .attr('class', 'line group__delimiter')
  .attr('x1', margin.left)
  .attr('y1', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('x2', viewport.width)
  .attr('y2', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('stroke', '#222')
  .attr('stroke-width', 1)
  .attr('stroke-dasharray', 10);

JSfiddle working code

Full code:

const projects = [{
    'name': 'foo',
    'team': 'operations',
    'start_date': '2018-01-01',
    'end_date': '2019-12-31'
  },
  {
    'name': 'bar',
    'team': 'operations',
    'start_date': '2017-01-01',
    'end_date': '2018-12-31'
  },
  {
    'name': 'abc',
    'team': 'operations',
    'start_date': '2018-01-01',
    'end_date': '2018-08-31'
  },
  {
    'name': 'xyz',
    'team': 'devops',
    'start_date': '2018-04-01',
    'end_date': '2020-12-31'
  },
  {
    'name': 'wtf',
    'team': 'devops',
    'start_date': '2018-01-01',
    'end_date': '2019-09-30'
  },
  {
    'name': 'qwerty',
    'team': 'frontend',
    'start_date': '2017-01-01',
    'end_date': '2019-01-31'
  },
  {
    'name': 'azerty',
    'team': 'marketing',
    'start_date': '2016-01-01',
    'end_date': '2019-08-31'
  },
  {
    'name': 'qwertz',
    'team': 'backend',
    'start_date': '2018-05-01',
    'end_date': '2019-12-31'
  },
  {
    'name': 'mysql',
    'team': 'database',
    'start_date': '2015-01-01',
    'end_date': '2017-09-15'
  },
  {
    'name': 'postgresql',
    'team': 'database',
    'start_date': '2016-01-01',
    'end_date': '2018-12-31'
  },
  {
    'name': 'mysql',
    'team': 'database',
    'start_date': '2018-05-01',
    'end_date': '2019-12-31'
  },
  {
    'name': 'mysql',
    'team': 'database',
    'start_date': '2018-05-01',
    'end_date': '2019-12-31'
  },
]

// Process data
projects.sort((a, b) => d3.ascending(a.start_date, b.start_date));

const data = d3.nest()
  .key(d => d.team)
  .entries(projects);

const flatData = d3.merge(data.map(d => d.values));

// Configure dimensions
const
  barHeight = 16,
  margin = {
    top: 50,
    left: 100,
    right: 20,
    bottom: 50
  },
  chart = {
    width: 1000,
    height: projects.length * barHeight
  },
  viewport = {
    width: chart.width + margin.left + margin.right,
    height: chart.height + margin.top + margin.bottom
  },
  tickBleed = 5,
  labelPadding = 10;

// Configure scales and axes
const xMin = d3.min(
  flatData,
  d => d3.isoParse(d.start_date)
);
const xMax = d3.max(
  flatData,
  d => d3.isoParse(d.end_date)
);

const xScale = d3.scaleTime()
  .range([0, chart.width])
  .domain([xMin, xMax]);

const xAxis = d3.axisBottom(xScale)
  .ticks(20)
  .tickSize(chart.height + tickBleed)
  .tickPadding(labelPadding);

const yScale = d3.scaleOrdinal()
  .range(data.reduce((acc, val, index, arr) => {
    if (index > 0) {
      acc.push(arr[index - 1].values.length + acc[acc.length - 1]);
    } else {
      acc.push(0);
    }
    return acc;
  }, []))
  .domain(data.map(d => d.key));

console.log(yScale.range());
console.log(yScale.domain());
console.log(yScale("database"));
console.log(yScale("marketing"));

const yAxis = d3.axisLeft(yScale);

// Draw SVG
const svg = d3.select('body')
  .append('svg')
  .attr('width', viewport.width)
  .attr('height', viewport.height);

svg.append('g')
  .attr('class', 'x-axis')
  .call(xAxis);

d3.select('.x-axis')
  .attr(
    'transform',
    `translate(${[margin.left, margin.top]})`
  );

d3.select('.x-axis .domain')
  .attr(
    'transform',
    `translate(${[0, chart.height]})`
  );

const chartArea = svg.append('rect')
  .attr('x', margin.left)
  .attr('y', margin.top)
  .attr('width', chart.width)
  .attr('height', chart.height)
  .style('fill', 'red')
  .style('opacity', 0.1)
  .style('stroke', 'black')
  .style('stroke-width', 1);

const teams = svg.selectAll('g.group__team')
  .data(d => {
    let offset = 0;
    return data.map((d, i) => {
      if (i > 0) offset += data[i - 1].values.length;
      return {
        ...d,
        offset
      };
    })
  })
  .join('g')
  .attr('class', d => 'group__team ' + d.key)
  .attr('transform', d => `translate(${[0, yScale(d.key) * barHeight]})`)
  .attr('transform', d => `translate(${[0, d.offset * barHeight]})`)
  .on('mouseenter', d => {
    svg.selectAll('.group__team')
      .filter(team => d.key != team.key)
      .attr('opacity', 0.2);
  })
  .on('mouseleave', d => {
    svg.selectAll('.group__team')
      .attr('opacity', 1);
  })

teams.selectAll('rect.group__project')
  .data(d => d.values)
  .join('rect')
  .attr('class', d => 'group__project ' + d.team)
  .attr('x', d => margin.left + xScale(d3.isoParse(d.start_date)))
  .attr('y', (d, i) => margin.top + i * barHeight)
  .attr('width', d => xScale(d3.isoParse(d.end_date)) - xScale(d3.isoParse(d.start_date)))
  .attr('height', barHeight);


teams.selectAll('text.group__name')
  .data(d => [d])
  .join('text')
  .attr('class', 'group__name')
  .attr('x', 5)
  .attr('y', (d, i) => margin.top + (d.values.length * barHeight) / 2)
  .attr('dy', '6px')
  .text(d => d.key);

teams.selectAll('line.group__delimiter')
  .data(d => [d])
  .join('line')
  .attr('class', 'line group__delimiter')
  .attr('x1', margin.left)
  .attr('y1', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('x2', viewport.width)
  .attr('y2', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('stroke', '#222')
  .attr('stroke-width', 1)
  .attr('stroke-dasharray', 10)



/**
svg.append('g')
    .attr('class', 'y-axis')
  .call(yAxis);
*/


Source: stackoverflow