|
<!DOCTYPE html> |
|
<head> |
|
<meta charset="utf-8"> |
|
<title>Scrolytelling the Penrose triangle</title> |
|
<meta name="description" content="Step by step animated explanations on how to draw a Penrose triangle (and some derivatives), using scrolytelling with D3.js"> |
|
<script src="https://d3js.org/d3.v5.min.js"></script> |
|
<style> |
|
body { |
|
margin:0;position:fixed;top:0;right:0;bottom:0;left:0; } |
|
|
|
#wip { |
|
#display: none; |
|
position: absolute; |
|
top: 400px; |
|
left: 330px; |
|
font-size: 40px; |
|
text-align: center; |
|
} |
|
|
|
#container { |
|
position: relative; |
|
z-index: 100; |
|
} |
|
#sticky { |
|
position: absolute; |
|
top: 0; |
|
right: 0; |
|
width: 50%; |
|
height: 100%; |
|
z-index: 50; |
|
} |
|
#story-container { |
|
height: 100vh; |
|
overflow-y: scroll; |
|
} |
|
|
|
.panel { |
|
height: 100vh; |
|
position: relative; |
|
#border-top: dotted 1px; |
|
} |
|
.panel:first-child { |
|
#height: 50vh; |
|
} |
|
.panel:last-child { |
|
margin-bottom: 50vh; |
|
} |
|
|
|
.vertical-positioner { |
|
position: absolute; |
|
width:50%; |
|
} |
|
.vertical-positioner.center { |
|
top: 35%; |
|
} |
|
.vertical-positioner.top { |
|
top: 5%; |
|
} |
|
.vertical-positioner.bottom { |
|
bottom: 5%; |
|
} |
|
|
|
.panel p { |
|
padding-left: 20px; |
|
padding-right: 20px; |
|
} |
|
|
|
.l-shape { |
|
fill: transparent; |
|
stroke: grey; |
|
stroke-width: 2px; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
<div id="sticky"> |
|
</div> |
|
|
|
<div id="story-container"> |
|
<div id="story"> |
|
<div class="panel"> |
|
<div class="vertical-positioner center"> |
|
<p>This <a href="http://bl.ocks.org/Kcnarf/476fe66949490c53f7085c24832612ca">block</a> illustrates how to draw a Penrose Triangle, one of the most famous impossible geometry.</p> |
|
<p><em>Let's get scrolling!</em></p> |
|
</div> |
|
</div> |
|
<div class="panel"> |
|
<div class="vertical-positioner bottom"> |
|
<p>1- Draw a triangle</p> |
|
<p><em>It will be the inner part of the Penrose Triangle.</em></p> |
|
</div> |
|
</div> |
|
<div class="panel"> |
|
<div class="vertical-positioner bottom"> |
|
<p>2- Extend a line off of each corner.</p> |
|
</div> |
|
</div> |
|
<div class="panel"> |
|
<div class="vertical-positioner bottom"> |
|
<p>3- Draw another line off each of thoose extensions that extends a bit over the corners.</p> |
|
<p><em>'a bit' means 'the same way as in the previous step'.</em></p> |
|
</div> |
|
</div> |
|
<div class="panel"> |
|
<div class="vertical-positioner bottom"> |
|
<p>4- Draw a short angle that lines up with the opposite side.</p> |
|
<p><em>The size should be the same as in the previous steps.</em></p> |
|
</div> |
|
</div> |
|
<div class="panel"> |
|
<div class="vertical-positioner bottom"> |
|
<p>5- Connect the lines, and you'll get the Penrose Triangle.</p> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div id="wip"> |
|
Work in progress ... |
|
</div> |
|
|
|
<script> |
|
//begin: layout conf |
|
const windowWidth = window.innerWidth, |
|
panelHeight = window.innerHeight, |
|
svgWidth = 960/2, |
|
svgHeight = 500, |
|
margins = {top: 10, right: 10, bottom:10, left:10}, |
|
width = svgWidth - margins.top - margins.bottom, |
|
height = svgHeight - margins.left - margins.right; |
|
//end: layout conf |
|
|
|
//begin: utils |
|
const cos60 = Math.cos(Math.PI/3); |
|
const sin60 = Math.sin(-Math.PI/3); //minus becase svg y axe goes to bottom |
|
const orthoY = function(obliqueY) { |
|
return [obliqueY*cos60, obliqueY*sin60]; |
|
} |
|
const orthoCoord = function(obliqueCoord){ |
|
const ortho = orthoY(obliqueCoord[1]); |
|
return [obliqueCoord[0]+ortho[0], ortho[1]]; |
|
} |
|
const orthoCoords = function(obliqueCoords) { |
|
return obliqueCoords.map(function(obliqueCoord) { |
|
return orthoCoord(obliqueCoord); |
|
}) |
|
} |
|
const liner = function (d) { |
|
let path = "M"+d.orthoStart; |
|
d.orthoDeltas.forEach(function(orthoDelta) { |
|
path += "l"+orthoDelta; |
|
}) |
|
//path += "z"; |
|
return path; |
|
} |
|
//end: utils |
|
|
|
//begin: data conf |
|
let ptInnerWidth, ptExtent1, ptExtent2; |
|
const defaultIW = 50, |
|
defaultExt1 = defaultIW, |
|
defaultExt2 = defaultIW; |
|
const pTData = computePTData(defaultIW, defaultExt1, defaultExt2); |
|
//end: data conf |
|
|
|
//begin: reusable d3 selections |
|
let svg, drawingArea, penroseTriangle; |
|
const storyContainer = d3.select('#story-container'), |
|
story = d3.select('#story'); |
|
//end reusable d3 selections |
|
|
|
//begin: scroll managment |
|
var scrollTop = 0 |
|
var newScrollTop = 0 |
|
const storyLength = story.node().getBoundingClientRect().height; |
|
const scrollLength = storyLength - panelHeight; |
|
storyContainer.on("scroll.scroller", function() { |
|
newScrollTop = storyContainer.node().scrollTop; |
|
}); |
|
//end: scroll management |
|
|
|
initData(); |
|
initLayout(); |
|
|
|
//begin: first animation (slide to top) utils |
|
const anim0Start = 0, |
|
anim0Length = panelHeight, |
|
anim0End = anim0Start + anim0Length; |
|
//end: first animation (slide to top) utils |
|
|
|
//begin: second animation (Penrose Triangle construction) utils |
|
//2nd animation uses the strokDashoffset trick to make paths appearing as they were hand drawn |
|
const anim1Start = anim0End, |
|
anim1Length = 5.5*panelHeight, |
|
anim1End = anim1Start + anim1Length; |
|
//lShape = 1 part of the Penrose triangle |
|
let lShapeLength = penroseTriangle.select(".l-shape").node().getTotalLength(); |
|
const strokeDashoffsetScale = computeStrokeDashoffsetScale(); |
|
//end: second animation (Penrose Triangle construction) utils |
|
|
|
//begin: third animation (changing inner triangle) utils |
|
const anim2Start = anim1End, |
|
anim2Length = panelHeight, |
|
anim2End = anim2Start + anim2Length; |
|
//end: third animation (changing inner triangle) utils |
|
|
|
|
|
//begin: story-telling management |
|
const render = function() { |
|
if (scrollTop !== newScrollTop) { |
|
scrollTop = newScrollTop; |
|
|
|
if (scrollTop < anim0End) { |
|
// 1st animation consist on sliding SVG to the top until it disappears completly |
|
let vPos = height/2 - scrollTop; |
|
penroseTriangle.attr("transform", "translate("+[width/2, vPos]+")"); |
|
penroseTriangle.style("stroke-dashoffset", 0); |
|
} else if (scrollTop < anim1End) { |
|
// 2nd animation consist on smoothly drawing the Penrose Triangle. |
|
// cf. computeStrokeDashoffsetScale() for better understanding. |
|
|
|
penroseTriangle.attr("transform", "translate("+[width/2, height/2]+")"); |
|
penroseTriangle.style("stroke-dasharray", lShapeLength) |
|
.style("stroke-dashoffset", strokeDashoffsetScale(scrollTop)); |
|
} else { |
|
penroseTriangle.attr("transform", "translate("+[width/2, height/2]+")"); |
|
penroseTriangle.style("stroke-dashoffset", 0); |
|
} |
|
} |
|
|
|
window.requestAnimationFrame(render); |
|
} |
|
//end: story-telling management |
|
window.requestAnimationFrame(render); |
|
|
|
function initData() { |
|
} |
|
|
|
function computePTData(iw, ext1, ext2) { |
|
/* |
|
We consider Penrose Triangles where : |
|
1- inner width 'iw' is the same along the 3 axes |
|
2- extension 'ext1' is the same along the 3 axes |
|
3- extension 'ext2' is the same along the 3 axes |
|
4- inner width 'iw', extensions 'ext1' and 'ext2' may be of different size |
|
For convention : |
|
* oblique x axis 'ox' goes to the right |
|
* oblique y axis 'oy' goes to the top, in a oblique fashion |
|
* [0,0] is at the center of the inner triangle (not shown below) |
|
|
|
|
|
/ ext2 |
|
oy / ______ |
|
/_____ / /\ |
|
ox / / \ |
|
/ / \ |
|
/ / .\ |
|
/ / ext2. \ |
|
/ / . \ |
|
/ / /\ \ |
|
/ / ext1/ \ \ |
|
/ / / \ \ |
|
/ / /\ \ \ |
|
/ / iw/ \iw \ \ |
|
/...../_____/____\ \ \ |
|
/ ext2 ext1 iw \ \ \ |
|
/ \ext1 \ \ |
|
/______________________\ \ \ |
|
\ . \ / |
|
ext2\ .ext2 \ /ext2 |
|
\_______________________._____\/ |
|
*/ |
|
|
|
//leftLShape encodes information on the L shape with the left most line |
|
let leftLShape = {}; |
|
leftLShape.obliqueStart = [3*iw/4,-iw/2]; |
|
leftLShape.obliqueDeltas = [ |
|
[-iw,0], |
|
[-ext1,0], |
|
[0, 3*ext1+iw], |
|
[-ext1, 0], |
|
[0, -4*ext1-iw] |
|
]; |
|
leftLShape.orthoStart = orthoCoord(leftLShape.obliqueStart); |
|
leftLShape.orthoDeltas = orthoCoords(leftLShape.obliqueDeltas); |
|
|
|
//rightLShape encodes information on the inverted-L shape with the right most line |
|
let rightLShape = {}; |
|
rightLShape.obliqueStart = [-iw/4,-iw/2]; |
|
rightLShape.obliqueDeltas = [ |
|
[0, iw,0], |
|
[0,ext1], |
|
[3*ext1+iw, -3*ext1-iw], |
|
[0,ext1], |
|
[-4*ext1-iw, 4*ext1+iw] |
|
]; |
|
rightLShape.orthoStart = orthoCoord(rightLShape.obliqueStart); |
|
rightLShape.orthoDeltas = orthoCoords(rightLShape.obliqueDeltas); |
|
|
|
//bottomLshape encodes information on the inverted-L shape with the bottom most line |
|
let bottomLShape = {}; |
|
bottomLShape.obliqueStart = [-iw/4, iw/2]; |
|
bottomLShape.obliqueDeltas = [ |
|
[iw,-iw], |
|
[ext1,-ext1], |
|
[-3*ext1-iw, 0], |
|
[ext1,-ext1], |
|
[4*ext1+iw, 0] |
|
]; |
|
bottomLShape.orthoStart = orthoCoord(bottomLShape.obliqueStart); |
|
bottomLShape.orthoDeltas = orthoCoords(bottomLShape.obliqueDeltas); |
|
|
|
return { |
|
leftLShape: leftLShape, |
|
rightLShape: rightLShape, |
|
bottomLShape: bottomLShape, |
|
} |
|
} |
|
|
|
function computeStrokeDashoffsetScale() { |
|
// 2nd animation consists on smoothly drawing the Penrose Triangle. It is composed of 3 identical L-shapes (each rotated by 120°). Eech L-shape is composed of 5 segments, hence the animation is compsed of 5 steps. |
|
// Each step (i) waits the text to reach the viewport's center, and then (ii) animates the drawing of the corresponding segment. |
|
|
|
// on one hand we have 5 panels to scroll (or 10 half-panels, as each step is divided into 2 sub-steps (wait text, draw segment)) |
|
const anim1CPs = [ |
|
anim1Start, // wait text to reach viewport's center |
|
anim1Start+0.5*panelHeight, // draw 1st segment |
|
anim1Start+1*panelHeight, // wait text |
|
anim1Start+1.5*panelHeight, // draw 2nd segment |
|
anim1Start+2*panelHeight, // wait text |
|
anim1Start+2.5*panelHeight, // draw 3rd segment |
|
anim1Start+3*panelHeight, // wait text |
|
anim1Start+3.5*panelHeight, // draw 4th segment |
|
anim1Start+4*panelHeight, // wait text |
|
anim1Start+4.5*panelHeight, // draw 5th segment |
|
anim1End // extra space to position last text at center |
|
] |
|
// on the other hand, the L-shape we have to draw is composed of 5 segments of different sizes |
|
const strokeDashoffsetCPs = [ |
|
lShapeLength, // wait text to appear, shape complitely hidden |
|
lShapeLength*11/12, // 1st segment is size 1 |
|
lShapeLength*11/12, // wait text |
|
lShapeLength*10/12, // 2nd segment is size 1 |
|
lShapeLength*10/12, // wait text to appear |
|
lShapeLength*6/12, // 3rd segment is size 4 |
|
lShapeLength*6/12, // wait text to appear |
|
lShapeLength*5/12, // 4th segment is size 1 |
|
lShapeLength*5/12, // wait text to appear |
|
0, // 5th segment is size 5, shape complitely drawn |
|
0 // extra space to position last text at center |
|
] |
|
|
|
return d3.scaleLinear() |
|
.domain(anim1CPs) |
|
.range(strokeDashoffsetCPs); |
|
} |
|
|
|
function initLayout() { |
|
svg = d3.select("#sticky").append("svg") |
|
.attr("width", 960) |
|
.attr("height", 500) |
|
|
|
drawingArea = svg.append("g") |
|
.classed("drawing-area", true) |
|
.attr("transform", "translate("+[margins.top, margins.left]+")"); |
|
|
|
penroseTriangle = drawingArea |
|
.classed("penrose-triangle", true) |
|
.attr("transform", "translate("+[width/2, height/2]+")"); |
|
|
|
penroseTriangle |
|
.selectAll(".l-shape") |
|
.data([pTData.leftLShape, pTData.rightLShape, pTData.bottomLShape]) |
|
.enter() |
|
.append("path") |
|
.classed("l-shape", true) |
|
.attr("d", function(d){ return liner(d); }); |
|
} |
|
</script> |
|
</body> |