我正在使用 d3 创建多线图表。我在与一行相对应的每个图例条目中添加了一个复选框,其想法是可以取消选中一个框来删除该行,然后重新选中一个框以将其添加回来。
就目前情况而言,每次选中复选框时我都会重新绘制图表。如果这是第一次运行 drawChart 函数,我会选中每个复选框。 如果选中复选框,则会过滤数据并再次运行drawChart函数,这次将原始数据与新数据进行比较。
虽然它(有点)有效,但图例颜色正在变化,我觉得这非常麻烦。有更好的方法吗?
let timeW = 960,
timeH = 450
let timeMargin = {
top: 20,
right: 300,
bottom: 80,
left: 60
},
timeWidth = timeW - timeMargin.left - timeMargin.right,
timeHeight = timeH - timeMargin.top - timeMargin.bottom;
var x2 = d3.scaleTime().range([0, timeWidth]),
y2 = d3.scaleLinear().range([timeHeight, 0]);
var xAxis = d3.axisBottom(x2),
yAxis = d3.axisLeft(y2);
var line = d3.line()
.x(function(d) {
return x2(d.date);
})
.y(function(d) {
return y2(d.value);
});
const parseDate = d3.timeParse("%Y%m%d");
d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/age.csv").then(function(data) {
var long_data = [];
data.forEach(function(row) {
row.date = parseDate(row.Date)
let tableKeys = data.columns.slice(1);
Object.keys(row).forEach(function(colname) {
if (colname !== "date" && colname !== "Date") {
long_data.push({
"date": row["date"],
"value": +row[colname],
"bucket": colname
});
}
});
});
data.sort((a, b) => a.date - b.date)
let dataNest = d3.group(long_data, d => d.bucket)
let tableKeys = data.columns.slice(1);
drawChart(long_data, dataNest, tableKeys, "init")
})
function drawChart(data, dataNest, tableKeys, which) {
d3.select("#timeseries").remove()
let timeseries = d3.select("#chart").append('svg')
.attr('id', 'timeseries')
.attr("width", timeWidth + timeMargin.left + timeMargin.right)
.attr("height", timeHeight + timeMargin.top + timeMargin.bottom)
var graph = timeseries.append('g').attr('transform', 'translate(' + timeMargin.left + ',' + timeMargin.top + ')');
var focus = timeseries.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + timeMargin.left + "," + timeMargin.top + ")");
x2.domain(d3.extent(data, function(d) {
return d.date;
}));
y2.domain([0, d3.max(data, function(d) {
return d.value;
})]);
const seriesColors = ['#ff3300', 'royalblue', 'green', 'turquoise', 'navy']
var color = d3.scaleOrdinal()
.range(seriesColors);
focus
.selectAll("path")
.data(dataNest)
.enter().append("path")
.attr('class', 'groups')
.attr("d", d => {
d.line = this;
return line(d[1]);
})
.style("stroke", d => color(d[0]))
.style("stroke-width", 1)
.style('fill', 'none')
.attr("clip-path", "url(#clip)")
focus.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + timeHeight + ")")
.call(xAxis);
focus.append("g")
.attr("class", "axis axis--y")
.call(yAxis);
// add a legend
if (which == "init") {
var legend = timeseries.append('g')
.attr('class', 'legend')
.attr("transform", "translate(" + (timeW - timeMargin.right + 10) + "," + timeMargin.top + ")")
var legendGroups = legend
.selectAll(".legendGroup")
.data(tableKeys, d => d)
var enterGroups = legendGroups
.enter()
.append("g")
.attr("class", d => "legendGroup " + d.replaceAll(" ", "_"))
legendGroups
.exit()
.remove();
enterGroups.append("text")
.attr('class', 'legend-text')
.text(d => d)
.attr("x", 45)
.attr("y", (d, i) => 10 + i * 20);
enterGroups
.append("rect")
.attr("width", 10)
.attr("height", 10)
.attr("fill", d => color(d))
.attr("x", 30)
.attr("y", (d, i) => i * 20)
.attr("class", d => d + ' legend-rect')
enterGroups
.append("foreignObject")
.attr("x", 15)
.attr("y", (d, i) => i * 20)
.attr("width", 12)
.attr("height", 13)
.attr("id", (d, i) => 'a' + i)
.append("xhtml:div")
.html("<input type='checkbox' checked class='check'>")
.attr('class', 'checkcontainer')
}
// legend items for all data, but update checkboxes checked
else if (which == "checkBoxes") {
let oldKeys = ['18-25', '26-40', '41-55', '56+', '<18']
var legend = timeseries.append('g')
.attr('class', 'legend')
.attr("transform", "translate(" + (timeW - timeMargin.right + 10) + "," + timeMargin.top + ")")
var legendGroups = legend
.selectAll(".legendGroup")
.data(oldKeys, d => d)
var enterGroups = legendGroups
.enter()
.append("g")
.attr("class", d => "legendGroup " + d.replaceAll(" ", "_"))
legendGroups
.exit()
.remove();
enterGroups.append("text")
.attr('class', 'legend-text')
.text(d => d)
.attr("x", 45)
.attr("y", (d, i) => 10 + i * 20);
enterGroups
.append("rect")
.attr("width", 10)
.attr("height", 10)
.attr("fill", d => color(d))
.attr("x", 30)
.attr("y", (d, i) => i * 20)
.attr("class", d => d + ' legend-rect')
enterGroups
.append("foreignObject")
.attr("x", 15)
.attr("y", (d, i) => i * 20)
.attr("width", 12)
.attr("height", 13)
.attr("id", (d, i) => 'a' + i)
.append("xhtml:div")
.html(function(d) {
if (tableKeys.indexOf(d) >= 0) {
return "<input type='checkbox' checked class='check'>"
} else {
return "<input type='checkbox' class='check'>"
}
})
.attr('class', 'checkcontainer')
}
d3.selectAll('.check').on('click', function(d) {
let isChecked = this.checked
let parentEnterGroup = d3.select(this.parentNode.parentNode.parentNode)
let parentGroupClass = parentEnterGroup._groups[0]
let parentGroupString = parentGroupClass[0].className.baseVal.split(" ")[1]
if (isChecked !== true) {
let newData = data.filter(d => d.bucket !== parentGroupString.replaceAll("_", " "))
let newDataNest = d3.group(newData, d => d.bucket)
let newTableKeysa = Array.from(newDataNest.keys())
drawChart(newData, newDataNest, newTableKeysa, "checkBoxes")
} else {
console.log('add it back')
}
})
};
#chart {
height: 450px;
width: 760px;
}
.check {
width: 11px;
height: 12px;
filter: grayscale(1);
margin: 0;
margin-top: -1px !important;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="chart"></div>
我个人不喜欢在 SVG 中使用复选框作为异物。虽然您可以保留它们,但我将使用 SVG 矩形,并在单击时切换它们的填充,使用描边保持其边界可见。尽管这个答案中的方法也适用于复选框。
我们需要一些方法来跟踪某个框是否被单击,为了方便起见,我只是向复选框的数据添加了一个属性,
d.clicked
。当点击一个框时,我们:
下图显示了第三点的占位符:
let data = ["one","two","three"];
let colors = ["crimson","steelblue","forestgreen"]
let svg = d3.select("body").append("svg")
let clickedItems = [];
let buttons = svg.selectAll("g")
.data(data)
.enter()
.append("g")
// use an object as the datum to allow easier modification later:
.datum(function(d) { return {name: d, clicked: false}})
.attr("transform", (d,i)=>"translate("+[5,i*20+10]+")");
buttons.append("text")
.attr("y", 10)
.attr("x", 16)
.text(d=>d.name)
buttons.append("rect")
.attr("width", 12)
.attr("height",12)
.attr("fill", (d,i)=>colors[i])
.attr("stroke", (d,i)=>colors[i])
.style("cursor", "pointer")
.attr("class", "legend-rect")
.attr("stroke-width", 2)
.on("click", function(event,d) {
// Get the index of the node to help select corresponding data:
// Unused here as I don't have a corresponding graph
//let i = d3.selectAll(".legend-rect").nodes().indexOf(this);
// record whether a box is clicked or not:
d.clicked = !d.clicked;
// toggle the rectangle's fill
d3.select(this).attr("fill", d.clicked ? "transparent" : colors[i]);
// Hide/show the data logic:
// Unused here as I don't have a corresponding graph
// d3.selectAll("#identifier"+i).style("display", d.clicked ? "none" : "");
// If we want to track if all boxes are unchecked:
// Track which boxes are checked:
d.clicked ? clickedItems.push(d.name) : clickedItems.splice(clickedItems.indexOf(d.name),1);
// If all are empty:
if(clickedItems.length == data.length) {
d3.selectAll(".legend-rect")
.attr("fill", (d,i) => colors[i])
.each(d=>d.clicked = false);
// clear clicked items array:
clickedItems = [];
// Logic for redrawing all data:
// d3.selectAll(".identifier")
// .style("display", "");
}
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
除了上述几点之外,我还使用数组(
clickedItems
)添加了快速检查,以查看是否所有框都未选中,这将导致所有复选框都被重新填充并显示所有数据(同样,只是一个占位符,因为我没有实际的模拟图)。可以轻松添加的一项附加功能是双击即可删除所有其他行。
我已在您的代码中使用了这种方法。我还添加了一些逻辑来根据显示的数据更新图表的比例。可以进行一些优化。
通过使用 CSS 隐藏/显示数据,数据将始终保留相同的顺序和索引,从而保持相同的颜色。
let timeW = 960,
timeH = 450
let timeMargin = {
top: 20,
right: 300,
bottom: 80,
left: 60
},
timeWidth = timeW - timeMargin.left - timeMargin.right,
timeHeight = timeH - timeMargin.top - timeMargin.bottom;
var x2 = d3.scaleTime().range([0, timeWidth]),
y2 = d3.scaleLinear().range([timeHeight, 0]);
var xAxis = d3.axisBottom(x2),
yAxis = d3.axisLeft(y2);
var line = d3.line()
.x(function(d) {
return x2(d.date);
})
.y(function(d) {
return y2(d.value);
});
const parseDate = d3.timeParse("%Y%m%d");
d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/age.csv").then(function(data) {
var long_data = [];
data.forEach(function(row) {
row.date = parseDate(row.Date)
let tableKeys = data.columns.slice(1);
Object.keys(row).forEach(function(colname) {
if (colname !== "date" && colname !== "Date") {
long_data.push({
"date": row["date"],
"value": +row[colname],
"bucket": colname
});
}
});
});
data.sort((a, b) => a.date - b.date)
let dataNest = d3.group(long_data, d => d.bucket)
let tableKeys = data.columns.slice(1);
drawChart(long_data, dataNest, tableKeys, "init")
})
function drawChart(data, dataNest, tableKeys, which) {
d3.select("#timeseries").remove()
let timeseries = d3.select("#chart").append('svg')
.attr('id', 'timeseries')
.attr("width", timeWidth + timeMargin.left + timeMargin.right)
.attr("height", timeHeight + timeMargin.top + timeMargin.bottom)
var graph = timeseries.append('g').attr('transform', 'translate(' + timeMargin.left + ',' + timeMargin.top + ')');
var focus = timeseries.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + timeMargin.left + "," + timeMargin.top + ")");
x2.domain(d3.extent(data, function(d) {
return d.date;
}));
y2.domain([0, d3.max(data, function(d) {
return d.value;
})]);
const seriesColors = ['#ff3300', 'royalblue', 'green', 'turquoise', 'navy']
var color = d3.scaleOrdinal()
.range(seriesColors);
focus
.selectAll("path")
.data(dataNest)
.enter().append("path")
.attr('class', function(d,i) { return 'groups group'+i; })
.attr("d", d => {
d.line = this;
return line(d[1]);
})
.style("stroke", d => color(d[0]))
.style("stroke-width", 1)
.style('fill', 'none')
.attr("clip-path", "url(#clip)")
var gXAxis = focus.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + timeHeight + ")")
.call(xAxis);
var gYAxis = focus.append("g")
.attr("class", "axis axis--y")
.call(yAxis);
var clickedItems = [];
var legend = timeseries.append('g')
.attr('class', 'legend')
.attr("transform", "translate(" + (timeW - timeMargin.right + 10) + "," + timeMargin.top + ")")
var legendGroups = legend
.selectAll(".legendGroup")
.data(tableKeys, d => d)
.enter()
.datum((d)=> d={clicked: false, name: d} )
.append("g")
.attr("transform", (d,i)=>"translate("+[0,i*20]+")");
legendGroups.append("text")
.attr('class', 'legend-text')
.text(d => d.name)
.attr("x", 45)
.attr("y", 10);
legendGroups.append("rect")
.attr("width", 10)
.attr("height", 10)
.attr("fill", d => color(d))
.attr("stroke", d=>color(d))
.attr("stroke-width", 2)
.style("cursor","pointer")
.attr("x", 30)
.attr("class", d => ' legend-rect')
.on("click", function(event,d) {
// record whether it is clicked or not:
d.clicked = !d.clicked;
// toggle the rectangle's fill
d3.select(this).attr("fill", d.clicked ? "transparent" : d=>color(d.name));
// hide/show the associated line
let i = d3.selectAll(".legend-rect").nodes().indexOf(this);
d3.selectAll(".group"+i).style("display", d.clicked ? "none" : "");
// Logic for all empty:
// keep track of unchecked boxes (also used in rescaling below):
d.clicked ? clickedItems.push(d.name) : clickedItems.splice(clickedItems.indexOf(d.name),1);
// If all are empty:
if(clickedItems.length == tableKeys.length) {
d3.selectAll(".legend-rect")
.attr("fill", d => color(d.name))
.each(d=>d.clicked = false);
d3.selectAll(".groups")
.style("display", "");
clickedItems = [];
}
// For re-scaling:
// filter scale values
x2.domain(d3.extent(data.filter(d=>clickedItems.indexOf(d.bucket) == -1), function(d) {
return d.date;
}));
y2.domain([0, d3.max(data.filter(d=>clickedItems.indexOf(d.bucket) == -1), function(dd) {
return dd.value;
})]);
gYAxis.call(yAxis)
gXAxis.call(xAxis)
focus.selectAll("path.groups")
.transition() // May want to use clip area for the line here.
.attr("d", d => {
return line(d[1]);
})
})
};
#chart {
height: 450px;
width: 760px;
}
.check {
width: 11px;
height: 12px;
filter: grayscale(1);
margin: 0;
margin-top: -1px !important;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="chart"></div>