Я использую 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.info('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>
большое спасибо за подробное объяснение!