javascriptd3.jsbrushsnapping

Is it possible to do brush snapping in d3.js when there isn't a regular interval?


I have a bar chart in d3 that uses focus/context. When the user brushes to select the dates, I want to snap to the dates available, rather than them being able to brush to any date between the beginning and end. This brush snapping example uses a twelve hour interval. In my case, there is no regular interval. Is there a way to snap to the dates with data?

const margin = {
        top: 20,
        right: 20,
        bottom: 90,
        left: 50
    },
        margin2 = {
            top: 230,
            right: 20,
            bottom: 30,
            left: 50
        },
        width = 960 - margin.left - margin.right,
        height = 300 - margin.top - margin.bottom,
        height2 = 300 - margin2.top - margin2.bottom;

    const parseTime = d3.timeParse("%Y-%m-%d %H:%M");

    const x = d3.scaleTime().range([0, width]),
        x2 = d3.scaleTime().range([0, width]),
        y = d3.scaleLinear().range([height, 0]),
        y2 = d3.scaleLinear().range([height2, 0]),
        dur = d3.scaleLinear().range([0, 12]);

    const xAxis = d3.axisBottom(x).tickSize(0),
        xAxis2 = d3.axisBottom(x2).tickSize(0),
        yAxis = d3.axisLeft(y).tickSize(0);

    const brush = d3.brushX()
        .extent([
            [0, 0],
            [width, height2]
        ])
        .on("start brush end", brushed);

    const svg = d3.select("body").append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom);

    svg.append("defs").append("clipPath")
        .attr("id", "clip")
        .append("rect")
        .attr("width", width)
        .attr("height", height);

    const focus = svg.append("g")
        .attr("class", "focus")
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    const context = svg.append("g")
        .attr("class", "context")
        .attr("transform", "translate(" + margin2.left + "," + margin2.top + ")");

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

        const parseTime = d3.timeParse("%Y-%m-%d %H:%M");
        const mouseoverTime = d3.timeFormat("%a %e %b %Y %H:%M");
        const minTime = d3.timeFormat("%b%e, %Y");
        const parseDate = d3.timeParse("%b %Y");

        data.forEach((d) => {
            d.date = parseTime(d.date);
            d.end = parseTime(d.end);
            d.distance = +d.distance;
            return d;
        },
            (error, data) => {
                if (error) throw error;
            })

        let total = 0;

        data.forEach((d) => total = d.distance + total);

        const minDate = d3.min(data, d => d.date)

        const xMin = d3.min(data, d => d.date)

        const yMax = Math.max(20, d3.max(data, d => d.distance))

        x.domain([xMin, d3.max(data, d => d.date)])
        y.domain([0, yMax]);
        x2.domain(x.domain());
        y2.domain(y.domain());

        var rects = focus.append("g");
        rects.attr("clip-path", "url(#clip)");
        rects.selectAll("rects")
            .data(data)
            .enter().append("rect")
            .style("fill","royalblue")
            .attr("class", "rects")
            .attr("x", d => x(d.date))
            .attr("y", d => y(d.distance))
            .attr("width", 10)
            .attr("height", d => height - y(d.distance))

        focus.append("g")
            .attr("class", "axis x-axis")
            .attr("transform", "translate(0," + height + ")")
            .call(xAxis);

        focus.append("g")
            .attr("class", "axis axis--y")
            .call(yAxis);

        focus.append("text")
            .attr("transform", "rotate(-90)")
            .attr("y", 0 - margin.left)
            .attr("x", 0 - (height / 2))
            .attr("dy", "1em")
            .style("text-anchor", "middle")
            .text("Distance in meters");

        svg.append("text")
            .attr("transform",
                "translate(" + ((width + margin.right + margin.left) / 2) + " ," +
                (height + margin.top + margin.bottom) + ")")
            .style("text-anchor", "middle")
            .text("Date");

        var rects = context.append("g");
        rects.attr("clip-path", "url(#clip)");
        rects.selectAll("rects")
            .data(data)
            .enter().append("rect")
            .style("fill", "royalblue")
            .attr("class", "rects")
            .attr("x", d => x2(d.date))
            .attr("y", d => y2(d.distance))
            .attr("width", 10)
            .attr("height", d => height2 - y2(d.distance));

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

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

    });

    function brushed(event) {
        var s = event.selection || x2.range();
        x.domain(s.map(x2.invert, x2));
        focus.selectAll(".rects")
            .attr("x", d => x(d.date))
            .attr("y", d => y(d.distance))
            .attr("width", 10)
            .attr("height", d => height - y(d.distance))

        focus.select(".x-axis").call(xAxis);

        var e = event.selection;
        var selectedrects = focus.selectAll('.rects').filter(() => {
            var xValue = this.getAttribute('x');
            return e[0] <= xValue && xValue <= e[1];
        });
    }
    body {
        font-family: avenir next, sans-serif;
        font-size: 12px;
    }

    .axis {
        stroke-width: 0.5px;
        stroke: #888;
        font: 10px avenir next, sans-serif;
    }

    .axis>path {
        stroke: #888;
    }

    .handle {
        width: 6px !important;
        fill: #000 !important;
        margin-left: 0px !important;
        display: block;
    }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="totalDistance">
    </div>


