Skip to content

Instantly share code, notes, and snippets.

@bumbeishvili
Forked from Kcnarf/.block
Created July 24, 2018 06:34
Show Gist options
  • Save bumbeishvili/176f394ea7dc0273d443d5cbdce8a06a to your computer and use it in GitHub Desktop.
Save bumbeishvili/176f394ea7dc0273d443d5cbdce8a06a to your computer and use it in GitHub Desktop.
Scrolytelling the Penrose triangle
license: mit
<!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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment