Partially an update to one of my previous blocks, the original intention here was to more clearly convey what is happening during the transitions between shapes by drawing lines from the vertices of the old shape to those of the new shapes.
It uses a kinda-sorta add-on I made called d3-shape-tween, which helps smoother transitions between complex shapes, especially when transitioning between shapes of varying complexity.
I also had some fun experimenting with rotating the shape as if it’s on a turntable…
<!DOCTYPE html>
<meta charset="utf-8">
<head>
<style>
div {
position:absolute
}
input[type=range][orient=vertical] {
writing-mode: bt-lr; /* IE */
-webkit-appearance: slider-vertical; /* WebKit */
width: 5px;
height: 400px;
margin-left: 50px;
margin-top: 50px;
}
span {
margin-left: 75px;
}
polygon {
fill-opacity: 0;
}
</style>
<script src="//d3js.org/d3.v4.min.js"></script>
<script src="shapes_test.js"></script>
<script src="//cdn.rawgit.com/alexmacy/d3-shape-tween/master/shapeTween.js"></script>
</head>
<body style="background-color:#343633">
<div>
<input type="range" id="slider" max=200 value=0 orient="vertical">
</div>
<div>
<h3><span id="sliderVal" style="color:#ecf0f1"></span></h3>
</div>
</body>
<script>
var width = Math.max(960, innerWidth),
height = Math.max(500, innerHeight);
d3.select("#slider")
.on("input", function() {
d3.select("#sliderVal").html("Revolutions per minute: " + this.value)
rotate(this.value)
})
d3.select("#sliderVal").html("Revolutions per minute: " + 0)
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + width/2 + ", " + height/2 + ")rotate(0)scale(1.5)");
var startingShape = shapes[0]
var shape = svg.append("polygon")
.style("stroke", "#d35400")
.attr("points", startingShape)
var wires = svg.append("g").selectAll("line")
.data(startingShape)
.enter().append("line")
.style("stroke", "#2980b9")
.attr("x1", function(d) {return d[0]})
.attr("y1", function(d) {return d[1]})
.attr("x2", function(d) {return d[0]})
.attr("y2", function(d) {return d[1]})
var dots1 = svg.append("g").selectAll("circle")
.data(startingShape)
.enter().append("circle")
.style("fill", "#2980b9")
.attr("r", 1)
.attr("cx", function(d) {return d[0]})
.attr("cy", function(d) {return d[1]})
var dots2 = svg.append("g").selectAll("circle")
.data(startingShape)
.enter().append("circle")
.style("fill", "#d35400")
.attr("r", 1)
.attr("cx", function(d) {return d[0]})
.attr("cy", function(d) {return d[1]})
loop(startingShape, 1)
function loop(passedShape, n) {
var newShape = shapeTweenSides(passedShape, shapes[n], true);
wires.each(function(d, i) {
d3.select(this).transition().duration(2000).ease(d3.easeSinInOut)
.attr("x2", newShape[i][0])
.attr("y2", newShape[i][1])
.transition().duration(2500).ease(d3.easeSinInOut)
.attr("x1", newShape[i][0])
.attr("y1", newShape[i][1])
})
dots1.each(function(d, i) {
d3.select(this)
.transition().duration(2000).ease(d3.easeSinInOut)
.attr("cx", newShape[i][0])
.attr("cy", newShape[i][1])
})
dots2.each(function(d, i) {
d3.select(this)
.transition().delay(2000).duration(2500).ease(d3.easeSinInOut)
.attr("cx", newShape[i][0])
.attr("cy", newShape[i][1])
})
shape.interrupt().transition().delay(2000).duration(2500).ease(d3.easeSinInOut)
.attr("points", newShape)
.on("end", function() {
(n < shapes.length - 1) ? loop(newShape, ++n) : loop(newShape, 0);
});
}
function rotate(rpm) {
if (rpm == 0) {
svg.interrupt()
} else {
var newAngle = svg._groups[0][0].transform.animVal[1].angle + 120;
svg.transition().duration(20000/rpm).ease(d3.easeLinear)
.attr("transform", "translate(" + width/2 + ", " + height/2 + ")rotate(" + newAngle + ")scale(1.5)")
.on("end", function() {rotate(rpm)})
}
}
</script>
//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 = [];
//match the orientation of the shapes
if (d3.polygonArea(shape1) < 0 != d3.polygonArea(shape2) < 0) shape2.reverse();
//make sure fromShape is the longer array
if (shape1.length > shape2.length) {
fromShape = shape1;
toShape = shape2;
} else {
fromShape = shape2;
toShape = shape1;
}
//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 = shape2[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 (shape2[i+1]) {
pointB = shape2[i+1];
} else {
pointB = shape2[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 = shape2[i][0] + (stepX * n),
newY = shape2[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);
}