This block demonstrates an alternative styling approach to gradient stops and clipping-paths for parametric bézier curves. Given an initial bézier curve, we splice it into segments rather than generating gradient stops or clipping-paths to style the segments differently. The main advantage of this approach is that it reduces the number of elements required for each conceptual curve and simplify the representation in the DOM -- especially when anticipating large numbers of curves and a non-uniform segmentation. Thanks to Tim Hall for code review, and to Jason Davies for his work on animated béziers. Lifespan design concept inspired by Periscopic's work on U.S. gun deaths. Related: Aaron Bycoffe's block on how to Split an SVG path into pieces.
xxxxxxxxxx
<style>
#chart {background-color: white;}
.x-axis line, .x-axis path {stroke: #999;}
.x-axis text {fill: #999;}
</style>
<html>
<body>
<div id="chart">
<svg></svg>
</div>
</body>
</html>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://unpkg.com/seedrandom@2.4.3/seedrandom.min.js"></script>
<script language="javascript" type="text/javascript">
Math.seedrandom('a13219765bc7c488a3a47') // Reproduce the same results for testing
r = d3.randomUniform(0.1, 1);
const people = 100;
const max_cuts = 5;
const total_delay = 1500;
const delay_between_segments = 10;
const margin = {
top: 10,
right: 10,
bottom: 30,
left: 10
};
const width = 900;
const height = 380;
// svg container
var svg = d3.select('#chart svg')
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
// Create scales
const xScale = d3.scaleLinear()
.domain([0, 100])
.range([20, width]);
const yScale = d3.scaleLinear()
.domain([0, 100])
.range([height, 0]);
const xAxis = d3.axisBottom(xScale)
.ticks(10, ",f")
.tickPadding(10);
svg.append("g")
.attr("class", "x-axis")
.attr("transform", "translate(0," + (height) + ")")
.call(xAxis);
const colors =["#004529", "#006837", "#238443", "#41ab5d", "#78c679", "#addd8e", "#d9f0a3", "#f7fcb9", "#ffffe5"];
var curveData = [];
for (var i = 0; i < people; i++) {
var age_at_death = d3.randomUniform(0, 100)();
// make height of curve is proportional to age_at_death
var curve_height = d3.randomUniform(0.1, 2*age_at_death)();
// create quadratic bézier (just one control point) for full lifespan curve
var fullq = [
{x: 0, y: 0}, // start point
{x: age_at_death/2, y: curve_height}, // control point
{x: age_at_death, y: 0} // end point
];
// Convert to quadratic bezier (1 control points) to cubic (2 control points)
var full = quadraticToCubic(fullq);
// Split the path into multiple segments with an event between each segment
// Generate a random number of events (cuts) at random spots on each curve
const parts = splitCurveMultiple(full, generate_random_cuts());
var cumulative_proportion = 0;
const segments = parts.map((points, index) => {
// Calculate proportion of segment length relative to total length
// We use that to determine the transition duration/delays later
const proportion = (points[points.length - 1].x - points[0].x)/age_at_death
cumulative_proportion += proportion;
return {
points,
proportion: proportion,
cumulative_proportion: cumulative_proportion-proportion,
// can specify what happens at the start or end of a segment
start: null,
end: dropBall(d => d.points[d.points.length - 1])}
});
curveData.push({
full,
segments,
color: colors[i%colors.length],
age_at_death
})
}
const curves = svg.append("g").attr('class', 'curves').selectAll(".curve")
.data(curveData);
const entering = curves
.enter()
.append('g');
entering.transition()
.duration(total_delay)
.delay((d, i) => i * total_delay)
.attr('class', 'person')
.on('start', drawSegment);
function drawSegment(d, i) {
const group = d3.select(this);
const segments = d.segments;
const duration = total_delay / segments.length - delay_between_segments;
group.selectAll('.segment')
.data(segments)
.enter()
.append('path')
.attr("class", "curve")
.style("stroke", (d, i) => colors[i%colors.length])
.style("stroke-opacity", 0.90)
.style("stroke-width", (d, i) => d3.randomUniform(1, 10)() + 'px')
.attr("stroke-dasharray", "0 600")
.style('fill', "none")
.attr("d", d => cubicBezier(d.points))
.transition()
.duration(function(d, i) {
l = this.getTotalLength();
return total_delay * d.proportion;
})
.delay(function(d, i) {
l = this.getTotalLength();
return (i * delay_between_segments) + (d.cumulative_proportion * total_delay);
})
.ease(d3.easeLinear)
.attrTween("stroke-dasharray", function() {
l = this.getTotalLength();
return d3.interpolateString("0," + l, l + "," + l);
})
.on('start', function(d, i) {
d3.active(this)
// Adding the linecaps after, otherwise they appear before the curve is drawn
.attr('stroke-linecap', 'round')
// Then fade old curves to help visualize new curves
.transition()
.duration(3000)
.style("stroke-opacity", 0.1)
if (d.start) d.start.call(this, d, i);
})
.on('end', function(d, i) {
if (d.end) d.end.call(this, d, i);
});
}
function dropBall(getPosition) {
return function(d, i) {
const group = d3.select(this.parentNode);
const position = getPosition.call(this, d, i);
group.append('circle')
.attr("cx", xScale(position.x))
.attr("cy", yScale(position.y))
.attr("r", 3)
.attr('stroke', 'transparent')
.attr('fill', 'transparent')
.transition()
.duration(delay_between_segments)
.attr('stroke', "orange")
.attr('fill', 'orange')
.transition()
.duration(100)
.attr('r', 8)
.transition()
.duration(300)
.attr('r', 3)
.transition()
.duration(500)
.attr("cy", yScale(0))
.transition()
.duration(200)
.style("opacity", 0.0)
}
}
// Utilities
// ---------
function generate_random_cuts() {
// generates a random number of cuts at random points between [0,1]
var cuts = [];
for (var j=0; j<d3.randomUniform(1, max_cuts)(); j++) {
cuts.push(r());
}
return cuts.sort();
};
function quadraticToCubic(pts) {
// Converts a quadratic bezier curve (with 1 control point) to the equivalent cubic (with 2 control)
// Reference: https://fontforge.github.io/bezier.html
return [
pts[0],
{x: pts[0].x + (2/3)*(pts[1].x-pts[0].x),
y: pts[0].y + (2/3)*(pts[1].y-pts[0].y)},
{x: pts[2].x + (2/3)*(pts[1].x-pts[2].x),
y: pts[2].y + (2/3)*(pts[1].y-pts[2].y)},
pts[2]
]
}
function cubicBezier(p) {
// generates a cubic SVG path (2 control points)
return "M " + xScale(p[0].x) + "," + yScale(p[0].y)
+ " C " + xScale(p[1].x) +"," + yScale(p[1].y)
+ " " + xScale(p[2].x) + "," + yScale(p[2].y)
+ " " + xScale(p[3].x) + "," + yScale(p[3].y)
}
function splitCurveMultiple(pts, ts) {
// Takes a bezier curve (pts) and an array of cuts (ts)
// Returns an array of bezier segements
let remaining = pts;
let currentT = 0;
const segments = [];
for (const t of ts) {
// Find t relative to remaining segment
const relativeT = (t - currentT) / (1 - currentT);
const parts = splitCurve(remaining, relativeT);
segments.push(parts.first);
remaining = parts.last;
currentT = t;
}
// Add last remaining segment
segments.push(remaining);
return segments;
}
function splitCurve(pts, t) {
// Splits a cubic Bezier curve into two segments at t
// Source: https://stackoverflow.com/a/26831216
function lerp(a, b, t) {
var s = 1 - t;
return {x:a.x*s + b.x*t,
y:a.y*s + b.y*t};
}
var p0 = pts[0], p1 = pts[1], p2 = pts[2], p3 = pts[3];
var p4 = lerp(p0, p1, t);
var p5 = lerp(p1, p2, t);
var p6 = lerp(p2, p3, t);
var p7 = lerp(p4, p5, t);
var p8 = lerp(p5, p6, t);
var p9 = lerp(p7, p8, t);
return {first: [p0, p4, p7, p9],
last: [p9, p8, p6, p3]}
}
</script>
https://d3js.org/d3.v4.min.js
https://unpkg.com/seedrandom@2.4.3/seedrandom.min.js