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.
Last active
January 29, 2018 16:06
-
-
Save john-clarke/3d305d8506d33c76917decefde960715 to your computer and use it in GitHub Desktop.
Lifespan events using segmented bézier curves
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
height: 420 | |
license: MIT |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<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: http://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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
�PNG | |