admin管理员组

文章数量:1402280

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.

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?

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. https://jsfiddle/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?

Share Improve this question asked Jun 12, 2019 at 11:12 cezarcezar 12k6 gold badges50 silver badges90 bronze badges
Add a ment  | 

2 Answers 2

Reset to default 6 +50

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);
*/

Thanks to the excellent answer of @torresomar I've got an idea how to further improve the code and came up with a slightly different approach. In his code example the gridlines and axis labels are positioned manually, using the general update pattern of D3.js. In my version I call the Y axis and the gridlines and the text labels are positioned automatically, with the text labels requiring some repositioning.

Here we go step for step, hopefully it is beneficial to other users.

These are the dummy data we have:

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'
  },
];

We want to group the projects by team. First we sort them by start_date in ascending order.

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

In my question I have used d3.nest. However this is part of the module d3-collection which is deprecated. The use of the new version of the module d3-array is recended. d3.group and d3.rollup are replacement for d3.nest.

const data = d3.group(projects, d => d.team);

This groups the projects in this way:

0: {"database" => Array(4)}
1: {"marketing" => Array(1)}
2: {"operations" => Array(3)}
3: {"frontend" => Array(1)}
4: {"devops" => Array(2)}
5: {"backend" => Array(1)}

It's important to note that this is a Map, not an Array. Map is a new JavaScript object type, introduced with ES2015.

When creating D3.js graphics I have a habit of defining a set of values at the beginning. Later if I want to change the size or reposition items, I just fiddle with these values. Here we go:


// Configure dimensions
const
  barHeight = 16,
  spacing = 6,
  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
;

Now we can configure the scales and the axes. I'll skip the X axis here for the sake of brevity and we jump directly to the Y axis.

// we create an array to hold the offsets starting with 0
// it will hold the number of projects per team
const offset = [0];
// and iterate over the map and push the value length to the offset array
data.forEach(function(d) {
    this.push(d.length);
}, offset);
// the end result is: [0, 4, 1, 3, 1, 2, 1]

// the range is [0, 4, 5, 8, 9, 11]
// the domain is the keys
// we use the spread operator to get an array out of the MapIterator
const yScale = d3.scaleOrdinal()
  .range(offset.map((d, i, a) => a.slice(0, (i + 1))
                                  .reduce((acc, cur) => acc + cur, 0) * barHeight
  ))
  .domain([...data.keys()])
;

// the inner ticks should serve as gridnlines stretching to the right end
const yAxis = d3.axisLeft(yScale)
  .tickSizeInner(chart.width)
  .tickSizeOuter(0)
;

// we call the Y-axis

// Draw Y axis
svg.append('g')
  .attr('class', 'y-axis')
  .attr('transform', `translate(${[margin.left + chart.width, margin.top]})`)
  .call(yAxis);

You can see now the intermediary result in this jsfiddle. The labels on the left axis are not positioned ideally. We can adjust them with this code:

svg.selectAll('.y-axis text')
  .attr('transform', d => `translate(0,${data.get(d).length * barHeight/2})`);

Now it looks better. Let's create the Gantt Chart and place the horizontal bars for the project timelines.

const teams = svg.selectAll('g.team')
  .data([...data])
  .join('g')
  .attr('class', 'team')
  .attr(
    'transform',
    (d, i, a) => `translate(${[margin.left, margin.top + yScale(d[0])]})`
  );

teams.selectAll('rect.project')
  .data(d => d[1])
  .join('rect')
  .attr('class', d => 'project ' + d.team)
  .attr('x', d => xScale(d3.isoParse(d.start_date)))
  .attr('y', (d,i) => i * barHeight)
  .attr(
    'width',
    d => xScale(d3.isoParse(d.end_date)) - xScale(d3.isoParse(d.start_date))
  )
  .attr('height', barHeight);

Here I must say I don't really know how we should pass the object Map to d3.data, so I just used the spread operator and converted it to an array.

The result looks like this. However I don't like that the bars are sticking to close to each other. I'd prefer to have some distance between the groups of bars. Maybe you have noticed I declared a constant spacing, but haven't used it. Let's make use of it.

We change these lines:

// config dimensions
chart = {
  width: 1000,
  height: projects.length * barHeight + data.size * spacing
},
// range for Y scale
.reduce((acc, cur) => acc + cur, 0) * barHeight + i * spacing
// reposition of left axis labels
.attr('transform', d => `translate(0,${data.get(d).length * barHeight/2 + spacing/2})`);
// appending groups for each team
.attr('transform', (d, i, a) => `translate(${[margin.left, margin.top + yScale(d[0]) + spacing/2]})`);

The chart now displays some distance between the timeline bars, grouped by team.

Eventually you have to use d3.nest due to legacy reasons, or you can't use new features of ES2015, like the object Map. If so, then please take a look to the alternative version. The domain paths are highlighted with blue colour. It is to show the reason why I started the offset array with 0 and include the value length for the last team item. This difference:

[ 0, 4, 5, 8, 9, 11 ]
[ 0, 4, 5, 8, 9, 11, 12 ]

is what makes the domain path go to the bottom of the chart.

本文标签: javascriptCreate a scale for bands with different width in D3jsStack Overflow