22

I'm using this nice force layout from Flowingdata.com to create a network diagram.

enter image description here

enter image description here

enter image description here

My diagram currently shows between 5 and 750 nodes with their relations. It works great with some custom changes to fit my needs. However one thing I can't get to work. I have a viewBox with preserveAspectRatio to auto-fit the container it is in. But depending on the number of nodes there are always some nodes around the edges (mainly top and bottom) that get cut off. And if there are very few nodes, it shows them in the middle with huge empty space around it (it's a big container it's in).

Is there any way to auto-zoom or scale the layout to auto-fit? So that a big layout gets somewhat zoomed out and a small layout zoomed in. I have a zoom event setup so scrolling and panning work like a charm. But can it automatically do that to fit the contents?

The d3.js startup code:

vis = d3.select(selection)
  .append("svg")
  .attr("viewBox", "0 0 " + width + " " + height )
  .attr("preserveAspectRatio", "xMidYMid meet")
  .attr("pointer-events", "all")
  .call(d3.behavior.zoom().scaleExtent([.1, 3])
    .on("zoom", redraw)).append('g');
xxx
  • 1,153
  • 1
  • 11
  • 23
DaFrenk
  • 412
  • 1
  • 3
  • 12

4 Answers4

27

All the other answers to date require access to data, and iterates through it so the complexity is at least O(nodes). I kept looking and found a way that is solely based on already rendered visual size, getBBox() which is hopefully O(1). It doesn't matter what's in it or how it's laid out, just its size and the parent container's size. I managed to whip up this based on http://bl.ocks.org/mbostock/9656675:

var root = // any svg.select(...) that has a single node like a container group by #id

function lapsedZoomFit(ticks, transitionDuration) {
    for (var i = ticks || 100; i > 0; --i) force.tick();
    force.stop();
    zoomFit(transitionDuration);
}

function zoomFit(transitionDuration) {
    var bounds = root.node().getBBox();
    var parent = root.node().parentElement;
    var fullWidth  = parent.clientWidth  || parent.parentNode.clientWidth,
        fullHeight = parent.clientHeight || parent.parentNode.clientHeight;
    var width  = bounds.width,
        height = bounds.height;
    var midX = bounds.x + width / 2,
        midY = bounds.y + height / 2;
    if (width == 0 || height == 0) return; // nothing to fit
    var scale = 0.85 / Math.max(width / fullWidth, height / fullHeight);
    var translate = [
        fullWidth  / 2 - scale * midX,
        fullHeight / 2 - scale * midY
    ];

    console.trace("zoomFit", translate, scale);

    root
        .transition()
        .duration(transitionDuration || 0) // milliseconds
        .call(zoom.translate(translate).scale(scale).event);
}

EDIT: The above works in D3 v3. Zoom is changed in D3 v4 and v5, so you have to make some minor changes to the last portion (the code below console.trace):

var transform = d3.zoomIdentity
  .translate(translate[0], translate[1])
  .scale(scale);

root
  .transition()
  .duration(transitionDuration || 0) // milliseconds
  .call(zoom.transform, transform);

WARNING (untested, but beware): According to Ngo Quang Duong in comments, if your SVG viewBox is not 0 0 width height format, you might need to adjust some variables, but even this might not be sufficient:

var fullWidth  = parent.viewBox.baseVal.width ;
var fullHeight = parent.viewBox.baseVal.height;
var translate = [
    parent.viewBox.baseVal.x + fullWidth  / 2 - scale * midX,
    parent.viewBox.baseVal.y + fullHeight / 2 - scale * midY
];
TWiStErRob
  • 44,762
  • 26
  • 170
  • 254
  • 1
    I don't mean to bother you but I have been trying to use your solution for past 2 days. The problem is that from getBBox() I get the real size of svg g object which is 30x70px instead of what isn't displayed which would be about 2000x1700px – Higeath Jul 08 '16 at 17:10
  • 1
    @Higeath See full example live at http://bl.ocks.org/TWiStErRob/b1c62730e01fe33baa2dea0d0aa29359 – TWiStErRob Jul 09 '16 at 12:03
  • 1
    I know this comment is coming a bit late, but your answer is a total life saver. Thank you @TWiStErRob! – Terry Oct 03 '16 at 15:34
  • @Terry that's the point ;) If it works for you, great! It doesn't matter when you came across this problem this solution is here for you. – TWiStErRob Oct 03 '16 at 22:20
  • 3
    @TWiStErRob On a side note, Firefox has a very annoying behaviour of refusing to return the accurate client dimensions of inline SVGs. [A small modification](http://stackoverflow.com/questions/13122790/how-to-get-svg-element-dimensions-in-firefox) made your script work in Firefox too ;) e.g. `fullWidth = parent.clientWidth || parent.parentNode.clientWidth` instead of just `fullWidth = parent.clientWidth`. – Terry Oct 05 '16 at 13:10
  • FWIW may help someone: (https://jsfiddle.net/joshweir/75h6fuq5/) I based my solution on this great answer but my force layout has many nodes that required skipping ticks for performance reasons, I was able to just use the `zoomFit` function after the force layout had finished rendering. – joshweir Jun 23 '17 at 04:17
  • @TWiStErRob can you explain the algorithm. how does it work – Jabran Saeed Apr 20 '18 at 06:29
  • 1
    @JabranSaeed what's not clear specifically? I can only tell you what's in the code: It advances the force so the things get organized, then based on that it starts a transition to move to the center of the laid out stuff to the center of the viewport and scale it to fit the 85% of the viewport (so there's padding). – TWiStErRob Apr 20 '18 at 07:37
  • if you need this to work in IE 11, replace `var parent = root.node().parentElement;` with `var parent = root.node().parentNode;`. See https://stackoverflow.com/a/36270354/2200690 for the difference between parentElement and parentNode – Suketu Bhuta Dec 06 '18 at 23:11
  • 3
    And to adjust the code for D3.js version 6 just replace the last line with `.attr("transform", transform);` – Matt Sergej Rinc Oct 19 '20 at 21:52
  • Thank you for your solution, it worked for me. However, your solution only works as expected if the `viewBox` attribute of has the value "0 0 width height". I had to adjust the `translate` vector as follow: `var translate = [ parent.viewBox.baseVal.x + fullWidth / 2 - scale * midX, parent.viewBox.baseVal.y + fullHeight / 2 - scale * midY ];` – Ngo Quang Duong Jan 29 '22 at 08:32
  • Thanks @NgoQuangDuong, edited, please have a look and fix is something is not right. – TWiStErRob Jan 29 '22 at 12:23
  • @TWiStErRob Thanks for your quick response. I have just tried the new code by editing your live example. Turn out, adjusting the offset is not sufficient, I had to redefine the two variables `fullWidth` and `fullHeight`: `var fullWidth = parent.viewBox.baseVal.width, fullHeight = parent.viewBox.baseVal.height;`. `fullWidth`, `fullHeight`, and `translate` are the only 3 variables that I changed (together with the viewBox attribute). – Ngo Quang Duong Jan 29 '22 at 13:16
  • I think a little warning to readers is necessary: the clientWidth/clientHeight and width/height in the viewBox attribute of the SVG element are not the same. I believe that my adjustment might not be sufficient in some cases - which I/we have never known. – Ngo Quang Duong Jan 29 '22 at 13:22
1

Your code should be similar to this

vis = d3.select(selection)
  .append("svg")
  .attr("viewBox", "0 0 " + width + " " + height)
  .attr("preserveAspectRatio", "xMidYMid meet")
  .attr("pointer-events", "all")
  .call(zoomListener)
  .on("zoom", redraw);

var mainGroup = vis.append('g');

function zoom() {
  mainGroup.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}

var zoomListener = d3.behavior.zoom().on("zoom", zoom);

var xArray = YOUR_NODES.map(function(d) { //YOUR_NODES is something like json.nodes after forse.end()
  return d.x
});

var minX = d3.min(xArray);
var maxX = d3.max(xArray);

var scaleMin = Math.abs(width / (maxX - minX));
var startX = (minX) / scaleMin;
var startY = 50 / scaleMin;

// Same as in the zoom function
mainGroup.attr("transform", "translate(" + [startX, startY] + ")scale(" + scaleMin + ")");

// Initialization start param of zoomListener
zoomListener.translate([startX, startY]);
zoomListener.scale(scaleMin);
zoomListener.scaleExtent([scaleMin, 1])
vis.call(zoomListener);

This code work only for xAxis. Because "global circle" SVG RX === RY. If it was not for you then you can add the same logic for yAxis var startY. Also, you need to adjust the initial coordinates considering cr of circle nodes.

xxx
  • 1,153
  • 1
  • 11
  • 23
Evgeniy Tkachenko
  • 1,733
  • 1
  • 17
  • 23
1

Accepted answer adapted for D3 v7:

 zoomToFit() {
    var bounds = this.svgInner.node().getBBox();
    var parent = this.svgInner.node().parentElement;
    var fullWidth = parent.clientWidth,
      fullHeight = parent.clientHeight;
    var width = bounds.width,
      height = bounds.height;
    var midX = bounds.x + width / 2,
      midY = bounds.y + height / 2;
    if (width == 0 || height == 0) return; // nothing to fit
    var scale = (.9) / Math.max(width / fullWidth, height / fullHeight);
    var translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY];

    var transform: any = zoomIdentity
      .translate(translate[0], translate[1])
      .scale(scale);

    this.svgInner.attr("transform", transform);
  }
Chris Fremgen
  • 4,649
  • 1
  • 26
  • 26
-1

You can iterate over the nodes; get the max and min x and y values for all nodes, and calculate the needed zoom and translation to cover all your viz within the SVG size.

I have a piece of code that centres the graph to a given node; this might help you get an idea.

zs = zoom.scale()
zt = zoom.translate();
dx = (w/2.0/zs) - d.x;
dy = (h/2.0/zs) - d.y;
zoom.translate([dx, dy]);
zoom.scale(zs);

Where w, h are the width and hieight of my SVG canvas, and d the node I want to centre to.

Instead of centring at d.x,d.y, you should calculate the average x and y. And calculate your zoom-scale that will make the width (and height) of your graph to fit in your SVG's width(and size) Pick the bigger zoom of the two.

Marjancek
  • 238
  • 1
  • 3
  • 4
    Sounds like a good way to start. However, where did you get the zoom object? I tried d3.behavior.zoom and d3.event but the first doesn't have a method scale() and the latter says cannot call method 'scale' of null. – DaFrenk Apr 26 '13 at 14:15