Keeping content centered and allow pan/zoom in d3

Viewed 1307

In my d3.js-based project I want to keep what is centered in the current view to stay centered even if the width or height of the view is changing. At the same time I want to allow the user to interact through panning and zooming.

My idea was to use have nested groups, with the outer group for the centering and the inner one as the d3.zoom target:

<g transform="translate(WIDTH/2, HEIGHT/2)">
  <g transform="translate(ZOOM_X, ZOOM_Y) scale(ZOOM_K)">

    ...

  </g>
</g>

See the following attempt:

var svgWrapper = d3.select("#svgWrapper").node(),
  svg = d3.select("svg"),
  width = svgWrapper.offsetWidth,
  height = svgWrapper.offsetHeight;

var outerG = svg.append("g");
var g = outerG.append("g");

outerG.append("line")
  .attr("stroke", "red")
  .attr("stroke-width", 2)
  .attr("x1", -10)
  .attr("x2", 10)
  .attr("y1", -10)
  .attr("y2", 10);

outerG.append("line")
  .attr("stroke", "red")
  .attr("stroke-width", 2)
  .attr("x1", -10)
  .attr("x2", 10)
  .attr("y1", 10)
  .attr("y2", -10);

for (var i = 1; i <= 3; ++i) {
  g.append("circle")
    .attr("fill", "none")
    .attr("stroke", "black")
    .attr("stroke-width", 1.5)
    .attr("r", i * 5);
}

var zoom = d3.zoom();

svg.call(zoom
  .scaleExtent([1 / 2, 4])
  .on("zoom", zoomed));

g.call(zoom.transform, d3.zoomIdentity.translate(-width / 2, -height / 2));

function zoomed() {
  g.attr("transform", d3.event.transform);
}

// https://developer.mozilla.org/en-US/docs/Web/Events/resize#requestAnimationFrame_customEvent
function throttle(type, name, obj) {
  obj = obj || window;
  var running = false;
  var func = function() {
    if (running) {
      return;
    }
    running = true;
    requestAnimationFrame(function() {
      obj.dispatchEvent(new CustomEvent(name));
      running = false;
    });
  };
  obj.addEventListener(type, func);
}

function resize() {
  width = svgWrapper.offsetWidth;
  height = svgWrapper.offsetHeight;
  zoom.extent([
    [0, 0],
    [width, height]
  ]);
  outerG.attr("transform", "translate(" + (width / 2) + ", " + (height / 2) + ")");
}

resize();

throttle("resize", "optimizedResize");
window.addEventListener("optimizedResize", resize);
html,
body,
#svgWrapper,
svg {
  width: 100%;
  height: 100%;
  overflow: hidden;
}
<div id="svgWrapper">
  <svg></svg>
</div>
<script src="https://d3js.org/d3.v4.min.js"></script>

The red cross represents the viewport center and the circles the content which can be dragged around. Click "Full page" and resize the window to see the resizing in action.

Sadly the first pan as well as zooming towards a certain point seem broken. Putting both translates() into a single transform didn't work either.

Why is that? Is there anything which can be done about it?

Ideas and my failed attempts so far

Single Transform

Turning it into a single transform, i.e. calling zoom.transform on every resize, wouldn't be a good solution in my case for various reasons:

  • It would cause start/zoom/end events to be emitted when the window is being resized and even checking event.sourceEvent there wouldn't help much

  • The pan limits (.translateExtent()) would be relative to the display.

Transforming back inside inner transform

This would hardly have any advantages over a single transform approach and from my limited testing also seems to have issues.

zoom.extent()

I don't understand how this might help me.

1 Answers

Adding a <rect> which catches all the input like in https://bl.ocks.org/mbostock/4e3925cdc804db257a86fdef3a032a45 seems to work but I had to add the rectangle to the outer group and transform it back (otherwise the zoom center would be off):

var svgWrapper = d3.select("#svgWrapper").node(),
  svg = d3.select("svg"),
  width = svgWrapper.offsetWidth,
  height = svgWrapper.offsetHeight;

var outerG = svg.append("g");
var g = outerG.append("g");

outerG.append("line")
  .attr("stroke", "red")
  .attr("stroke-width", 2)
  .attr("x1", -10)
  .attr("x2", 10)
  .attr("y1", -10)
  .attr("y2", 10);

outerG.append("line")
  .attr("stroke", "red")
  .attr("stroke-width", 2)
  .attr("x1", -10)
  .attr("x2", 10)
  .attr("y1", 10)
  .attr("y2", -10);

for (var i = 1; i <= 3; ++i) {
  g.append("circle")
    .attr("fill", "none")
    .attr("stroke", "black")
    .attr("stroke-width", 1.5)
    .attr("r", i * 5);
}

var zoom = d3.zoom();

var rect = outerG.append("rect")
  .attr("x", "-50%")
  .attr("y", "-50%")
  .attr("width", "100%")
  .attr("height", "100%")
  .style("fill", "yellow")
  .style("opacity", ".1")
  .style("pointer-events", "all")
  .call(zoom
    .scaleExtent([1 / 2, 4])
    .on("zoom", zoomed));

function zoomed() {
  g.attr("transform", d3.event.transform);
}

// https://developer.mozilla.org/en-US/docs/Web/Events/resize#requestAnimationFrame_customEvent
function throttle(type, name, obj) {
  obj = obj || window;
  var running = false;
  var func = function() {
    if (running) {
      return;
    }
    running = true;
    requestAnimationFrame(function() {
      obj.dispatchEvent(new CustomEvent(name));
      running = false;
    });
  };
  obj.addEventListener(type, func);
}

function resize() {
  width = svgWrapper.offsetWidth;
  height = svgWrapper.offsetHeight;
  zoom.extent([
    [0, 0],
    [width, height]
  ]);
  outerG.attr("transform", "translate(" + (width / 2) + ", " + (height / 2) + ")");
}

resize();

throttle("resize", "optimizedResize");
window.addEventListener("optimizedResize", resize);
html,
body,
#svgWrapper,
svg {
  width: 100%;
  height: 100%;
  overflow: hidden;
}
<div id="svgWrapper">
  <svg></svg>
</div>
<script src="https://d3js.org/d3.v4.min.js"></script>

This works well for the example but sadly the overlay catches all the events so apparently you can't have clickable/hoverable elements then. Feel free to present alternative solutions.

Because of these reasons I went with the single transform approach after all for my project.

Related