Используйте флажки для добавления или удаления линий из многолинейной диаграммы d3.js

Я использую 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>
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
1
0
95
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Лично мне не нравится использовать флажки в SVG как посторонние объекты. Хотя вы можете оставить их, я просто буду использовать прямоугольники SVG и буду переключать их заливку при нажатии, используя обводку, чтобы их границы оставались видимыми. Хотя подход в этом ответе работает и с флажками.

Нам нужен какой-то метод, чтобы отслеживать, нажат ли флажок, для простоты я просто добавил свойство к данным флажков, d.clicked. При нажатии на поле мы:

  • переключать значение щелчка датума между ложью и истиной
  • либо удалите, либо повторно добавьте заливку на основе d.clicked
  • используйте CSS, чтобы скрыть/показать связанную линию графика

Ниже показано это с заполнителем для третьей точки:

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>

большое спасибо за подробное объяснение!

sprucegoose 08.04.2024 20:07

Другие вопросы по теме