我们有分配给不同团队的项目。现在,我必须创建项目时间表。
出于这个问题的目的,我在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_date
到end_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是否为此提供其他方法?
答案 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);
完整代码:
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-array
。 d3.group
和d3.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
,而不是Array
。 Map
是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 ]
是使域路径移到图表底部的原因。