javascriptd3.jsbrush

In d3 multiline chart with focus and context, how do I restrict brushing of context to specific dates?


I have a multiline chart created with d3.js using focus and context, where I can use the context to brush to a different extent of dates. My data only includes dates that are the first of each month, but when brushing, one can brush between the dates. I want the brush to snap to the discrete dates in my data because eventually I want to output the selected data in a table.

I found this example, based on this stackoverflow question, that uses d3v3, but I'm having a hard time translating it to my needs. Any help would be very welcome.

let timeW = 960,
    timeH = 500

  let timeMargin = { top: 20, right: 250, bottom: 130, left: 60 },
    timeMargin2 = { top: 410, right: 250, bottom: 30, left: 60 },
    timeWidth = timeW - timeMargin.left - timeMargin.right,
    timeHeight = timeH - timeMargin.top - timeMargin.bottom,
    timeHeight2 = timeH - timeMargin2.top - timeMargin2.bottom;

  var parseDate = d3.timeParse("%Y-%m-%d");

  let timeseries = d3.select("#timeseries-container").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 x2 = d3.scaleTime().range([0, timeWidth]),
    x3 = d3.scaleTime().range([0, timeWidth]),
    y2 = d3.scaleLinear().range([timeHeight, 0]),
    y3 = d3.scaleLinear().range([timeHeight2, 0]);

  var xAxis = d3.axisBottom(x2),
    xAxis2 = d3.axisBottom(x3),
    yAxis = d3.axisLeft(y2);

  var brush = d3.brushX()
    .extent([[0, 0], [timeWidth, timeHeight2]])
    .on("brush end", brushed);

  var zoom = d3.zoom()
    .scaleExtent([1, Infinity])
    .translateExtent([[0, 0], [timeWidth, timeHeight]])
    .extent([[0, 0], [timeWidth, timeHeight]])
    .on("zoom", zoomed);

  var line = d3.line()
    .x(function (d) { return x2(d.date); })
    .y(function (d) { return y2(d.value); });

  var line2 = d3.line()
    .x(function (d) { return x3(d.date); })
    .y(function (d) { return y3(d.value); });

  timeseries.append("defs").append("clipPath")
    .attr("id", "clip")
    .append("rect")
    .attr("width", timeWidth)
    .attr("height", timeHeight);

  var focus = timeseries.append("g")
    .attr("class", "focus")
    .attr("transform", "translate(" + timeMargin.left + "," + timeMargin.top + ")");

  var context = timeseries.append("g")
    .attr("class", "context")
    .attr("transform", "translate(" + timeMargin2.left + "," + timeMargin2.top + ")");

  d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/sample.csv").then(function (data) {

    data.forEach((d) => {
      d.date = parseDate(d.date);
      d.value = +d.value;
    })

    x2.domain(d3.extent(data, function (d) { return d.date; }));
    y2.domain([0, d3.max(data, function (d) { return d.value; })]);
    x3.domain(x2.domain());
    y3.domain(y2.domain());

    const dataNest = d3.group(data, d => d.state)

    const seriesColors = ["#003F5C", "#38CFE8"]

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

    context
      .selectAll("path")
      .data(dataNest)
      .enter().append("path")
      .attr('class', 'groups')
      .attr("d", d => {
        d.line = this;
        return line2(d[1]);
      })
      .style("stroke", d => color(d[0]))
      .style("stroke-width", 1)
      .style('fill', 'none')
      .attr("clip-path", "url(#clip)")

    context.append("g")
      .attr("class", "axis axis--x")
      .attr("transform", "translate(0," + timeHeight2 + ")")
      .call(xAxis2);

    context.append("g")
      .attr("class", "brush")
      .call(brush)
      .call(brush.move, x2.range());

  });

  let zoombrush;
  function brushed(event) {
    var s = event.selection || x2.range();
    if (s[1] === s[0]) s[1] += .5;

    if (event.mode === "move") {
      var extentlength = Math.round(s[1] - s[0])
      event.target.extent([Math.round(s[0] + 0.5) - 0.5,
      Math.round(s[0] + 0.5) - 0.5 + extentlength])
    } else {
      event.target.extent([Math.round(s[0] + 0.5) - 0.5,
      Math.round(s[1] + 0.5) - 0.5])
    }
    event.target(d3.select(this))

    x2.domain(s.map(x3.invert, x3));
    focus.selectAll(".groups")
      .attr("d", d => {
        d.line = this;
        return line(d[1]);
      })

    focus.select(".axis--x").call(xAxis);
    timeseries.select(".zoom").call(zoom.transform, d3.zoomIdentity
      .scale(timeWidth / (s[1] - s[0]))
      .translate(-s[0], 0));
    zoombrush = 0;
  }
  brush.on('end', (event, d) => {
  })

  function zoomed(event) {
    if (zoombrush) return; // ignore zoom-by-brush
    zoombrush = 1;

    var t = event.transform;
    x.domain(t.rescaleX(x2).domain());
    focus.select(".groups").attr("d", line);
    focus.select(".axis--x").call(xAxis);
    context.select(".brush").call(brush.move, x.range().map(t.invertX, t));
    zoombrush = 0;
  }
