sprucegoose
sprucegoose

Reputation: 610

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>

Upvotes: 1

Views: 34

Answers (1)

Mark
Mark

Reputation: 108512

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>

Upvotes: 1

Related Questions