Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

d3.js force layout auto zoom/scale after loading

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');
like image 686
DaFrenk Avatar asked Apr 26 '13 12:04

DaFrenk


2 Answers

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
];
like image 173
TWiStErRob Avatar answered Oct 28 '22 20:10

TWiStErRob


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.

like image 1
Evgeniy Tkachenko Avatar answered Oct 28 '22 21:10

Evgeniy Tkachenko