.line {
    stroke: #333;
    fill: none;
    clip-path: url(#clip);
  }

  .zoom {
    cursor: move;
    fill: none;
    pointer-events: all;
  }

  .handle {
    fill: #4D4E56;
    stroke: #fff;
    stroke-opacity: 0.5;
    stroke-width: 1.25px;
  }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="timeseries-container"></div>


Solution

  • Here is an example using d3 v7. I removed the zoom behavior completely. It was an over-complicated way just to re-draw the lines.

    <!DOCTYPE html>
    
    <html>
      <head>
        <style>
          .line {
            stroke: #333;
            fill: none;
            clip-path: url(#clip);
          }
    
          .handle {
            fill: #4d4e56;
            stroke: #fff;
            stroke-opacity: 0.5;
            stroke-width: 1.25px;
          }
        </style>
      </head>
    
      <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
        <div id="timeseries-container"></div>
    
        <script>
          let timeW = 960,
            timeH = 500;
    
          let timeMargin = { top: 20, right: 250, bottom: 130, left: 60 },
            timeMargin2 = { top: 410, right: 250, bottom: 30, left: 60 },
            timeWidth = timeW - timeMargin.left - timeMargin.right,
            timeHeight = timeH - timeMargin.top - timeMargin.bottom,
            timeHeight2 = timeH - timeMargin2.top - timeMargin2.bottom;
    
          var parseDate = d3.timeParse('%Y-%m-%d');
    
          let timeseries = d3
            .select('#timeseries-container')
            .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 x2 = d3.scaleTime().range([0, timeWidth]),
            x3 = d3.scaleTime().range([0, timeWidth]),
            y2 = d3.scaleLinear().range([timeHeight, 0]),
            y3 = d3.scaleLinear().range([timeHeight2, 0]);
    
          var xAxis = d3.axisBottom(x2),
            xAxis2 = d3.axisBottom(x3),
            yAxis = d3.axisLeft(y2);
    
          var brush = d3
            .brushX()
            .extent([
              [0, 0],
              [timeWidth, timeHeight2],
            ])
            .on('brush', brush)
            .on('end', brushed);
    
          var line = d3
            .line()
            .x(function (d) {
              return x2(d.date);
            })
            .y(function (d) {
              return y2(d.value);
            });
    
          var line2 = d3
            .line()
            .x(function (d) {
              return x3(d.date);
            })
            .y(function (d) {
              return y3(d.value);
            });
    
          timeseries.append("defs").append("clipPath")
            .attr("id", "clip")
            .append("rect")
            .attr("width", timeWidth)
            .attr("height", timeHeight);
    
          var focus = timeseries
            .append('g')
            .attr('class', 'focus')
            .attr(
              'transform',
              'translate(' + timeMargin.left + ',' + timeMargin.top + ')'
            );
    
          var context = timeseries
            .append('g')
            .attr('class', 'context')
            .attr(
              'transform',
              'translate(' + timeMargin2.left + ',' + timeMargin2.top + ')'
            );
    
          d3.csv(
            'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/sample.csv'
          ).then(function (data) {
    
            data.forEach((d) => {
              d.date = parseDate(d.date);
              d.value = +d.value;
            });
    
            x2.domain(
              d3.extent(data, function (d) {
                return d.date;
              })
            );
            y2.domain([
              0,
              d3.max(data, function (d) {
                return d.value;
              }),
            ]);
            x3.domain(x2.domain());
            y3.domain(y2.domain());
    
            const dataNest = d3.group(data, (d) => d.state);
    
            const seriesColors = ['#003F5C', '#38CFE8'];
    
            var color = d3.scaleOrdinal().range(seriesColors);
    
            focus
              .selectAll('path')
              .data(dataNest)
              .enter()
              .append('path')
              .attr('class', 'focus_lines')
              .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);
    
            context
              .selectAll('path')
              .data(dataNest)
              .enter()
              .append('path')
              .attr('class', 'groups')
              .attr('d', (d) => {
                d.line = this;
                return line2(d[1]);
              })
              .style('stroke', (d) => color(d[0]))
              .style('stroke-width', 1)
              .style('fill', 'none')
              .attr('clip-path', 'url(#clip)');
    
            context
              .append('g')
              .attr('class', 'axis axis--x')
              .attr('transform', 'translate(0,' + timeHeight2 + ')')
              .call(xAxis2);
    
            context
              .append('g')
              .attr('class', 'brush')
              .call(brush)
              .call(brush.move, x2.range());
          });
    
          function brush(event) {
            if (!event.sourceEvent) return;
    
            const d0 = event.selection.map(x3.invert),
              start = d3.timeMonth.floor(d0[0]),
              end = d3.timeMonth.ceil(d0[1]);
    
            x2.domain([start, end]);
            d3.select(this).call(brush.move, [x3(start), x3(end)]);
          }
    
          function brushed(event){
            const lines = d3.selectAll('.focus_lines')
              .attr('d', (d) => {
                return line(d[1]);
              });
    
              d3.select(".axis--x").call(d3.axisBottom(x2))
          };
          
        </script>
      </body>
    </html>