Last active
August 29, 2015 14:08
-
-
Save futuraprime/782017ea7492dac90bf5 to your computer and use it in GitHub Desktop.
Bach face plant // source http://jsbin.com/yatigi
This file contains hidden or 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
/*! | |
* Bowser - a browser detector | |
* https://github.com/ded/bowser | |
* MIT License | (c) Dustin Diaz 2014 | |
*/ | |
!function (name, definition) { | |
if (typeof module != 'undefined' && module.exports) module.exports['browser'] = definition() | |
else if (typeof define == 'function' && define.amd) define(definition) | |
else this[name] = definition() | |
}('bowser', function () { | |
/** | |
* See useragents.js for examples of navigator.userAgent | |
*/ | |
var t = true | |
function detect(ua) { | |
function getFirstMatch(regex) { | |
var match = ua.match(regex); | |
return (match && match.length > 1 && match[1]) || ''; | |
} | |
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase() | |
, likeAndroid = /like android/i.test(ua) | |
, android = !likeAndroid && /android/i.test(ua) | |
, versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i) | |
, tablet = /tablet/i.test(ua) | |
, mobile = !tablet && /[^-]mobi/i.test(ua) | |
, result | |
if (/opera|opr/i.test(ua)) { | |
result = { | |
name: 'Opera' | |
, opera: t | |
, version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/windows phone/i.test(ua)) { | |
result = { | |
name: 'Windows Phone' | |
, windowsphone: t | |
, msie: t | |
, version: getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/msie|trident/i.test(ua)) { | |
result = { | |
name: 'Internet Explorer' | |
, msie: t | |
, version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/chrome|crios|crmo/i.test(ua)) { | |
result = { | |
name: 'Chrome' | |
, chrome: t | |
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (iosdevice) { | |
result = { | |
name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod' | |
} | |
// WTF: version is not part of user agent in web apps | |
if (versionIdentifier) { | |
result.version = versionIdentifier | |
} | |
} | |
else if (/sailfish/i.test(ua)) { | |
result = { | |
name: 'Sailfish' | |
, sailfish: t | |
, version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/seamonkey\//i.test(ua)) { | |
result = { | |
name: 'SeaMonkey' | |
, seamonkey: t | |
, version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/firefox|iceweasel/i.test(ua)) { | |
result = { | |
name: 'Firefox' | |
, firefox: t | |
, version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i) | |
} | |
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) { | |
result.firefoxos = t | |
} | |
} | |
else if (/silk/i.test(ua)) { | |
result = { | |
name: 'Amazon Silk' | |
, silk: t | |
, version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (android) { | |
result = { | |
name: 'Android' | |
, version: versionIdentifier | |
} | |
} | |
else if (/phantom/i.test(ua)) { | |
result = { | |
name: 'PhantomJS' | |
, phantom: t | |
, version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) { | |
result = { | |
name: 'BlackBerry' | |
, blackberry: t | |
, version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i) | |
} | |
} | |
else if (/(web|hpw)os/i.test(ua)) { | |
result = { | |
name: 'WebOS' | |
, webos: t | |
, version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i) | |
}; | |
/touchpad\//i.test(ua) && (result.touchpad = t) | |
} | |
else if (/bada/i.test(ua)) { | |
result = { | |
name: 'Bada' | |
, bada: t | |
, version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i) | |
}; | |
} | |
else if (/tizen/i.test(ua)) { | |
result = { | |
name: 'Tizen' | |
, tizen: t | |
, version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier | |
}; | |
} | |
else if (/safari/i.test(ua)) { | |
result = { | |
name: 'Safari' | |
, safari: t | |
, version: versionIdentifier | |
} | |
} | |
else result = {} | |
// set webkit or gecko flag for browsers based on these engines | |
if (/(apple)?webkit/i.test(ua)) { | |
result.name = result.name || "Webkit" | |
result.webkit = t | |
if (!result.version && versionIdentifier) { | |
result.version = versionIdentifier | |
} | |
} else if (!result.opera && /gecko\//i.test(ua)) { | |
result.name = result.name || "Gecko" | |
result.gecko = t | |
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i) | |
} | |
// set OS flags for platforms that have multiple browsers | |
if (android || result.silk) { | |
result.android = t | |
} else if (iosdevice) { | |
result[iosdevice] = t | |
result.ios = t | |
} | |
// OS version extraction | |
var osVersion = ''; | |
if (iosdevice) { | |
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i); | |
osVersion = osVersion.replace(/[_\s]/g, '.'); | |
} else if (android) { | |
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i); | |
} else if (result.windowsphone) { | |
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i); | |
} else if (result.webos) { | |
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i); | |
} else if (result.blackberry) { | |
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i); | |
} else if (result.bada) { | |
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i); | |
} else if (result.tizen) { | |
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i); | |
} | |
if (osVersion) { | |
result.osversion = osVersion; | |
} | |
// device type extraction | |
var osMajorVersion = osVersion.split('.')[0]; | |
if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) { | |
result.tablet = t | |
} else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) { | |
result.mobile = t | |
} | |
// Graded Browser Support | |
// http://developer.yahoo.com/yui/articles/gbs | |
if ((result.msie && result.version >= 10) || | |
(result.chrome && result.version >= 20) || | |
(result.firefox && result.version >= 20.0) || | |
(result.safari && result.version >= 6) || | |
(result.opera && result.version >= 10.0) || | |
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) || | |
(result.blackberry && result.version >= 10.1) | |
) { | |
result.a = t; | |
} | |
else if ((result.msie && result.version < 10) || | |
(result.chrome && result.version < 20) || | |
(result.firefox && result.version < 20.0) || | |
(result.safari && result.version < 6) || | |
(result.opera && result.version < 10.0) || | |
(result.ios && result.osversion && result.osversion.split(".")[0] < 6) | |
) { | |
result.c = t | |
} else result.x = t | |
return result | |
} | |
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '') | |
/* | |
* Set our detect method to the main bowser object so we can | |
* reuse it to test other user agents. | |
* This is needed to implement future tests. | |
*/ | |
bowser._detect = detect; | |
return bowser | |
}); |
This file contains hidden or 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 name="description" content="I'll Be Bach" /> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
<title>I'll Be Bach</title> | |
<script src="//cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.min.js"></script> | |
<!-- for generating hash-based names for files--> | |
<script src="http://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/md5.js"></script> | |
<!-- browser sniffing --> | |
<script src="bowser.js"></script> | |
<!-- Finite State Machine --> | |
<script src="machina.js"></script> | |
<!-- Gotham Font --> | |
<link rel="stylesheet" href="//media.wnyc.org/static/gotham/176205/1D6F0D9D0E2D9044E.css" type="text/css"> | |
<!-- Icon font --> | |
<link href="//maxcdn.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css" rel="stylesheet"> | |
<style> | |
body { | |
background-color: black; | |
color: white; | |
} | |
body, #trigger, #save, #cancel { | |
font-family: 'Gotham SSm A', 'Gotham SSm B', Helvetica, Arial, sans-serif; | |
} | |
h1, h2, h3 { | |
font-weight: normal; | |
font-family: 'Gotham A', 'Gotham B', Helvetica, Arial, sans-serif; | |
} | |
.header, .buttons { | |
text-align: center; | |
} | |
.buttons { | |
margin-top: 10px; | |
} | |
#picture { | |
display: block; | |
margin: 10px auto; | |
border: 1px solid #222; | |
} | |
#trigger,#save,#cancel { | |
border-radius: 30px; | |
border: none; | |
outline: none; | |
cursor: pointer; | |
background-color: #aaa; | |
color: black; | |
padding: 8px; | |
-webkit-box-sizing: border-box; | |
-moz-box-sizing: border-box; | |
box-sizing: border-box; | |
text-decoration: none; | |
-webkit-transition: background-color 250ms ease-out; | |
-o-transition: background-color 250ms ease-out; | |
transition: background-color 250ms ease-out; | |
text-shadow: 1px 1px 0 rgba(255,255,255,0.5); | |
text-transform: uppercase; | |
font-weight: 600; | |
} | |
#trigger:hover, #save:hover, #cancel:hover { | |
background-color: white; | |
text-decoration: none; | |
} | |
#trigger { | |
width: 200px; | |
} | |
#trigger:before, #save:before, #cancel:before { | |
font-family: FontAwesome; | |
display: block; | |
margin-bottom: 3px; | |
font-size: 18px; | |
font-weight: initial; | |
} | |
#trigger:before { | |
font-size: 25px; | |
} | |
#cancel, #save { | |
width: 40px; | |
height: 40px; | |
font-size: 18px; | |
line-height: 24px; /* 40 - 8 - 8 */ | |
vertical-align: 10px; | |
} | |
.notifier { | |
position: absolute; | |
display: none; | |
top: 0; | |
padding: 10px; | |
background-color: black; | |
} | |
.links { | |
text-align: center; | |
margin-top: 5px; | |
} | |
.links a { | |
color: white; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="notifier" id="notifier"> | |
Activate your webcam! | |
</div> | |
<!-- <div class="header"> | |
<img src="wqxr.svg" alt="WQXR" height="48"> | |
</div> | |
--> | |
<canvas id="picture" width="480" height="600"></canvas> | |
<div class="buttons"> | |
<a id="save" style="display:none;" class="fa fa-save"></a> | |
<a id="trigger" class="fa fa-camera">Snap my Bach</a> | |
<a id="cancel" style="display:none;" class="fa fa-refresh"></a> | |
</div> | |
<div class="links"> | |
<a href="http://www.wqxr.org/">Visit WQXR.org</a> | | |
<a href="http://www.wqxr.org/mobile">Get the mobile app</a> | |
</div> | |
<script src="jsbin.yatigi.js"></script> | |
<!-- Google Analytics --> | |
<script type="text/javascript"> | |
var _gaq = _gaq || []; | |
_gaq.push(['_setAccount', 'UA-283599-23']); | |
_gaq.push(['_trackPageview']); | |
(function() { | |
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; | |
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; | |
var s = document.getElementsByTagName('script')[0]; | |
s.parentNode.insertBefore(ga, s); | |
})(); | |
</script> | |
<!-- End Google Analytics --> | |
</body> | |
</html> |
This file contains hidden or 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
var video = document.createElement('video'); | |
var canvas = document.getElementById('picture'); | |
var ctx = canvas.getContext('2d'); | |
// polyfill from https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement.toBlob | |
if (!HTMLCanvasElement.prototype.toBlob) { | |
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { | |
value: function (callback, type, quality) { | |
var binStr = atob( this.toDataURL(type, quality).split(',')[1] ), | |
len = binStr.length, | |
arr = new Uint8Array(len); | |
for (var i=0; i<len; i++ ) { | |
arr[i] = binStr.charCodeAt(i); | |
} | |
callback( new Blob( [arr], {type: type || 'image/png'} ) ); | |
} | |
}); | |
} | |
var bachURL = './bach.png'; | |
var bach = new Image(); | |
bach.src = bachURL; | |
var bach_ready = false; | |
bach.onload = function() { | |
bach_ready = true; | |
}; | |
var videoOpts = { 'video' : true }; | |
ctx.textAlign = 'center'; | |
ctx.font = '14px "Gotham SSm A", "Gotham SSm B", Helvetica, Arial, sans-serif'; | |
var trigger = document.getElementById('trigger'); | |
var cancel = document.getElementById('cancel'); | |
var save = document.getElementById('save'); | |
var notifier = document.getElementById('notifier'); | |
var snapURL, snap = new Image(); | |
var startTime = null; | |
var currentTime = null; | |
var flash = null; | |
// this function takes care of text on the display | |
var writeMessage; | |
function buttonDisplay(triggerDisplay, cancelDisplay, saveDisplay) { | |
trigger.style.display = triggerDisplay ? 'inline-block' : 'none'; | |
cancel.style.display = cancelDisplay ? 'inline-block' : 'none'; | |
save.style.display = saveDisplay ? 'inline-block' : 'none'; | |
} | |
// we need an FSM here | |
var fsm = new machina.Fsm({ | |
initialize : function() { | |
}, | |
// _save is a utility function that uploads the file... | |
_save : function() { | |
var fd = new FormData(); | |
var self = this; | |
canvas.toBlob(function(blob) { | |
fd.append("1279_3067-answer", blob, self.filename); | |
var request = new XMLHttpRequest(); | |
request.open( | |
"POST", | |
"http://www.wqxr.org/crowdsourcing/bach-face/api/submit_survey_questions_json/" | |
); | |
request.send(fd); | |
}, 'image/png'); | |
this.outURL = 'https://media2.wnyc.org/i/raw/1/'+this.filename; | |
}, | |
initialState : 'initializing', | |
states : { | |
'initializing' : { | |
_onEnter : function() { | |
var self = this; | |
var errorCallback = function(error) { | |
self.transition('denied_access') | |
}; | |
buttonDisplay(); | |
writeMessage = function() { | |
ctx.fillText('Please allow us to', 270, 232); | |
ctx.fillText('access your camera', 270, 250); | |
} | |
if(bowser.mobile || bowser.tablet) { | |
return this.transition('mobile'); | |
} | |
notifier.style.display = 'block'; | |
if(bowser.chrome) { | |
notifier.style.right = '50px'; | |
notifier.innerHTML = 'Activate your camera! <i class="fa fa-arrow-up"></i>'; | |
} else if(bowser.firefox) { | |
notifier.style.left = '40px'; | |
notifier.innerHTML = '<i class="fa fa-arrow-up"></i> Activate your camera!'; | |
} | |
// kick off the video, if we can... | |
if(navigator.getUserMedia) { | |
navigator.getUserMedia(videoOpts, function(stream) { | |
self.transition('viewing'); | |
video.src = stream; | |
video.play(); | |
}, errorCallback); | |
} else if(navigator.webkitGetUserMedia) { | |
navigator.webkitGetUserMedia(videoOpts, function(stream) { | |
self.transition('viewing'); | |
video.src = window.webkitURL.createObjectURL(stream); | |
video.play(); | |
}, errorCallback); | |
} else if(navigator.mozGetUserMedia) { | |
navigator.mozGetUserMedia(videoOpts, function(stream){ | |
self.transition('viewing'); | |
video.src = window.URL.createObjectURL(stream); | |
video.play(); | |
}, errorCallback); | |
} else { | |
self.transition('unsupported'); | |
} | |
} | |
}, | |
'unsupported' : { | |
_onEnter : function() { | |
notifier.style.display = 'none' | |
buttonDisplay(); | |
writeMessage = function() { | |
ctx.fillText('Your browser won\'t let', 270, 250-18*2); | |
ctx.fillText('you be Bach today.', 270, 250-18); | |
ctx.fillText('(Please try again in', 270, 250+18); | |
ctx.fillText('Chrome or Firefox.)', 270, 250+18*2); | |
} | |
} | |
}, | |
'mobile' : { | |
_onEnter : function() { | |
notifier.style.display = 'none'; | |
buttonDisplay(); | |
writeMessage = function() { | |
ctx.fillText('Unfortunately, you', 270, 250-18*3); | |
ctx.fillText('cannot be Bach', 270, 250-18*2); | |
ctx.fillText('on a mobile device.', 270, 250-18); | |
ctx.fillText('(Please try again in', 270, 250+18); | |
ctx.fillText('Chrome or Firefox', 270, 250+18*2); | |
ctx.fillText('on your computer.)', 270, 250+18*3) | |
} | |
} | |
}, | |
'denied_access' : { | |
_onEnter : function() { | |
buttonDisplay(); | |
notifier.style.display = 'block'; | |
if(bowser.chrome) { | |
notifier.innerHTML = '<img src="chrome_icon.png" style="margin:5px 0 -5px;"/> Please reset your webcam settings.'; | |
notifier.style.right = '30px'; | |
} else if(bowser.firefox) { | |
notifier.style.left = '0px'; | |
notifier.innerHTML = 'You\'ve disabled your webcam for this page.'; | |
} | |
writeMessage = function() { | |
ctx.fillText('You have decided not', 270, 250-18*3); | |
ctx.fillText('to be Bach today.', 270, 250-18*2); | |
ctx.fillText('You\'ll need to allow', 270, 250+18*0); | |
ctx.fillText('access to your webcam', 270, 250+18*1); | |
ctx.fillText('for us to bring you Bach.', 270, 250+18*2); | |
} | |
} | |
}, | |
'viewing' : { | |
_onEnter : function() { | |
notifier.style.display = 'none'; | |
buttonDisplay(true); | |
trigger.classList.add('fa-camera', 'fa-refresh'); | |
trigger.classList.remove('fa-save'); | |
trigger.innerHTML = "Snap my Bach"; | |
writeMessage = function() { | |
ctx.fillText('Starting the camera...', 270, 250); | |
} | |
}, | |
trigger : function () { | |
this.transition('snapped'); | |
snapURL = canvas.toDataURL('image/png'); | |
snap.src = snapURL; | |
flash = currentTime; | |
} | |
}, | |
'snapped' : { | |
_onEnter : function() { | |
notifier.style.display = 'none'; | |
buttonDisplay(true, true, true); | |
trigger.classList.remove('fa-camera', 'fa-refresh'); | |
trigger.classList.add('fa-save'); | |
trigger.innerHTML = "Save my Bach"; | |
save.style.visibility = 'hidden'; | |
cancel.style.visibility = 'visible'; | |
var url = this.savedUrl = canvas.toDataURL('image/png'); | |
this.filename = CryptoJS.MD5(url).toString() + String(+new Date()) + '.png'; | |
}, | |
cancel : function() { | |
this.transition('viewing'); | |
}, | |
// this tweets the image | |
// trigger : function() { | |
// this._save(); | |
// var url = this.outURL; | |
// window.open( | |
// 'https://twitter.com/share?via=WQXR&hashtags=illbebach&url='+url, | |
// 'twitter', | |
// 'height=400,width=600,left=10,top=10,menubar=no,location=no,status=no' | |
// ); | |
// }, | |
// this offers the image for download | |
trigger : function(evt) { | |
trigger.href = this.savedUrl; | |
trigger.download = 'illbebach.png'; | |
this.transition('saved'); | |
} | |
}, | |
'saved' : { | |
_onEnter : function() { | |
notifier.style.display = 'none'; | |
buttonDisplay(true, true, true); | |
trigger.classList.remove('fa-camera', 'fa-save'); | |
trigger.classList.add('fa-refresh'); | |
trigger.innerHTML = "Another Bach?"; | |
save.style.visibility = 'visible'; | |
cancel.style.visibility = 'hidden'; | |
this.filename = CryptoJS.MD5(this.savedUrl).toString() + String(+new Date()) + '.png'; | |
}, | |
save : function() { | |
save.href = this.savedUrl; | |
save.download = 'illbebach.png'; | |
this.transition('saved'); | |
}, | |
trigger : function() { | |
// weirdly, these have to be here, in the event handler... | |
trigger.removeAttribute('href'); | |
trigger.removeAttribute('download'); | |
this.transition('viewing'); | |
} | |
} | |
} | |
}); | |
trigger.addEventListener('click', function() { | |
fsm.handle('trigger'); | |
}); | |
cancel.addEventListener('click', function() { | |
fsm.handle('cancel'); | |
}); | |
save.addEventListener('click', function() { | |
fsm.handle('save'); | |
}); | |
var flashTime = 250; // ms | |
function step(timestamp) { | |
currentTime = timestamp; | |
if(startTime === null) { startTime = timestamp; } | |
if(['snapped', 'saved'].indexOf(fsm.state) > -1) { | |
ctx.drawImage(snap, 0, 0, canvas.width, canvas.height); | |
if((timestamp - flash) < (flashTime * 2)) { | |
ctx.fillStyle = 'rgba(255,255,255,'+Math.min(1, 1 + (1 - (timestamp - flash)/flashTime))+')'; | |
ctx.fillRect(0,0,canvas.width, canvas.height); | |
} | |
} else { | |
ctx.fillStyle = 'black'; | |
ctx.fillRect(0,0,canvas.width, canvas.height); | |
ctx.fillStyle = 'white'; | |
writeMessage(); | |
ctx.save(); | |
ctx.translate(canvas.width, 0); | |
ctx.scale(-1, 1); | |
ctx.drawImage(video, 0, 50, canvas.width, canvas.width * video.videoHeight / video.videoWidth ); | |
ctx.restore(); | |
if(bach_ready) { | |
ctx.drawImage(bach, 0, 0, canvas.width, canvas.width * bach.height / bach.width); | |
} | |
} | |
window.requestAnimationFrame(step); | |
} | |
window.requestAnimationFrame(step); |
This file contains hidden or 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
/** | |
* machina - A library for creating powerful and flexible finite state machines. Loosely inspired by Erlang/OTP's gen_fsm behavior. | |
* Author: Jim Cowart (http://freshbrewedcode.com/jimcowart) | |
* Version: v0.4.0-1 | |
* Url: http://machina-js.org/ | |
* License(s): MIT, GPL | |
*/ | |
(function (root, factory) { | |
if (typeof define === "function" && define.amd) { | |
// AMD. Register as an anonymous module. | |
define(["lodash"], function (_) { | |
return factory(_, root); | |
}); | |
} else if (typeof module === "object" && module.exports) { | |
// Node, or CommonJS-Like environments | |
module.exports = factory(require("lodash")); | |
} else { | |
// Browser globals | |
root.machina = factory(root._, root); | |
} | |
}(this, function (_, global, undefined) { | |
var slice = [].slice; | |
var NEXT_TRANSITION = "transition"; | |
var NEXT_HANDLER = "handler"; | |
var HANDLING = "handling"; | |
var HANDLED = "handled"; | |
var NO_HANDLER = "nohandler"; | |
var TRANSITION = "transition"; | |
var INVALID_STATE = "invalidstate"; | |
var DEFERRED = "deferred"; | |
var NEW_FSM = "newfsm"; | |
var utils = { | |
makeFsmNamespace: (function () { | |
var machinaCount = 0; | |
return function () { | |
return "fsm." + machinaCount++; | |
}; | |
})(), | |
getDefaultOptions: function () { | |
return { | |
initialState: "uninitialized", | |
eventListeners: { | |
"*": [] | |
}, | |
states: {}, | |
eventQueue: [], | |
namespace: utils.makeFsmNamespace(), | |
targetReplayState: "", | |
state: undefined, | |
priorState: undefined, | |
_priorAction: "", | |
_currentAction: "" | |
}; | |
} | |
}; | |
if (!_.deepExtend) { | |
var behavior = { | |
"*": function (obj, sourcePropKey, sourcePropVal) { | |
obj[sourcePropKey] = sourcePropVal; | |
}, | |
"object": function (obj, sourcePropKey, sourcePropVal) { | |
obj[sourcePropKey] = deepExtend({}, obj[sourcePropKey] || {}, sourcePropVal); | |
}, | |
"array": function (obj, sourcePropKey, sourcePropVal) { | |
obj[sourcePropKey] = []; | |
_.each(sourcePropVal, function (item, idx) { | |
behavior[getHandlerName(item)](obj[sourcePropKey], idx, item); | |
}, this); | |
} | |
}, | |
getActualType = function (val) { | |
if (_.isArray(val)) { | |
return "array"; | |
} | |
if (_.isDate(val)) { | |
return "date"; | |
} | |
if (_.isRegExp(val)) { | |
return "regex"; | |
} | |
return typeof val; | |
}, | |
getHandlerName = function (val) { | |
var propType = getActualType(val); | |
return behavior[propType] ? propType : "*"; | |
}, | |
deepExtend = function (obj) { | |
_.each(slice.call(arguments, 1), function (source) { | |
_.each(source, function (sourcePropVal, sourcePropKey) { | |
behavior[getHandlerName(sourcePropVal)](obj, sourcePropKey, sourcePropVal); | |
}); | |
}); | |
return obj; | |
}; | |
_.mixin({ | |
deepExtend: deepExtend | |
}); | |
} | |
var Fsm = function (options) { | |
_.extend(this, options); | |
_.defaults(this, utils.getDefaultOptions()); | |
this.initialize.apply(this, arguments); | |
machina.emit(NEW_FSM, this); | |
if (this.initialState) { | |
this.transition(this.initialState); | |
} | |
}; | |
_.extend(Fsm.prototype, { | |
initialize: function () {}, | |
emit: function (eventName) { | |
var args = arguments; | |
if (this.eventListeners["*"]) { | |
_.each(this.eventListeners["*"], function (callback) { | |
try { | |
callback.apply(this, slice.call(args, 0)); | |
} catch (exception) { | |
if (console && typeof console.log !== "undefined") { | |
console.log(exception.toString()); | |
} | |
} | |
}, this); | |
} | |
if (this.eventListeners[eventName]) { | |
_.each(this.eventListeners[eventName], function (callback) { | |
try { | |
callback.apply(this, slice.call(args, 1)); | |
} catch (exception) { | |
if (console && typeof console.log !== "undefined") { | |
console.log(exception.toString()); | |
} | |
} | |
}, this); | |
} | |
}, | |
handle: function (inputType) { | |
if (!this.inExitHandler) { | |
var states = this.states, | |
current = this.state, | |
args = slice.call(arguments, 0), | |
handlerName, handler, catchAll, action; | |
this.currentActionArgs = args; | |
if (states[current][inputType] || states[current]["*"] || this["*"]) { | |
handlerName = states[current][inputType] ? inputType : "*"; | |
catchAll = handlerName === "*"; | |
if (states[current][handlerName]) { | |
handler = states[current][handlerName]; | |
action = current + "." + handlerName; | |
} else { | |
handler = this["*"]; | |
action = "*"; | |
} | |
if (!this._currentAction) this._currentAction = action; | |
this.emit.call(this, HANDLING, { | |
inputType: inputType, | |
args: args.slice(1) | |
}); | |
if (_.isFunction(handler)) handler = handler.apply(this, catchAll ? args : args.slice(1)); | |
if (_.isString(handler)) this.transition(handler); | |
this.emit.call(this, HANDLED, { | |
inputType: inputType, | |
args: args.slice(1) | |
}); | |
this._priorAction = this._currentAction; | |
this._currentAction = ""; | |
this.processQueue(NEXT_HANDLER); | |
} else { | |
this.emit.call(this, NO_HANDLER, { | |
inputType: inputType, | |
args: args.slice(1) | |
}); | |
} | |
this.currentActionArgs = undefined; | |
} | |
}, | |
transition: function (newState) { | |
if (!this.inExitHandler && newState !== this.state) { | |
var curState = this.state; | |
if (this.states[newState]) { | |
if (curState && this.states[curState] && this.states[curState]._onExit) { | |
this.inExitHandler = true; | |
this.states[curState]._onExit.call(this); | |
this.inExitHandler = false; | |
} | |
this.targetReplayState = newState; | |
this.priorState = curState; | |
this.state = newState; | |
this.emit.call(this, TRANSITION, { | |
fromState: this.priorState, | |
action: this._currentAction, | |
toState: newState | |
}); | |
if (this.states[newState]._onEnter) { | |
this.states[newState]._onEnter.call(this); | |
} | |
if (this.targetReplayState === newState) { | |
this.processQueue(NEXT_TRANSITION); | |
} | |
return; | |
} | |
this.emit.call(this, INVALID_STATE, { | |
state: this.state, | |
attemptedState: newState | |
}); | |
} | |
}, | |
processQueue: function (type) { | |
var filterFn = type === NEXT_TRANSITION ? | |
function (item) { | |
return item.type === NEXT_TRANSITION && ((!item.untilState) || (item.untilState === this.state)); | |
} : function (item) { | |
return item.type === NEXT_HANDLER; | |
}; | |
var toProcess = _.filter(this.eventQueue, filterFn, this); | |
this.eventQueue = _.difference(this.eventQueue, toProcess); | |
_.each(toProcess, function (item) { | |
this.handle.apply(this, item.args); | |
}, this); | |
}, | |
clearQueue: function (type, name) { | |
if (!type) { | |
this.eventQueue = []; | |
} else { | |
var filter; | |
if (type === NEXT_TRANSITION) { | |
filter = function (evnt) { | |
return (evnt.type === NEXT_TRANSITION && (name ? evnt.untilState === name : true)); | |
}; | |
} else if (type === NEXT_HANDLER) { | |
filter = function (evnt) { | |
return evnt.type === NEXT_HANDLER; | |
}; | |
} | |
this.eventQueue = _.filter(this.eventQueue, filter); | |
} | |
}, | |
deferUntilTransition: function (stateName) { | |
if (this.currentActionArgs) { | |
var queued = { | |
type: NEXT_TRANSITION, | |
untilState: stateName, | |
args: this.currentActionArgs | |
}; | |
this.eventQueue.push(queued); | |
this.emit.call(this, DEFERRED, { | |
state: this.state, | |
queuedArgs: queued | |
}); | |
} | |
}, | |
deferUntilNextHandler: function () { | |
if (this.currentActionArgs) { | |
var queued = { | |
type: NEXT_HANDLER, | |
args: this.currentActionArgs | |
}; | |
this.eventQueue.push(queued); | |
this.emit.call(this, DEFERRED, { | |
state: this.state, | |
queuedArgs: queued | |
}); | |
} | |
}, | |
on: function (eventName, callback) { | |
var self = this; | |
if (!self.eventListeners[eventName]) { | |
self.eventListeners[eventName] = []; | |
} | |
self.eventListeners[eventName].push(callback); | |
return { | |
eventName: eventName, | |
callback: callback, | |
off: function () { | |
self.off(eventName, callback); | |
} | |
}; | |
}, | |
off: function (eventName, callback) { | |
if (!eventName) { | |
this.eventListeners = {}; | |
} else { | |
if (this.eventListeners[eventName]) { | |
if (callback) { | |
this.eventListeners[eventName] = _.without(this.eventListeners[eventName], callback); | |
} else { | |
this.eventListeners[eventName] = []; | |
} | |
} | |
} | |
} | |
}); | |
Fsm.prototype.trigger = Fsm.prototype.emit; | |
// _machKeys are members we want to track across the prototype chain of an extended FSM constructor | |
// Since we want to eventually merge the aggregate of those values onto the instance so that FSMs | |
// that share the same extended prototype won't share state *on* those prototypes. | |
var _machKeys = ["states", "initialState"]; | |
var inherits = function (parent, protoProps, staticProps) { | |
var fsm; // placeholder for instance constructor | |
var machObj = {}; // object used to hold initialState & states from prototype for instance-level merging | |
var ctor = function () {}; // placeholder ctor function used to insert level in prototype chain | |
// The constructor function for the new subclass is either defined by you | |
// (the "constructor" property in your `extend` definition), or defaulted | |
// by us to simply call the parent's constructor. | |
if (protoProps && protoProps.hasOwnProperty('constructor')) { | |
fsm = protoProps.constructor; | |
} else { | |
// The default machina constructor (when using inheritance) creates a | |
// deep copy of the states/initialState values from the prototype and | |
// extends them over the instance so that they'll be instance-level. | |
// If an options arg (args[0]) is passed in, a states or intialState | |
// value will be preferred over any data pulled up from the prototype. | |
fsm = function () { | |
var args = slice.call(arguments, 0); | |
args[0] = args[0] || {}; | |
var blendedState; | |
var instanceStates = args[0].states || {}; | |
blendedState = _.deepExtend(_.cloneDeep(machObj), { | |
states: instanceStates | |
}); | |
blendedState.initialState = args[0].initialState || this.initialState; | |
_.extend(args[0], blendedState); | |
parent.apply(this, args); | |
}; | |
} | |
// Inherit class (static) properties from parent. | |
_.deepExtend(fsm, parent); | |
// Set the prototype chain to inherit from `parent`, without calling | |
// `parent`'s constructor function. | |
ctor.prototype = parent.prototype; | |
fsm.prototype = new ctor(); | |
// Add prototype properties (instance properties) to the subclass, | |
// if supplied. | |
if (protoProps) { | |
_.extend(fsm.prototype, protoProps); | |
_.deepExtend(machObj, _.transform(protoProps, function (accum, val, key) { | |
if (_machKeys.indexOf(key) !== -1) { | |
accum[key] = val; | |
} | |
})); | |
} | |
// Add static properties to the constructor function, if supplied. | |
if (staticProps) { | |
_.deepExtend(fsm, staticProps); | |
} | |
// Correctly set child's `prototype.constructor`. | |
fsm.prototype.constructor = fsm; | |
// Set a convenience property in case the parent's prototype is needed later. | |
fsm.__super__ = parent.prototype; | |
return fsm; | |
}; | |
// The self-propagating extend function that Backbone classes use. | |
Fsm.extend = function (protoProps, classProps) { | |
var fsm = inherits(this, protoProps, classProps); | |
fsm.extend = this.extend; | |
return fsm; | |
}; | |
var machina = { | |
Fsm: Fsm, | |
utils: utils, | |
on: function (eventName, callback) { | |
if (!this.eventListeners[eventName]) { | |
this.eventListeners[eventName] = []; | |
} | |
this.eventListeners[eventName].push(callback); | |
return callback; | |
}, | |
off: function (eventName, callback) { | |
if (this.eventListeners[eventName]) { | |
this.eventListeners[eventName] = _.without(this.eventListeners[eventName], callback); | |
} | |
}, | |
trigger: function (eventName) { | |
var i = 0, | |
len, args = arguments, | |
listeners = this.eventListeners[eventName] || []; | |
if (listeners && listeners.length) { | |
_.each(listeners, function (callback) { | |
callback.apply(null, slice.call(args, 1)); | |
}); | |
} | |
}, | |
eventListeners: { | |
newFsm: [] | |
} | |
}; | |
machina.emit = machina.trigger; | |
return machina; | |
})); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment