block by micahstubbs 275b480d6e9c860c0d0f15a9accc57ca

United States of Voronoi Tweening

Full Screen

a small iteration on @alexmacy‘s United States of Voronoi Tweening that transitions the colors of the pologons, polygon borders, and state-capitol points

playing with ways to highlight the differences between the geographic shape and the Voronoi shape of each US state


Original README.md


This is another version of the shape tweening from this block. Inspired by Jason Davies’ United States of Voronoi.

The transition I had previously used for shape tweening plots the points along the new shape proportionally to where they were along the original shape. This potentially results in rounded or skipped corners, so I had to figure out a new way to do it.

The new transition used here draws the destination shapes by plotting the points form the old shape equally along each side of the new shape. The result isn’t always as clean of a transition, but it’s still pretty smooth and the resulting shape is free from any rounded or skipped corners.

I also put both transition functions in the standalone file shapeTween.js to be able to easily reuse it later.

index.html

<!DOCTYPE html>
<meta charset="utf-8">
<head>
    <style>
        polygon {
          fill: #ccc;
          stroke: black;
        }
    </style>
    <script src="//d3js.org/d3.v4.min.js"></script>
    <script src="states.js"></script>
    <script src="shapeTween.js"></script>
</head>
<body>
</body>
<script>

    var width = 960,
        height = 500,
        interval = 2000;

    var projection = d3.geoMercator()
        .rotate([100, 0])
        .center([5, 37.7])
        .scale(800);

    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height);
    
    var g = svg.append("g")
        .attr("clip-path", "url(#myClip)")

    var voronoi = d3.voronoi().size([width, height]),
        voronoiData = voronoi(stateData.map(function(d) {return projection(d.capital);})).polygons();

    stateData.forEach(function(d, i) {
        d.vorShape = []
        for (n=0; n<voronoiData[i].length; n++) {d.vorShape.push(voronoiData[i][n])}
        d.projected = d.geometry.coordinates[0].map(projection) 
    })

    var clip = svg.append("clipPath")
        .attr("id", "myClip")
        .selectAll("path")
        .data(stateData)
        .enter().append("path")
            .attr("d", function(d) {return "M" + d.projected});    

    var states = g.selectAll("g")
        .data(stateData)
      .enter().append("g")
        .attr("id", function(d) {return d.name})
        
    states.each(function(p) {
        
        d3.select(this).append("polygon")
            .attr("points", p.projected);

        d3.select(this).append("circle")
            .attr("cx", projection(p.capital)[0])
            .attr("cy", projection(p.capital)[1])
            .attr("r", 2);
    })

    d3.timeout(function() {loop()}, interval);

    function loop() {
        const ihsl = d3.interpolateHslLong('#ccc', 'green');
        states.each(function(p) {
            d3.select(this).select("polygon")
                .transition().duration(interval)
                .attr("points", shapeTweenSides(p.projected, p.vorShape, true))
                .style('fill', ihsl(.25))
                .style('stroke', 'white')
                .style('stroke-width', '2px')
                .transition().delay(interval).duration(interval)
                .attr("points", p.projected)
                .style('fill', ihsl(0))
                .style('stroke', 'black')
                .style('stroke-width', '1px');

            d3.select(this).select('circle')
                .transition().duration(interval)
                .style('fill', 'white')
                .transition().delay(interval).duration(interval)
                .style('fill', 'black');
        })

        d3.timeout(function() {loop()}, interval * 4);
    }
</script>

shapeTween.js

//this distributes the points based on 'sides' of the shorter path
//this results in a more accurate final shape, but the transition is often not as clean
function shapeTweenSides(shape1, shape2, findStart) {

    var fromShape = [], toShape = [], newShape = [];

    //make sure fromShape is the longer array
    if (shape1.length > shape2.length) {
        fromShape = shape1;
        toShape = shape2;
    } else {
        fromShape = shape2;
        toShape = shape1;            
    }

    //make sure the orientation of the shapes match
    if (d3.polygonArea(fromShape) < 0 != d3.polygonArea(toShape) < 0) toShape.reverse();

    //calculate how many sides on toShape and how many points per side in order to have a matching number of points
    var sides = toShape.length;
    var stepsPerSide = Math.floor(fromShape.length/sides);

    //cycle through each side, adding points along that side's path
    for (i=0; i<sides; i++) {
        var pointA = toShape[i];
        var pointB;

        //if it's the last side, change the step count to use the rest of the points needed to match lengths
        if (toShape[i+1]) {
            pointB = toShape[i+1];
        } else {
            pointB = toShape[0];
            stepsPerSide = fromShape.length - newShape.length;
        }
        
        var stepX = (pointB[0] - pointA[0])/stepsPerSide,
            stepY = (pointB[1] - pointA[1])/stepsPerSide;

        for (n=0; n<stepsPerSide;n++) {
            var newX = toShape[i][0] + (stepX * n),
                newY = toShape[i][1] + (stepY * n);
            newShape.push([newX, newY])
        }
    }
    return findStart ? findStartingPoint(fromShape, newShape) : newShape;
}

//this is often much smoother, but can result in skipped or rounded corners
//it also requires creating a hidden path element in order to use getPointAtLength to plot the points 
function shapeTweenLength(shape1, shape2, findStart){

    var newShape = findStart ? findStartingPoint(shape1, shape2) : shape2;

    var distances = getDistances(shape1),
        totalLength = d3.max(getDistances(newShape)),
        coordsScale = d3.scaleLinear().range([0,1]).domain([0,d3.max(distances)])
    
    var hiddenShape;

    if (!document.getElementById("hiddenShape")) {
        hiddenShape = d3.select('svg').append("path").attr("id", "hiddenShape");
    } else {
        hiddenShape = d3.select('svg').select("#hiddenShape");
    }

    hiddenShape.datum(newShape)
        .attr("d", d3.line())
        .style("visibility", "hidden");
        
    for (i in shape1) { 
        var newPoint = document.getElementById("hiddenShape")
            .getPointAtLength(coordsScale(distances[i]) * totalLength);
        newShape[i] = [newPoint.x, newPoint.y];
    }

    //check the rotational direction by calculating the polygon's area. reverse the array of points if needed.
    return d3.polygonArea(newShape) < 0 ? newShape : newShape.reverse();

    //get distances along the perimeter for plotting the points proportionally
    function getDistances(coordsArray) {
        var distances = [0];
        for (i=1; i<coordsArray.length; i++) {
            distances[i] = distances[i-1] + calcDistance(coordsArray[i-1], coordsArray[i]);
        }
        return distances;
    }  
}

//optional function to match the starting point for both shapes
function findStartingPoint(fromCoords, toCoords) {      

    var closestDist = calcDistance(fromCoords[0], toCoords[0]),
        closestPoints = {},
        tempArrayFrom = [],
        tempArrayTo = [];

    for (n=0; n<toCoords.length; n++) {
        var thisDist = calcDistance(fromCoords[0], toCoords[n]);
        if (thisDist < closestDist) {
            closestDist = thisDist;
            closestPoints = {"from":0, "to":n};
        }
    }

    for (i=0; i<toCoords.length; i++) {
        tempArrayTo.push(toCoords[i]);
    }
        
    return tempArrayTo.splice(closestPoints.to).concat(tempArrayTo)
}

//convenience function for calculating distance between two points
function calcDistance(coord1, coord2) {
    var distX = coord2[0] - coord1[0];
    var distY = coord2[1] - coord1[1];
    return Math.sqrt(distX * distX + distY * distY);
}