D3 transition: Fading in and out the colors within a gradient fill

In this D3 diagram, the circles are filled with radial gradients, and changing opacity is used for fading in and fading out:

Animated Screenshot of desired effect

var width = 400,
    height = 400,
    padding = 1.5, // separation between same-color nodes
    clusterPadding = 6, // separation between different-color nodes
    maxRadius = 12;

var n = 200, // total number of nodes
    m = 10; // number of distinct clusters

var color = d3.scale.category10()
    .domain(d3.range(m));

// The largest node for each cluster.
var clusters = new Array(m);

var nodes = d3.range(n).map(function() {
    var i = Math.floor(Math.random() * m),
        r = Math.sqrt((i + 1) / m * -Math.log(Math.random())) * maxRadius,
        d = {cluster: i, radius: r};
    if (!clusters[i] || (r > clusters[i].radius)) clusters[i] = d;
    return d;
});

// Use the pack layout to initialize node positions.
d3.layout.pack()
    .sort(null)
    .size([width, height])
    .children(function(d) { return d.values; })
    .value(function(d) { return d.radius * d.radius; })
    .nodes({values: d3.nest()
        .key(function(d) { return d.cluster; })
        .entries(nodes)
    });

var force = d3.layout.force()
    .nodes(nodes)
    .size([width, height])
    .gravity(.02)
    .charge(0)
    .on("tick", tick)
    .start();

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height);

var grads = svg.append("defs").selectAll("radialGradient")
    .data(nodes)
   .enter()
    .append("radialGradient")
    .attr("gradientUnits", "objectBoundingBox")
    .attr("cx", 0)
    .attr("cy", 0)
    .attr("r", "100%")
    .attr("id", function(d, i) { return "grad" + i; });

grads.append("stop")
    .attr("offset", "0%")
    .style("stop-color", "white");

grads.append("stop")
    .attr("offset", "100%")
    .style("stop-color",  function(d) { return color(d.cluster); });

var node = svg.selectAll("circle")
    .data(nodes)
   .enter()
    .append("circle")
    .style("fill", function(d, i) {
        return "url(#grad" + i + ")";
    })
    // .style("fill", function(d) { return color(d.cluster); })
    .call(force.drag)
    .on("mouseover", fade(.1))
    .on("mouseout", fade(1));

node.transition()
    .duration(750)
    .delay(function(d, i) { return i * 5; })
    .attrTween("r", function(d) {
      var i = d3.interpolate(0, d.radius);
      return function(t) { return d.radius = i(t); };
    });


function fade(opacity) {
    return function(d) {
        node.transition().duration(1000)
            .style("fill-opacity", function(o) {
                return isSameCluster(d, o) ? 1 : opacity;
            })
            .style("stroke-opacity", function(o) {
                return isSameCluster(d, o) ? 1 : opacity;
            });
    };
};

function isSameCluster(a, b) {
     return a.cluster == b.cluster;
};


function tick(e) {
    node
        .each(cluster(10 * e.alpha * e.alpha))
        .each(collide(.5))
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; });
}

// Move d to be adjacent to the cluster node.
function cluster(alpha) {
    return function(d) {
        var cluster = clusters[d.cluster];
        if (cluster === d) return;
        var x = d.x - cluster.x,
            y = d.y - cluster.y,
            l = Math.sqrt(x * x + y * y),
            r = d.radius + cluster.radius;
        if (l != r) {
            l = (l - r) / l * alpha;
            d.x -= x *= l;
            d.y -= y *= l;
            cluster.x += x;
            cluster.y += y;
        }
    };
}

// Resolves collisions between d and all other circles.
function collide(alpha) {
    var quadtree = d3.geom.quadtree(nodes);
    return function(d) {
        var r = d.radius + maxRadius + Math.max(padding, clusterPadding),
            nx1 = d.x - r,
            nx2 = d.x + r,
            ny1 = d.y - r,
            ny2 = d.y + r;
        quadtree.visit(function(quad, x1, y1, x2, y2) {
            if (quad.point && (quad.point !== d)) {
                var x = d.x - quad.point.x,
                    y = d.y - quad.point.y,
                    l = Math.sqrt(x * x + y * y),
                    r = d.radius + quad.point.radius +
                       (d.cluster === quad.point.cluster ? padding : clusterPadding);
                if (l < r) {
                    l = (l - r) / l * alpha;
                    d.x -= x *= l;
                    d.y -= y *= l;
                    quad.point.x += x;
                    quad.point.y += y;
                }
            }
            return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
        });
    };
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

(Same code as a jsfiddle)

How to use color for fading in and fading out, instead of opacity? For example, let say we want to make all circles gray while in "faded out" state", and bring them back to their original color in their "normal state"? You can't just transition the fill property as a color value, because the fill is a URL reference to a <radialGradient> element.

Answers:

Answer

If you were using solid color fills, it would be straightforward to transition them to gray and then back to color -- just use the d3 transition of the fill property instead of the fill-opacity and stroke-opacity properties.

However, the colors in this case aren't actually associated with the elements in your selection. Instead, they are specified within the <stop> elements of the <radialGradient> created for each category. (Actually, they are currently created for each individual circle -- see my note below.) Therefore, you need to select these elements to transition the stop colors.

Because you're transitioning all elements in a given category at the same time, you wouldn't need to create additional gradient elements -- you just need a way to select the gradients associated with those categories, and transition them.

Here's your original code for creating the gradient elements, and referencing them to color the circles:

var grads = svg.append("defs").selectAll("radialGradient")
    .data(nodes)
   .enter()
    .append("radialGradient")
    .attr("gradientUnits", "objectBoundingBox")
    .attr("cx", 0)
    .attr("cy", 0)
    .attr("r", "100%")
    .attr("id", function(d, i) { return "grad" + i; });

grads.append("stop")
    .attr("offset", "0%")
    .style("stop-color", "white");

grads.append("stop")
    .attr("offset", "100%")
    .style("stop-color",  function(d) { return color(d.cluster); });

var node = svg.selectAll("circle")
    .data(nodes)
   .enter()
    .append("circle")
    .style("fill", function(d, i) {
        return "url(#grad" + i + ")";
    })
    .call(force.drag)
    .on("mouseover", fade(.1))
    .on("mouseout", fade(1));

The fade() function you're currently using generates a separate event handler function for each element, which will then select all the circles and transition them to the specified opacity, or to full opacity, according to whether they are in the same cluster as the circle that received the event:

function fade(opacity) {
    return function(d) {
        node.transition().duration(1000)
            .style("fill-opacity", function(o) {
                return isSameCluster(d, o) ? 1 : opacity;
            })
            .style("stroke-opacity", function(o) {
                return isSameCluster(d, o) ? 1 : opacity;
            });
    };
};

function isSameCluster(a, b) {
     return a.cluster == b.cluster;
};

To transition the gradients instead, you need to select the gradients instead of the circles, and check which cluster they are associated with. Since the gradient elements are attached to the same data objects as the nodes, you can reuse the isSameCluster() method. You just need to change the inner function within the fade() method:

function fade(saturation) {
  return function(d) {
    grads.transition().duration(1000)
    .select("stop:last-child") //select the second (colored) stop
    .style("stop-color", function(o) {

      var c = color(o.cluster);
      var hsl = d3.hsl(c);

      return isSameCluster(d, o) ? 
        c : 
        d3.hsl(hsl.h, hsl.s*saturation, hsl.l);
    });
  };
};

Some notes:

  • In order to select the correct stop element within the gradient, I'm using the :last-child pseudoclass. You could also just give the stop elements a normal CSS class when you create them.

  • To desaturate the color by the specified amount, I'm using d3's color functions to convert the color to a HSL (hue-saturation-luminance) value, and then multiply the saturation property. I multiply it, instead of setting it directly, in case any of your starting colors aren't 100% saturated. However, I would recommend using similarly saturated colors to get a consistent effect.

  • In the working example, I also changed your color palette so that you wouldn't have any gray colors to start out with (for the first 10 clusters, anyway). You'll probably need to create a custom palette with similar saturation values for all colors.

  • If you want the final value for the fade-out effect to always be an identical gray gradient, you could probably simplify the code quite a bit -- remove all the hsl calculations, and use a boolean parameter instead of a numerical saturation value. Or even just have two functions, one that resets all the colors, without needing to test for which cluster is which, and one that tests for clusters and sets values to gray accordingly.

Working snippet:

var width = 400,
    height = 400,
    padding = 1.5, // separation between same-color nodes
    clusterPadding = 6, // separation between different-color nodes
    maxRadius = 12;

var n = 200, // total number of nodes
    m = 10; // number of distinct clusters

var color = d3.scale.category20()
    .domain(d3.range(m));

// The largest node for each cluster.
var clusters = new Array(m);

var nodes = d3.range(n).map(function() {
    var i = Math.floor(Math.random() * m),
        r = Math.sqrt((i + 1) / m * -Math.log(Math.random())) * maxRadius,
        d = {cluster: i, radius: r};
    if (!clusters[i] || (r > clusters[i].radius)) clusters[i] = d;
    return d;
});

// Use the pack layout to initialize node positions.
d3.layout.pack()
    .sort(null)
    .size([width, height])
    .children(function(d) { return d.values; })
    .value(function(d) { return d.radius * d.radius; })
    .nodes({values: d3.nest()
        .key(function(d) { return d.cluster; })
        .entries(nodes)
    });

var force = d3.layout.force()
    .nodes(nodes)
    .size([width, height])
    .gravity(.02)
    .charge(0)
    .on("tick", tick)
    .start();

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height);

var grads = svg.append("defs").selectAll("radialGradient")
    .data(nodes)
   .enter()
    .append("radialGradient")
    .attr("gradientUnits", "objectBoundingBox")
    .attr("cx", 0)
    .attr("cy", 0)
    .attr("r", "100%")
    .attr("id", function(d, i) { return "grad" + i; });

grads.append("stop")
    .attr("offset", "0%")
    .style("stop-color", "white");

grads.append("stop")
    .attr("offset", "100%")
    .style("stop-color",  function(d) { return color(d.cluster); });

var node = svg.selectAll("circle")
    .data(nodes)
   .enter()
    .append("circle")
    .style("fill", function(d, i) {
        return "url(#grad" + i + ")";
    })
    // .style("fill", function(d) { return color(d.cluster); })
    .call(force.drag)
    .on("mouseover", fade(.1))
    .on("mouseout", fade(1));

node.transition()
    .duration(750)
    .delay(function(d, i) { return i * 5; })
    .attrTween("r", function(d) {
      var i = d3.interpolate(0, d.radius);
      return function(t) { return d.radius = i(t); };
    });


function fade(saturation) {
  return function(d) {
    grads.transition().duration(1000)
    .select("stop:last-child") //select the second (colored) stop
    .style("stop-color", function(o) {

      var c = color(o.cluster);
      var hsl = d3.hsl(c);

      return isSameCluster(d, o) ? 
        c : 
      d3.hsl(hsl.h, hsl.s*saturation, hsl.l);
    });
  };
};

function isSameCluster(a, b) {
  return a.cluster == b.cluster;
};


function tick(e) {
    node
        .each(cluster(10 * e.alpha * e.alpha))
        .each(collide(.5))
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; });
}

// Move d to be adjacent to the cluster node.
function cluster(alpha) {
    return function(d) {
        var cluster = clusters[d.cluster];
        if (cluster === d) return;
        var x = d.x - cluster.x,
            y = d.y - cluster.y,
            l = Math.sqrt(x * x + y * y),
            r = d.radius + cluster.radius;
        if (l != r) {
            l = (l - r) / l * alpha;
            d.x -= x *= l;
            d.y -= y *= l;
            cluster.x += x;
            cluster.y += y;
        }
    };
}

// Resolves collisions between d and all other circles.
function collide(alpha) {
    var quadtree = d3.geom.quadtree(nodes);
    return function(d) {
        var r = d.radius + maxRadius + Math.max(padding, clusterPadding),
            nx1 = d.x - r,
            nx2 = d.x + r,
            ny1 = d.y - r,
            ny2 = d.y + r;
        quadtree.visit(function(quad, x1, y1, x2, y2) {
            if (quad.point && (quad.point !== d)) {
                var x = d.x - quad.point.x,
                    y = d.y - quad.point.y,
                    l = Math.sqrt(x * x + y * y),
                    r = d.radius + quad.point.radius +
                       (d.cluster === quad.point.cluster ? padding : clusterPadding);
                if (l < r) {
                    l = (l - r) / l * alpha;
                    d.x -= x *= l;
                    d.y -= y *= l;
                    quad.point.x += x;
                    quad.point.y += y;
                }
            }
            return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
        });
    };
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>


Note:

Currently, you're creating a separate <radialGradient> for each circle, when you really only need one gradient per cluster. You could improve the overall performance by using your clusters array as the data for the gradient selection instead of your nodes array. However, you would need to then change the id values for the gradients to be based on the cluster data rather than on the index of the node.


Using filters, as suggested by Robert Longson in the comments, would be another option. However, if you wanted a transition effect, you would still need to select the filter elements and transition their attributes. At least for now. When CSS filter functions are more widely supported, you would be able to directly transition a filter: grayscale(0) to filter: grayscale(1).

Tags

Recent Questions

Top Questions

Home Tags Terms of Service Privacy Policy DMCA Contact Us

©2020 All rights reserved.