Aenaon
Aenaon

Reputation: 3593

D3.js jumpy zoom behaviour

I am trying to do a zoomable heatmap and the community here on SO have helped massively, however I am now stuck the whole day today trying to fix a glitch and I am hitting the wall every single time.

The issue is that the zoom looks jumpy, ie the plot is rendered fine, however when the zoom event is triggered some kind of transformation happens that changes the axes and the scaling in an abrupt way. The code below demonstrates this issue. The problem does not always happen, it depends on the heatmap dimension and/or the number of the dots.

Some similar cases from people with the same problem here on SO turned out to be that the zoom was not applied to the correct object but I think I am not doing that mistake. Many thanks in advance

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <style>
    .axis text {
      font: 10px sans-serif;
    }
    
    .axis path,
    .axis line {
      fill: none;
      stroke: #000000;
    }
    
    .x.axis path {
      //display: none;
    }
    
    .chart rect {
      fill: steelblue;
    }
    
    .chart text {
      fill: white;
      font: 10px sans-serif;
      text-anchor: end;
    }
    
    #tooltip {
      position: absolute;
      background-color: #2B292E;
      color: white;
      font-family: sans-serif;
      font-size: 15px;
      pointer-events: none;
      /*dont trigger events on the tooltip*/
      padding: 15px 20px 10px 20px;
      text-align: center;
      opacity: 0;
      border-radius: 4px;
    }
  </style>
  <title>Heatmap Chart</title>

  <!-- Reference style.css -->
  <!--    <link rel="stylesheet" type="text/css" href="style.css">-->

  <!-- Reference minified version of D3 -->
  <script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
  <script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
  <script src='heatmap.js' type='text/javascript'></script>
</head>

<body>
  <div id="chart">
    <svg width="550" height="1000"></svg>
  </div>
  <script>
    var dataset = [];
    for (let i = 1; i < 60; i++) { //360
      for (j = 1; j < 70; j++) { //75
        dataset.push({
          xKey: i,
          xLabel: "xMark " + i,
          yKey: j,
          yLabel: "yMark " + j,
          val: Math.random() * 25,

        })
      }
    };

    var svg = d3.select("#chart")
      .select("svg")

    var xLabels = [],
      yLabels = [];
    for (i = 0; i < dataset.length; i++) {
      if (i == 0) {
        xLabels.push(dataset[i].xLabel);
        var j = 0;
        while (dataset[j + 1].xLabel == dataset[j].xLabel) {
          yLabels.push(dataset[j].yLabel);
          j++;
        }
        yLabels.push(dataset[j].yLabel);
      } else {
        if (dataset[i - 1].xLabel == dataset[i].xLabel) {
          //do nothing
        } else {
          xLabels.push(dataset[i].xLabel);
        }
      }
    };



    var margin = {
      top: 0,
      right: 25,
      bottom: 40,
      left: 75
    };

    var width = +svg.attr("width") - margin.left - margin.right,
      height = +svg.attr("height") - margin.top - margin.bottom;

    var dotSpacing = 0,
      dotWidth = width / (2 * (xLabels.length + 1)),
      dotHeight = height / (2 * yLabels.length);

    //    var dotWidth = 1,
    //        dotHeight = 3,
    //        dotSpacing = 0.5;


    var daysRange = d3.extent(dataset, function(d) {
        return d.xKey
      }),
      days = daysRange[1] - daysRange[0];

    var hoursRange = d3.extent(dataset, function(d) {
        return d.yKey
      }),
      hours = hoursRange[1] - hoursRange[0];

    var tRange = d3.extent(dataset, function(d) {
        return d.val
      }),
      tMin = tRange[0],
      tMax = tRange[1];

    //    var width = (dotWidth * 2 + dotSpacing) * days,
    //        height = (dotHeight * 2 + dotSpacing) * hours;
    //    var width = +svg.attr("width") - margin.left - margin.right,
    //        height = +svg.attr("height") - margin.top - margin.bottom;

    var colors = ['#2C7BB6', '#00A6CA', '#00CCBC', '#90EB9D', '#FFFF8C', '#F9D057', '#F29E2E', '#E76818', '#D7191C'];

    // the scale
    var scale = {
      x: d3.scaleLinear()
        .domain([-1, d3.max(dataset, d => d.xKey)])
        .range([-1, width]),
      y: d3.scaleLinear()
        .domain([0, d3.max(dataset, d => d.yKey)])
        .range([height, 0]),
      //.range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]),
    };

    var xBand = d3.scaleBand().domain(xLabels).range([0, width]),
      yBand = d3.scaleBand().domain(yLabels).range([height, 0]);

    var axis = {
      x: d3.axisBottom(scale.x).tickFormat((d, e) => xLabels[d]),
      y: d3.axisLeft(scale.y).tickFormat((d, e) => yLabels[d]),
    };


    function updateScales(data) {
      scale.x.domain([-1, d3.max(data, d => d.xKey)]),
        scale.y.domain([0, d3.max(data, d => d.yKey)])
    }

    var colorScale = d3.scaleQuantile()
      .domain([0, colors.length - 1, d3.max(dataset, function(d) {
        return d.val;
      })])
      .range(colors);



    var zoom = d3.zoom()
      .scaleExtent([dotWidth, dotHeight])
      .on("zoom", zoomed);

    var tooltip = d3.select("body").append("div")
      .attr("id", "tooltip")
      .style("opacity", 0);

    // SVG canvas
    svg = d3.select("svg")
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom)
      //.call(zoom)
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    // Clip path
    svg.append("clipPath")
      .attr("id", "clip")
      .append("rect")
      .attr("width", width)
      .attr("height", height + dotHeight);


    // Heatmap dots
    var heatDotsGroup = svg.append("g")
      .attr("clip-path", "url(#clip)")
      .append("g");

    heatDotsGroup.call(zoom);



    //Create X axis
    var renderXAxis = svg.append("g")
      .attr("class", "x axis")
      .attr("transform", "translate(0," + scale.y(-1) + ")")
      .call(axis.x)

    //Create Y axis
    var renderYAxis = svg.append("g")
      .attr("class", "y axis")
      .call(axis.y);


    function zoomed() {
      d3.event.transform.y = 0;
      d3.event.transform.x = Math.min(d3.event.transform.x, 5);
      d3.event.transform.x = Math.max(d3.event.transform.x, (1 - d3.event.transform.k) * width);
      d3.event.transform.k = Math.max(d3.event.transform.k, 1);
      //console.log(d3.event.transform)

      // update: rescale x axis
      renderXAxis.call(axis.x.scale(d3.event.transform.rescaleX(scale.x)));

      heatDotsGroup.attr("transform", d3.event.transform.toString().replace(/scale\((.*?)\)/, "scale($1, 1)"));
    }

    svg.call(renderPlot, dataset)

    function renderPlot(selection, dataset) {
      //updateScales(dataset);

      heatDotsGroup.selectAll("ellipse")
        .data(dataset)
        .enter()
        .append("ellipse")
        .attr("cx", function(d) {
          return scale.x(d.xKey) - xBand.bandwidth();
        })
        .attr("cy", function(d) {
          return scale.y(d.yKey) + yBand.bandwidth();
        })
        .attr("rx", dotWidth)
        .attr("ry", dotHeight)
        .attr("fill", function(d) {
          return colorScale(d.val);
        })
        .on("mouseover", function(d) {
          $("#tooltip").html("X: " + d.xKey + "<br/>Y: " + d.yKey + "<br/>Value: " + Math.round(d.val * 100) / 100);
          var xpos = d3.event.pageX + 10;
          var ypos = d3.event.pageY + 20;
          $("#tooltip").css("left", xpos + "px").css("top", ypos + "px").animate().css("opacity", 1);
        }).on("mouseout", function() {
          $("#tooltip").animate({
            duration: 500
          }).css("opacity", 0);
        });


    }
  </script>
</body>

</html>

Upvotes: 0

Views: 762

Answers (1)

rioV8
rioV8

Reputation: 28838

Change the zoom scaleExtend

var zoom = d3.zoom()
  .scaleExtent([1, dotHeight])
  .on("zoom", zoomed);

Call the zoom on the whole svg not on the heatDotsGroup because this node receives the tranformation, and also not on the g node that has the graph transformation here variable svg (to keep things a bit obscure)

svg = d3.select("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .call(zoom)
  .append("g")
  .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

// heatDotsGroup.call(zoom);

Don't limit the zoom scale k in the tick. Already taken care of by the scaleExtent()

//   d3.event.transform.k = Math.max(d3.event.transform.k, 1);

Why calculate all the d3.max() when you already have calculated the d3.extent() of these values?

Upvotes: 2

Related Questions