Solution

  • Similar to my last answer but in this version, during the drag event, it finds the dates in your dataset closest to both drag handles and snaps to them. Note, I didn't completely polish this up (it doesn't re-draw the "focus" bar):

    <!DOCTYPE html>
    
    <html>
      <head>
        <style>
          body {
            font-family: avenir next, sans-serif;
            font-size: 12px;
          }
    
          .axis {
            stroke-width: 0.5px;
            stroke: #888;
            font: 10px avenir next, sans-serif;
          }
    
          .axis > path {
            stroke: #888;
          }
    
          .handle {
            width: 6px !important;
            fill: #000 !important;
            margin-left: 0px !important;
            display: block;
          }
        </style>
      </head>
    
      <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
        <div id="totalDistance"></div>
    
        <script>
          const margin = {
              top: 20,
              right: 20,
              bottom: 90,
              left: 50,
            },
            margin2 = {
              top: 230,
              right: 20,
              bottom: 30,
              left: 50,
            },
            width = 960 - margin.left - margin.right,
            height = 300 - margin.top - margin.bottom,
            height2 = 300 - margin2.top - margin2.bottom;
    
          const parseTime = d3.timeParse('%Y-%m-%d %H:%M');
    
          const x = d3.scaleTime().range([0, width]),
            x2 = d3.scaleTime().range([0, width]),
            y = d3.scaleLinear().range([height, 0]),
            y2 = d3.scaleLinear().range([height2, 0]),
            dur = d3.scaleLinear().range([0, 12]);
    
          const xAxis = d3.axisBottom(x).tickSize(0),
            xAxis2 = d3.axisBottom(x2).tickSize(0),
            yAxis = d3.axisLeft(y).tickSize(0);
    
          const brush = d3
            .brushX()
            .extent([
              [0, 0],
              [width, height2],
            ])
            .on('end', brushed)
            .on('brush', brushing);
    
          const svg = d3
            .select('body')
            .append('svg')
            .attr('width', width + margin.left + margin.right)
            .attr('height', height + margin.top + margin.bottom);
    
          svg
            .append('defs')
            .append('clipPath')
            .attr('id', 'clip')
            .append('rect')
            .attr('width', width)
            .attr('height', height);
    
          const focus = svg
            .append('g')
            .attr('class', 'focus')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
    
          const context = svg
            .append('g')
            .attr('class', 'context')
            .attr(
              'transform',
              'translate(' + margin2.left + ',' + margin2.top + ')'
            );
    
          let data;
          d3.csv(
            'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/data.csv'
          ).then((d) => {
            data = d;
    
            const parseTime = d3.timeParse('%Y-%m-%d %H:%M');
            const mouseoverTime = d3.timeFormat('%a %e %b %Y %H:%M');
            const minTime = d3.timeFormat('%b%e, %Y');
            const parseDate = d3.timeParse('%b %Y');
    
            data.forEach(
              (d) => {
                d.date = parseTime(d.date);            
                d.end = parseTime(d.end);
                d.distance = +d.distance;
                return d;
              },
              (error, data) => {
                if (error) throw error;
              }
            );
    
            let total = 0;
    
            data.forEach((d) => (total = d.distance + total));
    
            const minDate = d3.min(data, (d) => d.date);
    
            const xMin = d3.min(data, (d) => d.date);
    
            const yMax = Math.max(
              20,
              d3.max(data, (d) => d.distance)
            );
    
            x.domain([xMin, d3.max(data, (d) => d.date)]);
            y.domain([0, yMax]);
            x2.domain(x.domain());
            y2.domain(y.domain());
    
            var rects = focus.append('g');
            rects.attr('clip-path', 'url(#clip)');
            rects
              .selectAll('rects')
              .data(data)
              .enter()
              .append('rect')
              .style('fill', 'royalblue')
              .attr('class', 'rects')
              .attr('x', (d) => x(d.date))
              .attr('y', (d) => y(d.distance))
              .attr('width', 10)
              .attr('height', (d) => height - y(d.distance));
    
            focus
              .append('g')
              .attr('class', 'axis x-axis')
              .attr('transform', 'translate(0,' + height + ')')
              .call(xAxis);
    
            focus.append('g').attr('class', 'axis axis--y').call(yAxis);
    
            focus
              .append('text')
              .attr('transform', 'rotate(-90)')
              .attr('y', 0 - margin.left)
              .attr('x', 0 - height / 2)
              .attr('dy', '1em')
              .style('text-anchor', 'middle')
              .text('Distance in meters');
    
            svg
              .append('text')
              .attr(
                'transform',
                'translate(' +
                  (width + margin.right + margin.left) / 2 +
                  ' ,' +
                  (height + margin.top + margin.bottom) +
                  ')'
              )
              .style('text-anchor', 'middle')
              .text('Date');
    
            var rects = context.append('g');
            rects.attr('clip-path', 'url(#clip)');
            rects
              .selectAll('rects')
              .data(data)
              .enter()
              .append('rect')
              .style('fill', 'royalblue')
              .attr('class', 'rects')
              .attr('x', (d) => x2(d.date))
              .attr('y', (d) => y2(d.distance))
              .attr('width', 10)
              .attr('height', (d) => height2 - y2(d.distance));
    
            context
              .append('g')
              .attr('class', 'axis x-axis')
              .attr('transform', 'translate(0,' + height2 + ')')
              .call(xAxis2);
    
            context
              .append('g')
              .attr('class', 'brush')
              .call(brush)
              .call(brush.move, x.range());
          });
    
          function brushing(event) {
            if (!event.sourceEvent) return;
    
            const clDt = event.selection.map(x2.invert),
              snapXs = x.domain();
    
            // find the dates in the dataset closest to both handles
            for (let i = 1; i < data.length; i++){
              const currDate = data[i].date, 
                    prevDate = data[i-1].date;
              if (Math.abs(currDate - clDt[0]) < Math.abs(prevDate - clDt[0]))
                snapXs[0] = currDate;
              if (Math.abs(currDate - clDt[1]) < Math.abs(prevDate - clDt[1]))
                snapXs[1] = currDate;
            }
            x.domain(snapXs);
            d3.select(this).call(brush.move, [x2(snapXs[0]), x2(snapXs[1])]);
          }
    
          function brushed(event){
              d3.select(".x-axis").call(d3.axisBottom(x))
          };
        </script>
      </body>
    </html>