Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Creating an endless horizontal axis with d3 v4

Tags:

svg

d3.js

I am using d3 v4 (4.12.0).

I have an SVG container into which I am drawing a simple horizontal axis (x-axis, linear scale) that responds to panning with the mouse.

I would like to simulate an "infinite" or "endless" horizontal axis.

By this, I mean that I want to only load and render a small portion of a very large dataset, and only draw enough of the axis that shows a very small subset of elements from this large set.

Say I have the horizontal axis that shows 10 data points from a larger array of objects. I hold an offset parameter which starts at 0, in order to show the first ten points of this array.

My procedure:

When I scroll the axis to the left far enough to show the 11th and subsequent data point, I then:

  1. Update the offset parameter to reflect how many units I have translated

  2. Update the x-axis scale, based off the new offset value

  3. Redraw the axis labels with the updated scale's range (x_scale)

  4. Translate the group element containing the axis by the number of pixels that represent one unit on the axis (scroller_element_width)

My attempt works up to step 3. This process appears to be failing at step 4, in that the final translation of the axis never happens.

The entire axis is moved to the left, and it has fresh labels, but it does not move to the right with those updated labels — it basically falls off the page.

I'd like to ask the d3 experts here why this step is failing and what I might do to fix this.

Here is the function that draws the axis and hooks up the zoom event:

  renderScroller() {
    console.log("renderScroller called");
    if ((this.state.scrollerWidth == 0) || (this.state.scrollerHeight == 0)) return;

    const self = this;
    const scroller = this.scrollerContainer;
    const scroller_content = this.scrollerContent;
    const scroller_width = this.state.scrollerWidth;
    const scroller_height = this.state.scrollerHeight; 

    var offset = 0,
        limit = 10,
        current_index = 10;

    var min_translate_x = 0,
        max_translate_x;

    var scroller_data = Constants.test_data.slice(offset, limit);

    var x_extent = d3.extent(scroller_data, function(d) { return d.window; });
    var y_extent = [0, d3.max(scroller_data, function(d) { return d.total; })];

    var x_scale = d3.scaleLinear();
    var y_scale = d3.scaleLinear();

    var x_axis_call = d3.axisTop();

    x_scale.domain(x_extent).range([0, scroller_width]);
    y_scale.domain(y_extent).range([scroller_height, 0]);

    x_axis_call.scale(x_scale);

    d3.select(scroller_content)
      .append("g")
      .attr("class", "x axis")
      .attr("transform", "translate(" + [0, scroller_height] + ")")
      .call(x_axis_call);

    var scroller_element_width = parseFloat(scroller_width / (x_scale.domain()[1] - x_scale.domain()[0]));

    var pan = d3.zoom()
      .on("zoom", function () { 

        var t = parseSvg(d3.select(scroller_content).attr("transform"));
        var x_offset = parseFloat((t.translateX + d3.event.transform.x) / scroller_element_width);

        //
        // lock scale and prevent y-axis pan
        //
        d3.event.transform.y = 0;
        if (d3.event.transform.k == 1) {
          d3.event.transform.x = (x_offset > 0) ? 0 : d3.event.transform.x;
        }
        else {
          d3.event.transform.k = 1;
          d3.event.transform.x = t.translateX;
        }
        d3.select(scroller_content).attr("transform", d3.event.transform);

        t = parseSvg(d3.select(scroller_content).attr("transform"));
        x_offset = parseFloat(t.translateX / scroller_element_width);

        var test_offset = Math.abs(parseInt(x_offset));

        if (test_offset != offset) {
          scroller_data = updateScrollerData(test_offset);
          x_extent = d3.extent(scroller_data, function(d) { return d.window; });
          y_extent = [0, d3.max(scroller_data, function(d) { return d.total; })];
          x_scale.domain(x_extent).range([0, scroller_width]);
          y_scale.domain(y_extent).range([scroller_height, 0]);
          x_axis_call.scale(x_scale);

          //
          // update axis labels
          //
          d3.select(scroller_content)
            .selectAll(".x.axis")
            .call(x_axis_call);

          //
          // shift the axis backwards to simulate an endless horizontal axis
          //  
          var pre_shift = parseSvg(d3.select(scroller_content).attr("transform"));
          console.log("pre_shift", pre_shift.translateX);
          console.log("scroller_element_width", scroller_element_width);
          var expected_post_shift = pre_shift.translateX + scroller_element_width;
          console.log("(expected) post_shift", expected_post_shift);

          d3.zoom().translateBy(d3.select(scroller_content), expected_post_shift, 0);

          //               
          // observed and expected translate values do not match!
          // 
          var post_shift = parseSvg(d3.select(scroller_content).attr("transform"));
          console.log("(observed) post_shift", post_shift.translateX);
        }

      });

    d3.select(scroller).call(pan);

    max_translate_x = this.state.scrollerWidth - x_scale(x_extent[1]);
    d3.zoom().translateBy(d3.select(scroller), max_translate_x, 0);

    // fetch test data
    function updateScrollerData(updated_offset) {
      offset = updated_offset;
      return Constants.test_data.slice(updated_offset - 1, updated_offset + limit - 1);
    }
  }

This is a function within a React component. The React stuff isn't so relevant, but here is the render() function of that component, to show the parent SVG and child group elements:

  render() {
    return (
      <svg 
        className="scroller" 
        ref={(scroller) => { this.scrollerContainer = scroller; }} 
        width={this.state.scrollerWidth} 
        height={this.state.scrollerHeight}>
        <g 
          className="scroller-content"
          ref={(scrollerContent) => { this.scrollerContent = scrollerContent; }} 
        />
      </svg>
    );
  }

As shown, the scrollerContainer ref is the SVG that contains the group element scrollerContent. This scrollerContent is what contains the horizontal axis.

When panning or scrolling the x-axis, transformations are applied to scrollerContent.

To get transformation parameters, I am using the parseSvg helper method from d3-interpolate, i.e. via ES6:

import * as d3 from 'd3';
import { parseSvg } from "d3-interpolate/src/transform/parse";

For completeness, here is a snippet of test data:

export const test_data = [
  {
    "total": 29.86,
    "signal": [
      4.842,
      1.608,
      1.837,
      3.052,
      1.677,
      0.8041,
      3.09,
      1.813,
      2.106,
      2.38,
      1.773,
      0.8128,
      2.047,
      1.658,
      0.3588
    ],
    "window": 0,
    "chr": "chr1"
  },
  {
    "total": 35.67,
    "signal": [
      0.6111,
      1.995,
      0.5715,
      2.51,
      3.318,
      1.523,
      3.94,
      2.743,
      4.445,
      0.759,
      4.938,
      2.61,
      3.379,
      1.27,
      1.057
    ],
    "window": 1,
    "chr": "chr1"
  },
  {
    "total": 39.14,
    "signal": [
      0.0589,
      0.1608,
      2.426,
      4.673,
      3.511,
      3.912,
      2.809,
      4.197,
      4.648,
      2.069,
      2.84,
      3.878,
      0.2681,
      3.622,
      0.06911
    ],
    "window": 2,
    "chr": "chr1"
  },
  {
    "total": 37.45,
    "signal": [
      2.688,
      1.235,
      2.358,
      1.994,
      1.541,
      1.189,
      0.8078,
      4.872,
      2.287,
      4.266,
      2.24,
      3.349,
      3.519,
      1.896,
      3.21
    ],
    "window": 3,
    "chr": "chr1"
  },
  {
    "total": 47.17,
    "signal": [
      3.338,
      3.613,
      3.872,
      1.166,
      1.828,
      4.24,
      1.476,
      4.025,
      4.144,
      4.922,
      2.183,
      2.701,
      3.825,
      4.346,
      1.494
    ],
    "window": 4,
    "chr": "chr1"
  },
  {
    "total": 41.7,
    "signal": [
      0.2787,
      1.74,
      0.7557,
      4.236,
      2.865,
      4.542,
      4.113,
      1.265,
      4.826,
      3.731,
      4.931,
      2.392,
      2.014,
      0.6566,
      3.352
    ],
    "window": 5,
    "chr": "chr1"
  },
  {
    "total": 31.43,
    "signal": [
      3.025,
      4.399,
      1.001,
      4.859,
      0.9173,
      2.851,
      2.916,
      1.821,
      1.228,
      1.646,
      0.1008,
      2.09,
      2.502,
      0.1476,
      1.924
    ],
    "window": 6,
    "chr": "chr1"
  },
  {
    "total": 38.23,
    "signal": [
      1.123,
      1.972,
      0.5079,
      4.808,
      0.5669,
      4.647,
      2.598,
      1.874,
      0.8699,
      4.876,
      3.981,
      1.503,
      4.683,
      2.853,
      1.366
    ],
    "window": 7,
    "chr": "chr1"
  },
  {
    "total": 44.2,
    "signal": [
      3.895,
      0.7457,
      2.208,
      1.837,
      3.219,
      3.98,
      3.494,
      4.225,
      3.117,
      3.162,
      3.171,
      2.449,
      0.1419,
      3.745,
      4.807
    ],
    "window": 8,
    "chr": "chr1"
  },
  {
    "total": 36.33,
    "signal": [
      0.3164,
      2.753,
      4.094,
      2.237,
      4.748,
      2.483,
      1.541,
      4.113,
      0.1874,
      3.71,
      1.313,
      0.221,
      2.736,
      1.208,
      4.671
    ],
    "window": 9,
    "chr": "chr1"
  },
  {
    "total": 43.05,
    "signal": [
      1.924,
      0.4136,
      3.057,
      4.686,
      1.263,
      0.1333,
      0.8786,
      4.715,
      4.845,
      4.282,
      2.112,
      4.597,
      3.822,
      1.322,
      4.999
    ],
    "window": 10,
    "chr": "chr1"
  },
  {
    "total": 31.28,
    "signal": [
      4.216,
      0.6655,
      2.078,
      1.235,
      0.5526,
      1.556,
      1.005,
      3.196,
      1.907,
      4.932,
      0.006601,
      1.269,
      3.964,
      4.608,
      0.09109
    ],
    "window": 11,
    "chr": "chr1"
  },
  {
    "total": 48.3,
    "signal": [
      4.469,
      1.138,
      3.958,
      2.801,
      3.404,
      4.988,
      2.649,
      3.818,
      3.284,
      0.9281,
      3.982,
      0.496,
      4.28,
      3.258,
      4.845
    ],
    "window": 12,
    "chr": "chr1"
  },
  {
    "total": 42.1,
    "signal": [
      1.087,
      3.127,
      0.493,
      3.276,
      4.195,
      1.561,
      2.638,
      4.897,
      3.675,
      4.937,
      0.05847,
      4.272,
      2.33,
      1.776,
      3.776
    ],
    "window": 13,
    "chr": "chr1"
  },
  {
    "total": 40.1,
    "signal": [
      1.275,
      4.574,
      2.805,
      1.646,
      0.8759,
      4.948,
      3.637,
      3.227,
      2.259,
      2.983,
      2.905,
      4.134,
      3.133,
      0.08384,
      1.617
    ],
    "window": 14,
    "chr": "chr1"
  },
  {
    "total": 50.31,
    "signal": [
      2.228,
      0.7037,
      4.977,
      1.143,
      2.506,
      4.348,
      4.344,
      3.998,
      4.213,
      2.745,
      4.374,
      3.411,
      4.504,
      4.417,
      2.396
    ],
    "window": 15,
    "chr": "chr1"
  },
  {
    "total": 34.7,
    "signal": [
      2.729,
      3.891,
      3.873,
      2.973,
      0.1487,
      1.573,
      1.781,
      2.788,
      2.191,
      2.912,
      1.355,
      2.582,
      2.374,
      3.164,
      0.3641
    ],
    "window": 16,
    "chr": "chr1"
  },
  {
    "total": 32.89,
    "signal": [
      3.619,
      2.119,
      1.854,
      4.083,
      0.9916,
      0.5065,
      0.8343,
      4.835,
      1.723,
      3.926,
      2.675,
      2.281,
      0.1531,
      2.239,
      1.049
    ],
    "window": 17,
    "chr": "chr1"
  },
  {
    "total": 38.94,
    "signal": [
      1.976,
      1.587,
      3.808,
      0.1173,
      3.823,
      4.349,
      3.652,
      1.308,
      3.434,
      3.855,
      1.622,
      0.2916,
      2.382,
      3.091,
      3.647
    ],
    "window": 18,
    "chr": "chr1"
  },
  {
    "total": 34.18,
    "signal": [
      0.339,
      3.695,
      3.108,
      3.267,
      0.08282,
      3.53,
      2.316,
      1.11,
      4.504,
      4.111,
      0.007636,
      0.5581,
      2.985,
      1.707,
      2.857
    ],
    "window": 19,
    "chr": "chr1"
  },
  {
    "total": 29.62,
    "signal": [
      2.695,
      0.8477,
      4.417,
      3.012,
      2.454,
      2.686,
      0.6529,
      0.2275,
      1.052,
      0.2092,
      2.968,
      3.268,
      0.7144,
      0.4441,
      3.973
    ],
    "window": 20,
    "chr": "chr1"
  }
];

Hopefully this shows all the work needed to explain the problem. Thanks for any advice or guidance.

like image 847
Alex Reynolds Avatar asked Dec 03 '17 12:12

Alex Reynolds


People also ask

Which is the correct syntax to generate the simplest d3 js axis?

var y_axis = d3. axisLeft() . scale(scale);

What is the syntax to draw a line in d3?

The line generator is then used to make a line. Syntax: d3. line();

What is d3 js axis component?

D3 provides functions to draw axes. An axis is made of Lines, Ticks and Labels. An axis uses a Scale, so each axis will need to be given a scale to work with.


1 Answers

I found your code difficult to follow without a full reproducible example. So I coded up a simple example of what you are trying to do. Perhaps it'll help:

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
  <style>
    .axis path {
      display: none;
    }
    
    .axis line {
      stroke-opacity: 0.3;
      shape-rendering: crispEdges;
    }
    
    .view {
      fill: url(#gradient);
      stroke: #000;
    }
    
    button {
      position: absolute;
      top: 20px;
      left: 20px;
    }
  </style>
</head>

<body>
  <svg width="500" height="500"></svg>
  <script src="//d3js.org/d3.v4.min.js"></script>
  <script>
  
    // 10,000 random data points
    var data = d3.range(1, 10000).map(function(d) {
      return {
        i: d,
        x: Math.random() < 0.5 ? Math.random() * 1000 : Math.random() * -1000,
        y: Math.random() < 0.5 ? Math.random() * 1000 : Math.random() * -1000,
      }
    });

    var svg = d3.select("svg"),
      margin = {
        top: 10,
        right: 10,
        bottom: 10,
        left: 10
      },
      width = +svg.attr("width") - margin.left - margin.right,
      height = +svg.attr("height") - margin.top - margin.bottom,
      g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    // large "endless" zoom
    var zoom = d3.zoom()
      .scaleExtent([-1e100, 1e100])
      .translateExtent([
        [-1e100, -1e100],
        [1e100, 1e100]
      ])
      .on("zoom", zoomed);

    var x = d3.scaleLinear()
      .domain([-100, 100])
      .range([0, width]);

    var y = d3.scaleLinear()
      .domain([-100, 100])
      .range([height, 0]);

    var xAxis = d3.axisBottom(x)
      .ticks((width + 2) / (height + 2) * 10)
      .tickSize(-height);

    var yAxis = d3.axisRight(y)
      .ticks(10)
      .tickSize(width)
      .tickPadding(8 - width);

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

    var gY = svg.append("g")
      .attr("class", "axis axis--y")
      .call(yAxis);

    svg.call(zoom);

    // plot our data initially
    updateData(x, y);

    function zoomed() {
      var t = d3.event.transform,
        sx = t.rescaleX(x), //<-- rescale the scales
        sy = t.rescaleY(x);

      // swap out axis
      gX.call(xAxis.scale(sx));
      gY.call(yAxis.scale(sy));

      updateData(sx, sy)
    }

    // classic enter, update, exit pattern
    function updateData(sx, sy) {

      // filter are data to those points in range
      var f = data.filter(function(d) {
        return (
          d.x > sx.domain()[0] &&
          d.x < sx.domain()[1] &&
          d.y > sy.domain()[0] &&
          d.y < sy.domain()[1]
        )
      });

      var s = g.selectAll(".point")
        .data(f, function(d) {
          return d.i;
        });

      // remove those out of range
      s.exit().remove();

      // add the new ones in range
      s = s.enter()
        .append('circle')
        .attr('class', 'point')
        .attr('r', 10)
        .style('fill', 'steelblue')
        .merge(s);

      // update all in range
      s.attr('cx', function(d) {
          return sx(d.x);
        })
        .attr('cy', function(d) {
          return sy(d.y);
        });
    }
  </script>
</body>

</html>
like image 97
Mark Avatar answered Oct 27 '22 02:10

Mark