Instantly share code, notes, and snippets.

Embed
What would you like to do?
Split-Flap Display

Split-Flap Display

a work in progress

I wanted to experiement with ways to build a split-flap display that could be updated in real time, using d3 to managae the updates. My first task was to learn what the heck these things are called, so i dutifully searched online for something like, "train station schedule sign rolling numbers". Google made Wiki's page the top result. Thanks, Google!

For this first attempt i wanted to use only HTML, rather than SVG. The idea would be that the data for each character would be attached to a parent div, each of which contained a pair of "flap" divs. The flaps are ½ the character height, with the bottom one having been translated downward while its content (the character) is translated back up, thus revealing the bottom half of the character.

Unfortunately, i'd forgotten that the manner in which an element behaves with respect to its z-index depends on the stacking context. And i'd just made a hash of that by burying the flaps inside a parent, creating separate contexts for each character. Z-index is crucially important. While i was willing to bring in a trainload of DOM elements to make this work, i'm not crazy enough to try animating the reordering of the DOM tree itself.

The solution was to do away with the parent divs. The flaps for each character position, top & bottom, all live inside their own container. Then it's just a matter of juggling 3 different z-index states: top, just below top, and hidden. The "enter" & "exit" classes are not related with the d3 data selection specifically, but were an easy way to maintain sanity while trying to get the timing right.

Unfortunately, this made using d3 a bit trickier. In fact, on first glance it may appear that i've mangled it. The problem was that i needed to use the data selection in such a way that each datum is attached to two individual divs. (I briefly considered doubling up the characters: "HHEELLOO …"). I might as well have used jQuery, or even native code but, as was just the first naive attempt, i wanted to play around with d3 a bit. Sometimes bending something in ways it wasn't meant to be can teach us more about it.

Or make a hash of it.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Split Flap Display version 1</title>
<style>
.container {
clear : left;
margin : 5px auto;
padding : 5px;
background: #444;
max-width : 946px;
}
.container:after {
content: "";
display: table;
clear : both;
}
.flapset {
position : relative;
float : left;
perspective: 300px;
height : 1em;
width : 1em;
margin : 0 2px;
font-family: monospace;
font-size : 18px;
line-height: 1em;
text-align : center;
color : #EAEAEA;
}
.controls {
margin-left: 300px;
}
.flap {
position : absolute;
width : 1em;
height : 0.5em;
overflow : hidden;
background: #333;
z-index : 0;
animation-duration : .05s;
animation-timing-function: linear;
animation-fill-mode : forwards;
transform-style : preserve-3d;
backface-visibility : hidden;
}
.flap span {
display: inline-block;
}
.flap-bottom span {
transform: translateY(-0.5em); /* show bottom only */
}
.flap-top {
transform-origin: left bottom;
}
.flap-bottom {
transform-origin: left top;
transform : translateY(0.5em) rotate3d(1,0,0,180deg);
}
.enter {
z-index: 2;
}
.flap-top.enter {
animation-name: topEnter;
}
.flap-bottom.enter {
animation-name: bottomEnter;
}
.flap-top.exit {
animation-name: topExit;
}
.flap-bottom.exit {
animation-name: bottomExit;
}
/*
To live in the gap
between the moment that is expiring
and the one that is arising ...
And when you close your eyes
what do you see?
nothing
Now open them.
-- Laurie Anderson, Heart of a Dog
*/
@keyframes topEnter {
0% {
z-index: 1;
}
100% {
z-index: 2;
}
}
@keyframes topExit {
0% {
transform: rotate3d(1,0,0,0deg);
z-index: 2;
}
50% {
transform: rotate3d(1,0,0,-90deg);
z-index: 2;
}
100% {
transform: rotate3d(1,0,0,-180deg);
z-index: 0;
}
}
@keyframes bottomEnter {
0% {
transform: translateY(0.5em) rotate3d(1,0,0,180deg);
z-index: 0;
}
50% {
transform: translateY(0.5em) rotate3d(1,0,0,90deg);
z-index: 1;
}
100% {
transform: translateY(0.5em) rotate3d(1,0,0,0deg);
z-index: 2;
}
}
@keyframes bottomExit {
0% {
transform: translateY(0.5em) rotate3d(1,0,0,0deg);
z-index: 2;
}
75% {
transform: translateY(0.5em) rotate3d(1,0,0,0deg);
z-index: 1;
}
100% {
opacity: 1e-6;
transform: translateY(0.5em) rotate3d(1,0,0,180deg);
z-index: 0;
}
}
</style>
</head>
<body>
<div class="controls">
<button id="flip">flip</button>
<button id="reset">reset</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/queue-async/1.0.7/queue.min.js"></script>
<script>
// let's get off to a zany start
String.prototype.padRight = function(length, character) {
return this + Array(length - this.length + 1).join(character || " ");
}
// ASCII characters to use
var charGroups = [
{offset: 32, length: 1}, // begin with space
{offset: 48, length: 10},
{offset: 65, length: 26}
];
var chars = genChars(charGroups);
var columns = 43; // tab stops?
var rows = 1;
var delayFactor = 50;
var defaultDuration = 100;
var duration, containers;
d3.select("#flip").on("click", update);
d3.select("#reset").on("click", reset);
init();
/**
* Build the DOM structure
*
* @return void
*/
function init() {
containers = d3.select("body").selectAll(".container")
.data(d3.range(rows)).enter()
.append("div")
.attr("class", "container");
containers.selectAll(".flapset")
.data(d3.range(columns)).enter()
.append("div")
.attr("class", "flapset")
.attr("data-d", function(d) { return d; })
.selectAll(".flap")
.data(chars).enter()
.call(addFlaps);
// initialise first flap in each set with enter class
var starters = reset();
// computes the animation speed using CSS style rule of first element
duration = getAnimationDuration(starters[0][0]) || defaultDuration;
}
/**
* Reset lines
*
* @return d3.selection the first flaps in each widget
*/
function reset() {
d3.selectAll(".flap").classed("enter", false);
return d3.selectAll(".flap[data-next='1'").classed("enter", true);
}
/**
* update messages
*
* @return void
*/
function update() {
// temporary
var msg = characters("Hello World", columns);
var line = Math.floor(Math.random() * rows);
dispatch(line, msg);
}
/**
* dispatch a msg to a given line
*
* @param int line zero-indexed line no.
* @param Array chars msg to display,prepped as individual characters
* @return void
*/
function dispatch(line, chars) {
containers.each(function(d, i) {
// there's got to be a better way of targeting a specific element in a selection
if (i === line) {
d3.select(this).selectAll(".flapset")
.each(function(d, i) {
window.setTimeout(function() {
flip(this, chars[i]);
}.bind(d3.select(this)), i * delayFactor);
});
}
});
}
/**
* Starts a split-flap widget flipping
*
* @return void
*/
function flip() {
var _this = arguments[0];
var letter = arguments[1];
// space char -- used for padding
if (letter == chars[0]) return;
var interval = window.setInterval(function() {
var enter = _this.selectAll(".enter");
var d = enter[0][0].dataset.d;
var next = chars[ enter[0][0].dataset.next ];
_this.selectAll(".exit").classed("exit", false);
enter.classed("exit", true).classed("enter", false);
_this.selectAll(".flap[data-d='" + next + "']").classed("enter", true);
if (next == letter) {
clearInterval(interval);
}
}, duration);
}
/**
* Adds top & bottom flaps for each character. Not super elegant.
* Note that all.flap-tops will be added before the .flap-bottoms.
*
* @param d3.selection selection
* @return void
*/
function addFlaps(selection) {
selection.append("div")
.attr("class", "flap flap-top")
.attr("data-d", function(d) { return d; })
.attr("data-next", getNextIndex)
.append("span")
.text(function(d) { return d;});
selection.append("div")
.attr("class", "flap flap-bottom")
.attr("data-d", function(d) { return d; })
.attr("data-next", getNextIndex)
.append("span")
.text(function(d) { return d;});
}
/**
* Prepares the characters in a msg
*
* @param String msg the message
* @param Int cols number of columns
* @return Array the chars,to uppercase and right-padded with spaces
*/
function characters(msg, cols) {
return msg.slice(0, cols).padRight(cols).toUpperCase().split("");
}
/**
* Generates an array of characters.
*
* @param Array groups Contains objects for each set of consecutive
* ASCII chars desired. Object should have the following properties:
* "offset": the decimal code for the first character in the group
* "length": the length of the group
* @return Array
*/
function genChars(groups) {
var a = [];
if (typeof groups === undefined || !(groups instanceof Array)) {
groups = [];
}
groups.forEach(function(d) {
if (d && d.length && d.offset) {
for (var i = 0; i < d.length; i++) {
a.push(String.fromCharCode(d.offset + i));
}
}
});
return a;
}
/**
* Get the index of the following char for a given char, or 0.
* Each div.flap stores this value in its data to make it easier
* to specify which should next have the "enter" class added.
*
* @param String d some character
* @return Integer the next index
*/
function getNextIndex(d) {
var i = chars.indexOf(d) + 1;
if (i === chars.length ) {
i = 0;
}
return i;
}
/**
* Gets the duration for an element's animation as set in
* the CSS rule.
*
* @param element
* @return Float milliseconds
*/
function getAnimationDuration(element) {
var properties = [
'animation-duration',
'WebkitAnimationDuration',
'msAnimationDuration',
'MozAnimationDuration',
'OAnimationDuration'
];
var p;
var style = window.getComputedStyle(element);
while (p = properties.shift()) {
if (typeof style[p] !== undefined) {
return parseFloat(style[p]) * 1000;
}
}
return false;
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment