-
-
Save ejb/5de9c86f6a69e90d07b6 to your computer and use it in GitHub Desktop.
JS Bin // source https://jsbin.com/baxoti
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> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>JS Bin</title> | |
<style id="jsbin-css"> | |
.chapter-illo { | |
width: 100%; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="chapter-illo"> | |
<!-- inline SVG --> | |
<svg version="1.1" id="Layer_6" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |
width="840.24px" height="592.8px" viewBox="0 0 840.24 592.8" enable-background="new 0 0 840.24 592.8" xml:space="preserve"> | |
<image xlink:href="http://graphics.wsj.com/libor-unraveling-tom-hayes/img/chapter-illos/chapter1-bg.jpg" x="0" y="0" height="592.8px" width="840.24px" image-rendering="optimizeQuality" /> | |
<path fill="none" stroke="#EB6824" stroke-width="3" stroke-miterlimit="10" d="M815.787,589.919l-20.333-113.186l-59.333-81.333 | |
l-93.333-86.999c0,0-6.334-64.334-13.667-89.667s-18.333-53-25.333-61.333s-23.666-11.999-22.667-14.667s10.667,0.334,19-2.333 | |
s12.334-11.332,11.333-14.333s-15.666-0.667-32.666-6.333s-47-23.333-47-23.333s-20.746-2.063-33.334-4c-13-2-10.834-0.332-14.334,0 | |
s-38.5-17.666-40-16.333s7.167,29.501,5.667,30s-9.334-15.667-17.667-10s-52,39-58.667,43S334.62,200.9,335.12,195.735 | |
s23.667,113.333,23.667,113.333l46-5.666l21-62.333c0,0,17.833-19.499,20-19.667s59.333,26.002,60.667,25.333 | |
s-9.666-25.667-8.333-29s12.332,14.667,17.666,17.667s20.667,11.835,22.667,11s3.054-40.802,5.11-40.568 | |
s39.389,29.903,40.223,28.568s-5.167-14.832-5.333-17s27.5-21.332,25.667-23.333s-15.501,7.834-17.667,8.333 | |
s-30.668-14.665-31.667-13.333s14.999,14.501,15,16.333s-61.666,60.667-61.666,60.667l-1,19.667l-13.334,65c0,0,20,0.667,41,4.667 | |
s31,13.333,31,13.333s4.667,38.667-0.333,42.667s-14.642,4.307-14.333,10.667c0.204,4.201,1.999,10.667,15.333,11 | |
s26.333,13.333,26.333,13.333s3.334,1.334,5.667-2.333s1.001-3,3.667-5s2.999-0.334,4.666-4.667s-0.666-2,1.334-6.333 | |
s5.666-13.334,2.666-18.667s-7-5-7-5l-13.333-49.666l13-17c0,0,1.703-0.846,5.167-4.742c0,0,2.574-0.698,2.637-2.015 | |
s-0.752-4.263-0.376-5.329s2.32-3.512,3.448-4.515s3.385,0.753,4.765,0.439s16.678-20.69,16.678-20.69s1.755,0.126,2.257,1.631 | |
s-0.753,5.643-2.947,8.777s-11.224,12.791-11.224,12.791s-2.381,1.379-4.89,4.2s-12.289,13.669-12.289,13.669 | |
s2.571,5.016,5.267,4.953s3.763-0.689,6.145-3.197s3.135-4.892,5.016-6.082s6.27-2.257,6.27-2.257s1.565-0.251,1.003-3.072 | |
c-0.188-0.94-0.501-1.756-0.501-1.756l-3.512,1.258c0,0-2.319,3.068-3.26,3.068s-1.128-0.376-1.567-0.689s-2.947-0.112-2.383-1.185 | |
s1.943-1.573,3.386-2.326s11.724-13.354,11.724-13.354s2.508-2.258,4.64-1.631s3.01,2.759,3.01,2.759s3.446,22.195,3.259,23.198 | |
s-1.553,13.51-1.553,17.51s1.25,31.25,1.75,34.25s-8.125,41.125-11,46.125s-9.5,16.75-13.5,17.5s-25.125,2.75-25.125,2.75 | |
s-3.25,0.125-4.375,1s-3,3.125-3,3.125s0,2.375-2.625,2.75s-21.5,1.375-21.5,1.375L551.62,473.9l1.875,3.5c0,0,6.375-1,10,0.375 | |
s4.875,4,8.875,4.375s10.625-2.25,12.75-2.5s9.875-2.75,11.875-4.75s11.75-12,13.75-12.5s8.75,6,8.625,7.5 | |
s-4.625,14.625-4.625,14.625s-3.75,12.25-5.5,14.125s-21,9.25-21,9.25s-26.375,8.25-29.875,6.375s-20.25-14-20.25-14 | |
s-17.75-10.75-20.625-14.25s-8.875-9.875-8.875-9.875s-3.375-7.375-6-9.25s-25.375-18.125-25.375-18.125 | |
c-0.25-2-7.5-34-8.875-36.875s-16.875-15.5-18.375-18.75s-4.375-10.5-3.625-12s15.625-10.625,15.625-10.625l15.75-7.875l0.125,2.5 | |
c0,0,2.25,1.375,3.75,1s10.375-6.5,12.25-6.375s2.5,0.625,3.25,1.5s11.125,10.625,11.125,10.625s0.625,2.75,3.125,2.875 | |
s3.375,2.5,4.25,2.875s3.375,0.625,5.5-0.5s7.125-7.375,7.25-8.375s-3,0.5-4.625,1.375s-2.75,0.875-4.5,0.375s-2.375-1.5-4-0.875 | |
s-12.625,5.75-14,5.875s-2-0.25-1.875,1s3.25,1.25,5.75,1.625s3,0.5,7,2.75s6.625,5,8,4.625s7.625-3.25,8.625-3.5s6.5-1,7.125-2.625 | |
s3.25-6.75,1.625-6.625s-6,2-5.125-0.75s3.375-3.625,5.75-3.5s4.5-0.75,4.625-1.5s1.375-1.25-0.375-1.5s-16.625-5.25-18.875-5.125 | |
s-5.75,2.125-9.375,2s-15-1.25-18-3s-65.125-63.875-65.125-63.875s2.624,18.042,1.458,23.875s-8.74,18.168-9.453,23.334 | |
s-5.185,40.507-7.046,47.333c0,0,0.5,0.834,0.25,1.334s-1.333,0.499-2.417,1.666s-0.167,4.666-2.167,5.5s-3.25,1.251-5.25-0.999 | |
s-27-36.917-27-36.917s-2.083-6.833-3.417-9.5s-12.167-8.416-13.5-11.25s-7.083-12.667-6.333-14.417s6-5.833,6-5.833 | |
s4.583-3.251,6.833-2.834s14,5.834,14,5.834s11.583,4.583,13.083,6.916s10.167,13.668,10.833,16.584s26.917,85.416,26.917,85.416 | |
s3.333,9.667,5.833,12.167s34.834,26.5,38.5,28.5s48.667,32.833,51.667,35s6.833,6,6.833,6l-59.166,31.833l-17.667,18 | |
c0,0-7.833-67.166-8.833-70.333S341.787,321.401,341.787,321.401L29.62,477.233"/> | |
</svg> | |
<p>Original illustration by the Gentleman Draughtsman.</p> | |
<p>See this effect in situ in <a href="http://graphics.wsj.com/libor-unraveling-tom-hayes/">The Unraveling of Tom Hayes</a>.</p> | |
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/snap.svg/0.4.1/snap.svg-min.js"></script> | |
<script src="https://code.jquery.com/jquery-2.1.4.js"></script> | |
<script tyle="text/javascript" src="https://rawgit.com/subprotocol/verlet-js/master/js/verlet-1.0.0.min.js"></script> | |
</div> | |
<script id="jsbin-javascript"> | |
/* | |
This is a breakdown of the effect used in 'The Unraveling of Tom Hayes'. | |
See it in situ: http://graphics.wsj.com/libor-unraveling-tom-hayes/ | |
What this does: | |
- Animates dash offset of multiple <path>s within an SVG | |
- Adds a physics-enabled 'thread' which follows dash offset progress | |
Technologies used: | |
- SVG controlled by Snap.svg (http://snapsvg.io) | |
- HTML5 Canvas with Vertlet-JS physics engine (https://github.com/subprotocol/verlet-js) | |
Additional features _not_ included in this code: | |
- Replacing a static JPEG with SVG image using AJAX (progressive enhancement!) | |
- and load SVG background image first, for seamless transition from JPG to SVG | |
- Trigger animation when illustration scrolls into view | |
- Deactivate animation if offscreen (to improve performance) | |
*/ | |
// kick it all off | |
var u = unraveling($('svg')).init(); | |
// this function controls the SVG animation | |
function unraveling($svg){ | |
if (oldFF()){ | |
return; | |
} | |
var snap = Snap($svg[0]); | |
var w = +$svg.attr('width').replace('px',''); | |
var h = w/2; | |
if (snap.node.offsetWidth > w) { | |
w = snap.node.offsetWidth; | |
} | |
var animationLength = 10000; | |
var paths = snap.selectAll('path'); | |
// loop through every path in the SVG | |
// these make up the Hayes portrait | |
var pathData = []; | |
var totalLength = 0; | |
paths.forEach(function(path){ | |
// store data for each path | |
// this will come in handy later | |
var length = path.getTotalLength(); | |
pathData.push({ | |
d: path.attr('d'), | |
length: length | |
}); | |
// set each path's stroke pattern to a single, | |
// full-length line | |
path.attr({ | |
"stroke-dasharray": "0 " + length + " " + length, | |
"stroke-dashoffset": length, | |
"stroke-linecap": "round" | |
}); | |
totalLength += length; | |
// add a 'remainder' path underneath | |
// which will be revealed when unraveled | |
var remainderColor = 'white'; | |
snap.select('image').after( | |
Snap($svg[0]).path(path.attr('d')).attr({ | |
opacity: 0.9, | |
fill: 'none', | |
stroke: remainderColor, | |
'stroke-width': path.attr('stroke-width'), | |
"stroke-linecap": "round" | |
}) | |
); | |
}).attr({ | |
fill: 'none' | |
}); | |
// calculate percentage of unraveling | |
// each path represents | |
for (var i = 0; i < pathData.length; i++) { | |
pathData[i].perc = pathData[i].length/totalLength; | |
} | |
var firstPoint = paths[0].getPointAtLength(0); | |
// this function runs the animation | |
// animOpts takes two properties: | |
// - onMove: callback for moving physics-enabled thread | |
// - onEnd: callback for unpinning thread | |
function animatePath(animOpts){ | |
var onMove = animOpts.onMove; | |
var i = 0; | |
anim(); | |
function anim(){ | |
var path = paths[i]; | |
moveToPoint(path, onMove); | |
path.animate({ | |
// animated dashoffset to give impression | |
// that line is disappearing | |
"stroke-dashoffset": 10 | |
}, (animationLength*pathData[i].perc), function() { | |
// when finished, make line invisible | |
path.attr('opacity', 0); | |
i++; | |
if (paths[i]) { | |
// run animation for next path | |
anim(); | |
} else { | |
// unpin thread for finish | |
// or it'll hang awkwardly | |
animOpts.onEnd(); | |
} | |
}); | |
} | |
} | |
// this controls where the 'loose thread' is pinned to | |
function moveToPoint(path,callback){ | |
Snap.animate(0, totalLength, function(value) { | |
movePoint = path.getPointAtLength(value); | |
if (!isNaN(movePoint.x)) { | |
callback(movePoint.x,movePoint.y); | |
} | |
}, animationLength); | |
} | |
// new instance of the physics-enabled thread | |
var threadMove = Thread({ | |
// use the illustration SVG's actual color | |
color: paths[0].attr('stroke'), | |
svg: $svg, | |
start: { | |
x: paths[0].getPointAtLength(0).x, | |
y: paths[0].getPointAtLength(0).y | |
}, | |
// prevents thread from 'pooling' at the bottom | |
noPooling: false | |
}); | |
return { | |
init: function(){ | |
threadMove.init(); | |
animatePath({ | |
onMove: threadMove.move, | |
onEnd: threadMove.end | |
}); | |
} | |
}; | |
} // end unraveling function | |
// this controls the canvas-based, physics-enabled dangling thread | |
function Thread(opts) { | |
// canvas dimensions | |
var width = +opts.svg.outerWidth(); | |
var height = +opts.svg.outerHeight(); | |
// match coordinate system to our viewBox'd SVG | |
var mod = { | |
x: +opts.svg.eq(0).attr('width').replace('px',''), | |
y: +opts.svg.eq(0).attr('height').replace('px','') | |
}; | |
opts = opts || {}; | |
opts.color = opts.color || 'black'; | |
opts.start = opts.start || {x:width/2,y:height}; | |
// VertletJS uses a regular HTML5 canvas | |
// which in this case goes over the top of the SVG | |
var canvas = document.createElement('canvas'); | |
canvas.style.position = 'absolute'; | |
canvas.style.display = 'inline-block'; | |
canvas.style.textAlign = 'right'; | |
opts.svg.before(canvas); | |
// retina stuff | |
var dpr = window.devicePixelRatio || 1; | |
canvas.width = width*dpr; | |
canvas.height = height*dpr; | |
canvas.getContext("2d").scale(dpr, dpr); | |
canvas.width = width; | |
canvas.height = height; | |
// create a new physics simulation | |
// using VertletJS | |
var sim = new VerletJS(width, height, canvas); | |
sim.friction = 0.95; | |
sim.gravity = new Vec2(0,1.5); | |
opts.start.x = (opts.start.x/mod.x)*width; | |
opts.start.y = (opts.start.y/mod.y)*height; | |
// some useful configuration variables | |
var threadLength = 2; | |
var joinLength = 10; | |
var lineWidth = 2; | |
// reduce complexity if on mobile | |
// to increase performance | |
if ($(window).width() < 992) { | |
joinLength = 15; | |
lineWidth = 1; | |
threadLength = 1; | |
} | |
// create entities - ie, the points of the thread | |
// (which is actually several tiny circles linked together | |
// with straight lines) | |
var stiffness = 6; | |
var vecs = []; | |
for (var i = 0; i < 10; i++) { | |
var xx = opts.start.x; | |
var yy = opts.start.y+(i*joinLength); | |
vecs.push( new Vec2(xx,yy) ); | |
} | |
// combine them into a single line | |
var segment = sim.lineSegments(vecs, stiffness); | |
// pin the end of the thread so it dangles | |
var pin = segment.pin(vecs.length-1); | |
// control how the thread is styled | |
// using the regular canvas API | |
segment.drawConstraints = function(ctx, composite) { | |
ctx.beginPath(); | |
ctx.moveTo(composite.particles[0].pos.x, composite.particles[0].pos.y); | |
for (var i = 1; i < composite.particles.length; i++) { | |
if(composite.particles[i]) { | |
ctx.lineTo(composite.particles[i].pos.x, composite.particles[i].pos.y); | |
} | |
} | |
ctx.lineWidth = lineWidth; | |
ctx.strokeStyle = opts.color; | |
ctx.stroke(); | |
}; | |
segment.drawParticles = function(ctx, composite) { | |
// draw nothing for particles | |
}; | |
// animation loop | |
var loopIndex = 0; | |
var loop = function() { | |
var ctx = canvas.getContext('2d'); | |
ctx.clearRect(0,0,10000,10000); | |
sim.frame(16); | |
sim.draw(); | |
loopIndex++; | |
requestAnimFrame(loop); | |
}; | |
// prevent the thread from getting too long | |
// especially on mobile | |
var limit = 150; | |
if ($(window).width() < 992) { | |
limit = 50; | |
} | |
return { | |
init: loop, | |
move: function(x,y){ | |
// this function is called during the unraveling() function | |
// it moves the pinned particle and increases | |
// the thread length, giving the illusion of unraveling | |
var first = segment.particles[0]; | |
var last = segment.particles[segment.particles.length-1]; | |
// only increase it once every 5 loops, | |
// and only until 150 | |
if ((loopIndex%5 === 0) && (segment.particles.length < 150) && first && first.lastPos) { | |
var pY = first.lastPos.y+2; | |
// make sure it doesn't drop off the bottom | |
// or the thread risks leaping around like a snake | |
if (pY > sim.height+4) { | |
pY = sim.height-1; | |
} | |
// add a new particle on the end | |
// to increase thread length | |
var newParticle = new Particle({ | |
x: first.lastPos.x, | |
y: pY | |
}); | |
segment.particles.unshift(newParticle); | |
// we need to modify the constrainsts between particles | |
var newConstraint = new DistanceConstraint( newParticle , segment.particles[1], 2); | |
segment.constraints.unshift(newConstraint); | |
} | |
// move pinned particle | |
pin.pos.x = (x/mod.x)*width; | |
pin.pos.y = (y/mod.y)*height; | |
}, | |
end: function(){ | |
// optional: make thread drop out the bottom | |
if (opts.noPooling){ | |
sim.bounds = function(){}; | |
} | |
// when finished, remove pinned particle | |
delete segment.constraints[segment.constraints.length-1]; | |
delete segment.particles[segment.particles.length-1]; | |
pin.relax(); | |
} | |
}; | |
} | |
/* Check if Firefox version is below 40, which has an SVG bug */ | |
function oldFF(){ | |
var ffversion = window.navigator.userAgent.split('Firefox/')[1]; | |
if (ffversion && (+ffversion < 40)){ | |
return true; | |
} else { | |
return false; | |
} | |
} | |
</script> | |
<script id="jsbin-source-css" type="text/css">.chapter-illo { | |
width: 100%; | |
> svg { | |
} | |
}</script> | |
<script id="jsbin-source-javascript" type="text/javascript">/* | |
This is a breakdown of the effect used in 'The Unraveling of Tom Hayes'. | |
See it in situ: http://graphics.wsj.com/libor-unraveling-tom-hayes/ | |
What this does: | |
- Animates dash offset of multiple <path>s within an SVG | |
- Adds a physics-enabled 'thread' which follows dash offset progress | |
Technologies used: | |
- SVG controlled by Snap.svg (http://snapsvg.io) | |
- HTML5 Canvas with Vertlet-JS physics engine (https://github.com/subprotocol/verlet-js) | |
Additional features _not_ included in this code: | |
- Replacing a static JPEG with SVG image using AJAX (progressive enhancement!) | |
- and load SVG background image first, for seamless transition from JPG to SVG | |
- Trigger animation when illustration scrolls into view | |
- Deactivate animation if offscreen (to improve performance) | |
*/ | |
// kick it all off | |
var u = unraveling($('svg')).init(); | |
// this function controls the SVG animation | |
function unraveling($svg){ | |
if (oldFF()){ | |
return; | |
} | |
var snap = Snap($svg[0]); | |
var w = +$svg.attr('width').replace('px',''); | |
var h = w/2; | |
if (snap.node.offsetWidth > w) { | |
w = snap.node.offsetWidth; | |
} | |
var animationLength = 10000; | |
var paths = snap.selectAll('path'); | |
// loop through every path in the SVG | |
// these make up the Hayes portrait | |
var pathData = []; | |
var totalLength = 0; | |
paths.forEach(function(path){ | |
// store data for each path | |
// this will come in handy later | |
var length = path.getTotalLength(); | |
pathData.push({ | |
d: path.attr('d'), | |
length: length | |
}); | |
// set each path's stroke pattern to a single, | |
// full-length line | |
path.attr({ | |
"stroke-dasharray": "0 " + length + " " + length, | |
"stroke-dashoffset": length, | |
"stroke-linecap": "round" | |
}); | |
totalLength += length; | |
// add a 'remainder' path underneath | |
// which will be revealed when unraveled | |
var remainderColor = 'white'; | |
snap.select('image').after( | |
Snap($svg[0]).path(path.attr('d')).attr({ | |
opacity: 0.9, | |
fill: 'none', | |
stroke: remainderColor, | |
'stroke-width': path.attr('stroke-width'), | |
"stroke-linecap": "round" | |
}) | |
); | |
}).attr({ | |
fill: 'none' | |
}); | |
// calculate percentage of unraveling | |
// each path represents | |
for (var i = 0; i < pathData.length; i++) { | |
pathData[i].perc = pathData[i].length/totalLength; | |
} | |
var firstPoint = paths[0].getPointAtLength(0); | |
// this function runs the animation | |
// animOpts takes two properties: | |
// - onMove: callback for moving physics-enabled thread | |
// - onEnd: callback for unpinning thread | |
function animatePath(animOpts){ | |
var onMove = animOpts.onMove; | |
var i = 0; | |
anim(); | |
function anim(){ | |
var path = paths[i]; | |
moveToPoint(path, onMove); | |
path.animate({ | |
// animated dashoffset to give impression | |
// that line is disappearing | |
"stroke-dashoffset": 10 | |
}, (animationLength*pathData[i].perc), function() { | |
// when finished, make line invisible | |
path.attr('opacity', 0); | |
i++; | |
if (paths[i]) { | |
// run animation for next path | |
anim(); | |
} else { | |
// unpin thread for finish | |
// or it'll hang awkwardly | |
animOpts.onEnd(); | |
} | |
}); | |
} | |
} | |
// this controls where the 'loose thread' is pinned to | |
function moveToPoint(path,callback){ | |
Snap.animate(0, totalLength, function(value) { | |
movePoint = path.getPointAtLength(value); | |
if (!isNaN(movePoint.x)) { | |
callback(movePoint.x,movePoint.y); | |
} | |
}, animationLength); | |
} | |
// new instance of the physics-enabled thread | |
var threadMove = Thread({ | |
// use the illustration SVG's actual color | |
color: paths[0].attr('stroke'), | |
svg: $svg, | |
start: { | |
x: paths[0].getPointAtLength(0).x, | |
y: paths[0].getPointAtLength(0).y | |
}, | |
// prevents thread from 'pooling' at the bottom | |
noPooling: false | |
}); | |
return { | |
init: function(){ | |
threadMove.init(); | |
animatePath({ | |
onMove: threadMove.move, | |
onEnd: threadMove.end | |
}); | |
} | |
}; | |
} // end unraveling function | |
// this controls the canvas-based, physics-enabled dangling thread | |
function Thread(opts) { | |
// canvas dimensions | |
var width = +opts.svg.outerWidth(); | |
var height = +opts.svg.outerHeight(); | |
// match coordinate system to our viewBox'd SVG | |
var mod = { | |
x: +opts.svg.eq(0).attr('width').replace('px',''), | |
y: +opts.svg.eq(0).attr('height').replace('px','') | |
}; | |
opts = opts || {}; | |
opts.color = opts.color || 'black'; | |
opts.start = opts.start || {x:width/2,y:height}; | |
// VertletJS uses a regular HTML5 canvas | |
// which in this case goes over the top of the SVG | |
var canvas = document.createElement('canvas'); | |
canvas.style.position = 'absolute'; | |
canvas.style.display = 'inline-block'; | |
canvas.style.textAlign = 'right'; | |
opts.svg.before(canvas); | |
// retina stuff | |
var dpr = window.devicePixelRatio || 1; | |
canvas.width = width*dpr; | |
canvas.height = height*dpr; | |
canvas.getContext("2d").scale(dpr, dpr); | |
canvas.width = width; | |
canvas.height = height; | |
// create a new physics simulation | |
// using VertletJS | |
var sim = new VerletJS(width, height, canvas); | |
sim.friction = 0.95; | |
sim.gravity = new Vec2(0,1.5); | |
opts.start.x = (opts.start.x/mod.x)*width; | |
opts.start.y = (opts.start.y/mod.y)*height; | |
// some useful configuration variables | |
var threadLength = 2; | |
var joinLength = 10; | |
var lineWidth = 2; | |
// reduce complexity if on mobile | |
// to increase performance | |
if ($(window).width() < 992) { | |
joinLength = 15; | |
lineWidth = 1; | |
threadLength = 1; | |
} | |
// create entities - ie, the points of the thread | |
// (which is actually several tiny circles linked together | |
// with straight lines) | |
var stiffness = 6; | |
var vecs = []; | |
for (var i = 0; i < 10; i++) { | |
var xx = opts.start.x; | |
var yy = opts.start.y+(i*joinLength); | |
vecs.push( new Vec2(xx,yy) ); | |
} | |
// combine them into a single line | |
var segment = sim.lineSegments(vecs, stiffness); | |
// pin the end of the thread so it dangles | |
var pin = segment.pin(vecs.length-1); | |
// control how the thread is styled | |
// using the regular canvas API | |
segment.drawConstraints = function(ctx, composite) { | |
ctx.beginPath(); | |
ctx.moveTo(composite.particles[0].pos.x, composite.particles[0].pos.y); | |
for (var i = 1; i < composite.particles.length; i++) { | |
if(composite.particles[i]) { | |
ctx.lineTo(composite.particles[i].pos.x, composite.particles[i].pos.y); | |
} | |
} | |
ctx.lineWidth = lineWidth; | |
ctx.strokeStyle = opts.color; | |
ctx.stroke(); | |
}; | |
segment.drawParticles = function(ctx, composite) { | |
// draw nothing for particles | |
}; | |
// animation loop | |
var loopIndex = 0; | |
var loop = function() { | |
var ctx = canvas.getContext('2d'); | |
ctx.clearRect(0,0,10000,10000); | |
sim.frame(16); | |
sim.draw(); | |
loopIndex++; | |
requestAnimFrame(loop); | |
}; | |
// prevent the thread from getting too long | |
// especially on mobile | |
var limit = 150; | |
if ($(window).width() < 992) { | |
limit = 50; | |
} | |
return { | |
init: loop, | |
move: function(x,y){ | |
// this function is called during the unraveling() function | |
// it moves the pinned particle and increases | |
// the thread length, giving the illusion of unraveling | |
var first = segment.particles[0]; | |
var last = segment.particles[segment.particles.length-1]; | |
// only increase it once every 5 loops, | |
// and only until 150 | |
if ((loopIndex%5 === 0) && (segment.particles.length < 150) && first && first.lastPos) { | |
var pY = first.lastPos.y+2; | |
// make sure it doesn't drop off the bottom | |
// or the thread risks leaping around like a snake | |
if (pY > sim.height+4) { | |
pY = sim.height-1; | |
} | |
// add a new particle on the end | |
// to increase thread length | |
var newParticle = new Particle({ | |
x: first.lastPos.x, | |
y: pY | |
}); | |
segment.particles.unshift(newParticle); | |
// we need to modify the constrainsts between particles | |
var newConstraint = new DistanceConstraint( newParticle , segment.particles[1], 2); | |
segment.constraints.unshift(newConstraint); | |
} | |
// move pinned particle | |
pin.pos.x = (x/mod.x)*width; | |
pin.pos.y = (y/mod.y)*height; | |
}, | |
end: function(){ | |
// optional: make thread drop out the bottom | |
if (opts.noPooling){ | |
sim.bounds = function(){}; | |
} | |
// when finished, remove pinned particle | |
delete segment.constraints[segment.constraints.length-1]; | |
delete segment.particles[segment.particles.length-1]; | |
pin.relax(); | |
} | |
}; | |
} | |
/* Check if Firefox version is below 40, which has an SVG bug */ | |
function oldFF(){ | |
var ffversion = window.navigator.userAgent.split('Firefox/')[1]; | |
if (ffversion && (+ffversion < 40)){ | |
return true; | |
} else { | |
return false; | |
} | |
} | |
</script></body> | |
</html> |
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
.chapter-illo { | |
width: 100%; | |
} |
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
/* | |
This is a breakdown of the effect used in 'The Unraveling of Tom Hayes'. | |
See it in situ: http://graphics.wsj.com/libor-unraveling-tom-hayes/ | |
What this does: | |
- Animates dash offset of multiple <path>s within an SVG | |
- Adds a physics-enabled 'thread' which follows dash offset progress | |
Technologies used: | |
- SVG controlled by Snap.svg (http://snapsvg.io) | |
- HTML5 Canvas with Vertlet-JS physics engine (https://github.com/subprotocol/verlet-js) | |
Additional features _not_ included in this code: | |
- Replacing a static JPEG with SVG image using AJAX (progressive enhancement!) | |
- and load SVG background image first, for seamless transition from JPG to SVG | |
- Trigger animation when illustration scrolls into view | |
- Deactivate animation if offscreen (to improve performance) | |
*/ | |
// kick it all off | |
var u = unraveling($('svg')).init(); | |
// this function controls the SVG animation | |
function unraveling($svg){ | |
if (oldFF()){ | |
return; | |
} | |
var snap = Snap($svg[0]); | |
var w = +$svg.attr('width').replace('px',''); | |
var h = w/2; | |
if (snap.node.offsetWidth > w) { | |
w = snap.node.offsetWidth; | |
} | |
var animationLength = 10000; | |
var paths = snap.selectAll('path'); | |
// loop through every path in the SVG | |
// these make up the Hayes portrait | |
var pathData = []; | |
var totalLength = 0; | |
paths.forEach(function(path){ | |
// store data for each path | |
// this will come in handy later | |
var length = path.getTotalLength(); | |
pathData.push({ | |
d: path.attr('d'), | |
length: length | |
}); | |
// set each path's stroke pattern to a single, | |
// full-length line | |
path.attr({ | |
"stroke-dasharray": "0 " + length + " " + length, | |
"stroke-dashoffset": length, | |
"stroke-linecap": "round" | |
}); | |
totalLength += length; | |
// add a 'remainder' path underneath | |
// which will be revealed when unraveled | |
var remainderColor = 'white'; | |
snap.select('image').after( | |
Snap($svg[0]).path(path.attr('d')).attr({ | |
opacity: 0.9, | |
fill: 'none', | |
stroke: remainderColor, | |
'stroke-width': path.attr('stroke-width'), | |
"stroke-linecap": "round" | |
}) | |
); | |
}).attr({ | |
fill: 'none' | |
}); | |
// calculate percentage of unraveling | |
// each path represents | |
for (var i = 0; i < pathData.length; i++) { | |
pathData[i].perc = pathData[i].length/totalLength; | |
} | |
var firstPoint = paths[0].getPointAtLength(0); | |
// this function runs the animation | |
// animOpts takes two properties: | |
// - onMove: callback for moving physics-enabled thread | |
// - onEnd: callback for unpinning thread | |
function animatePath(animOpts){ | |
var onMove = animOpts.onMove; | |
var i = 0; | |
anim(); | |
function anim(){ | |
var path = paths[i]; | |
moveToPoint(path, onMove); | |
path.animate({ | |
// animated dashoffset to give impression | |
// that line is disappearing | |
"stroke-dashoffset": 10 | |
}, (animationLength*pathData[i].perc), function() { | |
// when finished, make line invisible | |
path.attr('opacity', 0); | |
i++; | |
if (paths[i]) { | |
// run animation for next path | |
anim(); | |
} else { | |
// unpin thread for finish | |
// or it'll hang awkwardly | |
animOpts.onEnd(); | |
} | |
}); | |
} | |
} | |
// this controls where the 'loose thread' is pinned to | |
function moveToPoint(path,callback){ | |
Snap.animate(0, totalLength, function(value) { | |
movePoint = path.getPointAtLength(value); | |
if (!isNaN(movePoint.x)) { | |
callback(movePoint.x,movePoint.y); | |
} | |
}, animationLength); | |
} | |
// new instance of the physics-enabled thread | |
var threadMove = Thread({ | |
// use the illustration SVG's actual color | |
color: paths[0].attr('stroke'), | |
svg: $svg, | |
start: { | |
x: paths[0].getPointAtLength(0).x, | |
y: paths[0].getPointAtLength(0).y | |
}, | |
// prevents thread from 'pooling' at the bottom | |
noPooling: false | |
}); | |
return { | |
init: function(){ | |
threadMove.init(); | |
animatePath({ | |
onMove: threadMove.move, | |
onEnd: threadMove.end | |
}); | |
} | |
}; | |
} // end unraveling function | |
// this controls the canvas-based, physics-enabled dangling thread | |
function Thread(opts) { | |
// canvas dimensions | |
var width = +opts.svg.outerWidth(); | |
var height = +opts.svg.outerHeight(); | |
// match coordinate system to our viewBox'd SVG | |
var mod = { | |
x: +opts.svg.eq(0).attr('width').replace('px',''), | |
y: +opts.svg.eq(0).attr('height').replace('px','') | |
}; | |
opts = opts || {}; | |
opts.color = opts.color || 'black'; | |
opts.start = opts.start || {x:width/2,y:height}; | |
// VertletJS uses a regular HTML5 canvas | |
// which in this case goes over the top of the SVG | |
var canvas = document.createElement('canvas'); | |
canvas.style.position = 'absolute'; | |
canvas.style.display = 'inline-block'; | |
canvas.style.textAlign = 'right'; | |
opts.svg.before(canvas); | |
// retina stuff | |
var dpr = window.devicePixelRatio || 1; | |
canvas.width = width*dpr; | |
canvas.height = height*dpr; | |
canvas.getContext("2d").scale(dpr, dpr); | |
canvas.width = width; | |
canvas.height = height; | |
// create a new physics simulation | |
// using VertletJS | |
var sim = new VerletJS(width, height, canvas); | |
sim.friction = 0.95; | |
sim.gravity = new Vec2(0,1.5); | |
opts.start.x = (opts.start.x/mod.x)*width; | |
opts.start.y = (opts.start.y/mod.y)*height; | |
// some useful configuration variables | |
var threadLength = 2; | |
var joinLength = 10; | |
var lineWidth = 2; | |
// reduce complexity if on mobile | |
// to increase performance | |
if ($(window).width() < 992) { | |
joinLength = 15; | |
lineWidth = 1; | |
threadLength = 1; | |
} | |
// create entities - ie, the points of the thread | |
// (which is actually several tiny circles linked together | |
// with straight lines) | |
var stiffness = 6; | |
var vecs = []; | |
for (var i = 0; i < 10; i++) { | |
var xx = opts.start.x; | |
var yy = opts.start.y+(i*joinLength); | |
vecs.push( new Vec2(xx,yy) ); | |
} | |
// combine them into a single line | |
var segment = sim.lineSegments(vecs, stiffness); | |
// pin the end of the thread so it dangles | |
var pin = segment.pin(vecs.length-1); | |
// control how the thread is styled | |
// using the regular canvas API | |
segment.drawConstraints = function(ctx, composite) { | |
ctx.beginPath(); | |
ctx.moveTo(composite.particles[0].pos.x, composite.particles[0].pos.y); | |
for (var i = 1; i < composite.particles.length; i++) { | |
if(composite.particles[i]) { | |
ctx.lineTo(composite.particles[i].pos.x, composite.particles[i].pos.y); | |
} | |
} | |
ctx.lineWidth = lineWidth; | |
ctx.strokeStyle = opts.color; | |
ctx.stroke(); | |
}; | |
segment.drawParticles = function(ctx, composite) { | |
// draw nothing for particles | |
}; | |
// animation loop | |
var loopIndex = 0; | |
var loop = function() { | |
var ctx = canvas.getContext('2d'); | |
ctx.clearRect(0,0,10000,10000); | |
sim.frame(16); | |
sim.draw(); | |
loopIndex++; | |
requestAnimFrame(loop); | |
}; | |
// prevent the thread from getting too long | |
// especially on mobile | |
var limit = 150; | |
if ($(window).width() < 992) { | |
limit = 50; | |
} | |
return { | |
init: loop, | |
move: function(x,y){ | |
// this function is called during the unraveling() function | |
// it moves the pinned particle and increases | |
// the thread length, giving the illusion of unraveling | |
var first = segment.particles[0]; | |
var last = segment.particles[segment.particles.length-1]; | |
// only increase it once every 5 loops, | |
// and only until 150 | |
if ((loopIndex%5 === 0) && (segment.particles.length < 150) && first && first.lastPos) { | |
var pY = first.lastPos.y+2; | |
// make sure it doesn't drop off the bottom | |
// or the thread risks leaping around like a snake | |
if (pY > sim.height+4) { | |
pY = sim.height-1; | |
} | |
// add a new particle on the end | |
// to increase thread length | |
var newParticle = new Particle({ | |
x: first.lastPos.x, | |
y: pY | |
}); | |
segment.particles.unshift(newParticle); | |
// we need to modify the constrainsts between particles | |
var newConstraint = new DistanceConstraint( newParticle , segment.particles[1], 2); | |
segment.constraints.unshift(newConstraint); | |
} | |
// move pinned particle | |
pin.pos.x = (x/mod.x)*width; | |
pin.pos.y = (y/mod.y)*height; | |
}, | |
end: function(){ | |
// optional: make thread drop out the bottom | |
if (opts.noPooling){ | |
sim.bounds = function(){}; | |
} | |
// when finished, remove pinned particle | |
delete segment.constraints[segment.constraints.length-1]; | |
delete segment.particles[segment.particles.length-1]; | |
pin.relax(); | |
} | |
}; | |
} | |
/* Check if Firefox version is below 40, which has an SVG bug */ | |
function oldFF(){ | |
var ffversion = window.navigator.userAgent.split('Firefox/')[1]; | |
if (ffversion && (+ffversion < 40)){ | |
return true; | |
} else { | |
return false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment