在D3.js中为不同宽度的波段创建刻度

时间:2019-06-12 11:12:05

标签: javascript d3.js svg data-visualization

我们有分配给不同团队的项目。现在,我必须创建项目时间表。

出于这个问题的目的,我在jsfiddle.net中创建了一个虚拟对象。 https://jsfiddle.net/cezar77/6u1waqso/2

“虚拟”数据如下所示:

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

时间显示在 x 轴上,从start_dateend_date的每个项目都有一个水平条。

在左侧的 y 轴上,我想显示团队(请参见jsfiddle左侧的标签),并为每个团队创建一条网格线,将项目组。由于每个团队都有不同数量的项目,因此网格线应放置在不同的距离上。

我尝试在机会不多的情况下使用阈值标度:

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

const yAxis = d3.axisLeft(yScale);

但是当我叫它:

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

它会引发错误。

为此使用刻度和轴是否合适?如果是,我应该如何解决这个问题?

如果使用比例尺和轴是错误的方法,D3.js是否为此提供其他方法?

2 个答案:

答案 0 :(得分:3)

是的,您可以使用刻度来处理该问题,如果始终对数据进行分组,则可以尝试保存每个分组值的偏移量。我们可以按比例缩放,也可以只使用数据。

创建音阶将是这样的:

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

这样,我们可以使用比例尺获得偏移量。我们要使用scaleOrdinal,因为我们想要一对一的映射。从文档中:

  

与连续刻度不同,序数刻度具有离散的域和范围。例如,序数标度可以将一组命名类别映射到一组颜色,或者确定柱状图中各列的水平位置。

如果我们检查新的yScale,我们将看到以下内容:

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

我们也可以尝试将偏移量添加到数据中并实现相同的目的:

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
      };
    })
  })

这样,我们只需创建组并使用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

现在让我们渲染每个项目:

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

这应该使我们所有的rect相对于我们的组。现在让我们处理标签:

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')

最后绘制团队分隔符:

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

完整代码:

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

答案 1 :(得分:1)

由于@torresomar的出色回答,我对如何进一步改进代码有了一个想法,并提出了一种略有不同的方法。在他的代码示例中,使用D3.js的常规更新模式手动定位了网格线和轴标签。在我的版本中,我称为 Y 轴,并且网格线和文本标签会自动定位,而文本标签需要重新定位。

我们将一步一步走,希望对其他用户有帮助。

这些是我们拥有的虚拟数据:

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

我们要按team对项目进行分组。首先,我们按start_date对其进行升序排序。

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

在我的问题中,我使用了d3.nest。但是,这是不推荐使用的模块d3-collection的一部分。建议使用新版本的模块d3-arrayd3.groupd3.rollup代替了d3.nest

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

这样可以将projects分组:

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

请务必注意,这是Map,而不是ArrayMap是ES2015引入的一种新的JavaScript对象类型。

在创建D3.js图形时,我习惯于在开始时定义一组值。以后,如果我想更改大小或重新定位项目,我只是在弄弄这些值。我们开始:


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

现在,我们可以配置比例尺和轴。为了简洁起见,我将跳过 X 轴,而我们直接跳转到 Y 轴。

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

您现在可以在此jsfiddle中看到中介结果。左轴上的标签放置不理想。我们可以使用以下代码对其进行调整:

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

Now看起来更好。让我们创建甘特图并为项目时间线放置水平条。

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

在这里我必须说我真的不知道如何将对象Map传递给d3.data,所以我只使用了散布运算符并将其转换为数组。

结果看起来像this。但是,我不喜欢这些条彼此紧贴。我希望各组酒吧之间保持一定距离。也许您已经注意到我声明了一个常量spacing,但是没有使用它。让我们利用它。

我们更改这些行:

// 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]})`);

chart现在显示了时间线栏之间的一些距离(按团队分组)。

最终由于遗留原因,您必须使用d3.nest,否则您将无法使用ES2015的新功能,例如对象Map。如果是这样,请查看替代项version。域路径以蓝色突出显示。这是为了说明我为什么从0开始偏移数组并包含最后一个团队项目的值长度的原因。区别:

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

是使域路径移到图表底部的原因。