543 lines
14 KiB
CoffeeScript
Executable File
543 lines
14 KiB
CoffeeScript
Executable File
VALUE_HTML = '<span class="odometer-value"></span>'
|
||
RIBBON_HTML = '<span class="odometer-ribbon"><span class="odometer-ribbon-inner">' + VALUE_HTML + '</span></span>'
|
||
DIGIT_HTML = '<span class="odometer-digit"><span class="odometer-digit-spacer">8</span><span class="odometer-digit-inner">' + RIBBON_HTML + '</span></span>'
|
||
FORMAT_MARK_HTML = '<span class="odometer-formatting-mark"></span>'
|
||
|
||
# The bit within the parenthesis will be repeated, so (,ddd) becomes 123,456,789....
|
||
#
|
||
# If your locale uses spaces to seperate digits, you could consider using a
|
||
# Narrow No-Break Space ( ), as it's a bit more correct.
|
||
#
|
||
# Numbers will be rounded to the number of digits after the radix seperator.
|
||
#
|
||
# When values are set using `.update` or the `.innerHTML`-type attributes,
|
||
# strings are assumed to already be in the locale's format.
|
||
#
|
||
# This is just the default, it can also be set as options.format.
|
||
DIGIT_FORMAT = '(,ddd).dd'
|
||
|
||
FORMAT_PARSER = /^\(?([^)]*)\)?(?:(.)(d+))?$/
|
||
|
||
# What is our target framerate?
|
||
FRAMERATE = 30
|
||
|
||
# How long will the animation last?
|
||
DURATION = 2000
|
||
|
||
# What is the fastest we should update values when we are
|
||
# counting up (not using the wheel animation).
|
||
COUNT_FRAMERATE = 20
|
||
|
||
# What is the minimum number of frames for each value on the wheel?
|
||
# We won't render more values than could be reasonably seen
|
||
FRAMES_PER_VALUE = 2
|
||
|
||
# If more than one digit is hitting the frame limit, they would all get
|
||
# capped at that limit and appear to be moving at the same rate. This
|
||
# factor adds a boost to subsequent digits to make them appear faster.
|
||
DIGIT_SPEEDBOOST = .5
|
||
|
||
MS_PER_FRAME = 1000 / FRAMERATE
|
||
COUNT_MS_PER_FRAME = 1000 / COUNT_FRAMERATE
|
||
|
||
TRANSITION_END_EVENTS = 'transitionend webkitTransitionEnd oTransitionEnd otransitionend MSTransitionEnd'
|
||
|
||
transitionCheckStyles = document.createElement('div').style
|
||
TRANSITION_SUPPORT = transitionCheckStyles.transition? or transitionCheckStyles.webkitTransition? or
|
||
transitionCheckStyles.mozTransition? or transitionCheckStyles.oTransition?
|
||
|
||
requestAnimationFrame = window.requestAnimationFrame or window.mozRequestAnimationFrame or
|
||
window.webkitRequestAnimationFrame or window.msRequestAnimationFrame
|
||
|
||
MutationObserver = window.MutationObserver or window.WebKitMutationObserver or window.MozMutationObserver
|
||
|
||
createFromHTML = (html) ->
|
||
el = document.createElement('div')
|
||
el.innerHTML = html
|
||
el.children[0]
|
||
|
||
removeClass = (el, name) ->
|
||
el.className = el.className.replace new RegExp("(^| )#{ name.split(' ').join('|') }( |$)", 'gi'), ' '
|
||
|
||
addClass = (el, name) ->
|
||
removeClass el, name
|
||
el.className += " #{ name }"
|
||
|
||
trigger = (el, name) ->
|
||
# Custom DOM events are not supported in IE8
|
||
if document.createEvent?
|
||
evt = document.createEvent('HTMLEvents')
|
||
evt.initEvent(name, true, true)
|
||
el.dispatchEvent(evt)
|
||
|
||
now = ->
|
||
window.performance?.now?() ? +new Date
|
||
|
||
round = (val, precision=0) ->
|
||
return Math.round(val) unless precision
|
||
|
||
val *= Math.pow(10, precision)
|
||
val += 0.5
|
||
val = Math.floor(val)
|
||
val /= Math.pow(10, precision)
|
||
|
||
truncate = (val) ->
|
||
# | 0 fails on numbers greater than 2^32
|
||
if val < 0
|
||
Math.ceil(val)
|
||
else
|
||
Math.floor(val)
|
||
|
||
fractionalPart = (val) ->
|
||
val - round(val)
|
||
|
||
_jQueryWrapped = false
|
||
do wrapJQuery = ->
|
||
return if _jQueryWrapped
|
||
|
||
if window.jQuery?
|
||
_jQueryWrapped = true
|
||
# We need to wrap jQuery's .html and .text because they don't always
|
||
# call .innerHTML/.innerText
|
||
for property in ['html', 'text']
|
||
do (property) ->
|
||
old = window.jQuery.fn[property]
|
||
window.jQuery.fn[property] = (val) ->
|
||
if not val? or not this[0]?.odometer?
|
||
return old.apply this, arguments
|
||
|
||
this[0].odometer.update val
|
||
|
||
# In case jQuery is brought in after this file
|
||
setTimeout wrapJQuery, 0
|
||
|
||
class Odometer
|
||
constructor: (@options) ->
|
||
@el = @options.el
|
||
return @el.odometer if @el.odometer?
|
||
|
||
@el.odometer = @
|
||
|
||
for k, v of Odometer.options
|
||
if not @options[k]?
|
||
@options[k] = v
|
||
|
||
@options.duration ?= DURATION
|
||
@MAX_VALUES = ((@options.duration / MS_PER_FRAME) / FRAMES_PER_VALUE) | 0
|
||
|
||
@resetFormat()
|
||
|
||
@value = @cleanValue(@options.value ? '')
|
||
|
||
@renderInside()
|
||
@render()
|
||
|
||
try
|
||
for property in ['innerHTML', 'innerText', 'textContent'] when @el[property]?
|
||
do (property) =>
|
||
Object.defineProperty @el, property,
|
||
get: =>
|
||
if property is 'innerHTML'
|
||
@inside.outerHTML
|
||
else
|
||
# It's just a single HTML element, so innerText is the
|
||
# same as outerText.
|
||
@inside.innerText ? @inside.textContent
|
||
set: (val) =>
|
||
@update val
|
||
catch e
|
||
# Safari
|
||
@watchForMutations()
|
||
|
||
@
|
||
|
||
renderInside: ->
|
||
@inside = document.createElement 'div'
|
||
@inside.className = 'odometer-inside'
|
||
@el.innerHTML = ''
|
||
@el.appendChild @inside
|
||
|
||
watchForMutations: ->
|
||
# Safari doesn't allow us to wrap .innerHTML, so we listen for it
|
||
# changing.
|
||
return unless MutationObserver?
|
||
|
||
try
|
||
@observer ?= new MutationObserver (mutations) =>
|
||
newVal = @el.innerText
|
||
|
||
@renderInside()
|
||
@render @value
|
||
@update newVal
|
||
|
||
@watchMutations = true
|
||
@startWatchingMutations()
|
||
catch e
|
||
|
||
startWatchingMutations: ->
|
||
if @watchMutations
|
||
@observer.observe @el, {childList: true}
|
||
|
||
stopWatchingMutations: ->
|
||
@observer?.disconnect()
|
||
|
||
cleanValue: (val) ->
|
||
if typeof val is 'string'
|
||
# We need to normalize the format so we can properly turn it into
|
||
# a float.
|
||
val = val.replace((@format.radix ? '.'), '<radix>')
|
||
val = val.replace /[.,]/g, ''
|
||
val = val.replace '<radix>', '.'
|
||
val = parseFloat(val, 10) or 0
|
||
|
||
round(val, @format.precision)
|
||
|
||
bindTransitionEnd: ->
|
||
return if @transitionEndBound
|
||
@transitionEndBound = true
|
||
|
||
# The event will be triggered once for each ribbon, we only
|
||
# want one render though
|
||
renderEnqueued = false
|
||
for event in TRANSITION_END_EVENTS.split(' ')
|
||
@el.addEventListener event, =>
|
||
return true if renderEnqueued
|
||
|
||
renderEnqueued = true
|
||
|
||
setTimeout =>
|
||
@render()
|
||
renderEnqueued = false
|
||
|
||
trigger @el, 'odometerdone'
|
||
, 0
|
||
|
||
true
|
||
, false
|
||
|
||
resetFormat: ->
|
||
format = @options.format ? DIGIT_FORMAT
|
||
format or= 'd'
|
||
|
||
parsed = FORMAT_PARSER.exec format
|
||
if not parsed
|
||
throw new Error "Odometer: Unparsable digit format"
|
||
|
||
[repeating, radix, fractional] = parsed[1..3]
|
||
|
||
precision = fractional?.length or 0
|
||
|
||
@format = {repeating, radix, precision}
|
||
|
||
render: (value=@value) ->
|
||
@stopWatchingMutations()
|
||
@resetFormat()
|
||
|
||
@inside.innerHTML = ''
|
||
|
||
theme = @options.theme
|
||
|
||
classes = @el.className.split(' ')
|
||
newClasses = []
|
||
for cls in classes when cls.length
|
||
if match = /^odometer-theme-(.+)$/.exec(cls)
|
||
theme = match[1]
|
||
continue
|
||
|
||
if /^odometer(-|$)/.test(cls)
|
||
continue
|
||
|
||
newClasses.push cls
|
||
|
||
newClasses.push 'odometer'
|
||
|
||
unless TRANSITION_SUPPORT
|
||
newClasses.push 'odometer-no-transitions'
|
||
|
||
if theme
|
||
newClasses.push "odometer-theme-#{ theme }"
|
||
else
|
||
# This class matches all themes, so it should do what you'd expect if only one
|
||
# theme css file is brought into the page.
|
||
newClasses.push "odometer-auto-theme"
|
||
|
||
@el.className = newClasses.join(' ')
|
||
|
||
@ribbons = {}
|
||
|
||
@digits = []
|
||
wholePart = not @format.precision or not fractionalPart(value) or false
|
||
for digit in value.toString().split('').reverse()
|
||
if digit is '.'
|
||
wholePart = true
|
||
|
||
@addDigit digit, wholePart
|
||
|
||
@startWatchingMutations()
|
||
|
||
update: (newValue) ->
|
||
newValue = @cleanValue newValue
|
||
|
||
return unless diff = newValue - @value
|
||
|
||
removeClass @el, 'odometer-animating-up odometer-animating-down odometer-animating'
|
||
if diff > 0
|
||
addClass @el, 'odometer-animating-up'
|
||
else
|
||
addClass @el, 'odometer-animating-down'
|
||
|
||
@stopWatchingMutations()
|
||
@animate newValue
|
||
@startWatchingMutations()
|
||
|
||
setTimeout =>
|
||
# Force a repaint
|
||
@el.offsetHeight
|
||
|
||
addClass @el, 'odometer-animating'
|
||
, 0
|
||
|
||
@value = newValue
|
||
|
||
renderDigit: ->
|
||
createFromHTML DIGIT_HTML
|
||
|
||
insertDigit: (digit, before) ->
|
||
if before?
|
||
@inside.insertBefore digit, before
|
||
else if not @inside.children.length
|
||
@inside.appendChild digit
|
||
else
|
||
@inside.insertBefore digit, @inside.children[0]
|
||
|
||
addSpacer: (chr, before, extraClasses) ->
|
||
spacer = createFromHTML FORMAT_MARK_HTML
|
||
spacer.innerHTML = chr
|
||
addClass(spacer, extraClasses) if extraClasses
|
||
@insertDigit spacer, before
|
||
|
||
addDigit: (value, repeating=true) ->
|
||
if value is '-'
|
||
return @addSpacer value, null, 'odometer-negation-mark'
|
||
|
||
if value is '.'
|
||
return @addSpacer (@format.radix ? '.'), null, 'odometer-radix-mark'
|
||
|
||
if repeating
|
||
resetted = false
|
||
while true
|
||
if not @format.repeating.length
|
||
if resetted
|
||
throw new Error "Bad odometer format without digits"
|
||
|
||
@resetFormat()
|
||
resetted = true
|
||
|
||
chr = @format.repeating[@format.repeating.length - 1]
|
||
@format.repeating = @format.repeating.substring(0, @format.repeating.length - 1)
|
||
|
||
break if chr is 'd'
|
||
|
||
@addSpacer chr
|
||
|
||
digit = @renderDigit()
|
||
digit.querySelector('.odometer-value').innerHTML = value
|
||
@digits.push digit
|
||
|
||
@insertDigit digit
|
||
|
||
animate: (newValue) ->
|
||
if not TRANSITION_SUPPORT or @options.animation is 'count'
|
||
@animateCount newValue
|
||
else
|
||
@animateSlide newValue
|
||
|
||
animateCount: (newValue) ->
|
||
return unless diff = +newValue - @value
|
||
|
||
start = last = now()
|
||
|
||
cur = @value
|
||
do tick = =>
|
||
if (now() - start) > @options.duration
|
||
@value = newValue
|
||
@render()
|
||
trigger @el, 'odometerdone'
|
||
return
|
||
|
||
delta = now() - last
|
||
|
||
if delta > COUNT_MS_PER_FRAME
|
||
last = now()
|
||
|
||
fraction = delta / @options.duration
|
||
dist = diff * fraction
|
||
|
||
cur += dist
|
||
@render Math.round cur
|
||
|
||
if requestAnimationFrame?
|
||
requestAnimationFrame tick
|
||
else
|
||
setTimeout tick, COUNT_MS_PER_FRAME
|
||
|
||
getDigitCount: (values...) ->
|
||
for value, i in values
|
||
values[i] = Math.abs(value)
|
||
|
||
max = Math.max values...
|
||
|
||
Math.ceil(Math.log(max + 1) / Math.log(10))
|
||
|
||
getFractionalDigitCount: (values...) ->
|
||
# This assumes the value has already been rounded to
|
||
# @format.precision places
|
||
#
|
||
parser = /^\-?\d*\.(\d*?)0*$/
|
||
for value, i in values
|
||
values[i] = value.toString()
|
||
|
||
parts = parser.exec values[i]
|
||
|
||
if not parts?
|
||
values[i] = 0
|
||
else
|
||
values[i] = parts[1].length
|
||
|
||
Math.max values...
|
||
|
||
resetDigits: ->
|
||
@digits = []
|
||
@ribbons = []
|
||
@inside.innerHTML = ''
|
||
@resetFormat()
|
||
|
||
animateSlide: (newValue) ->
|
||
oldValue = @value
|
||
|
||
fractionalCount = @getFractionalDigitCount oldValue, newValue
|
||
|
||
if fractionalCount
|
||
newValue = newValue * Math.pow(10, fractionalCount)
|
||
oldValue = oldValue * Math.pow(10, fractionalCount)
|
||
|
||
return unless diff = newValue - oldValue
|
||
|
||
@bindTransitionEnd()
|
||
|
||
digitCount = @getDigitCount(oldValue, newValue)
|
||
|
||
digits = []
|
||
boosted = 0
|
||
# We create a array to represent the series of digits which should be
|
||
# animated in each column
|
||
for i in [0...digitCount]
|
||
start = truncate(oldValue / Math.pow(10, (digitCount - i - 1)))
|
||
end = truncate(newValue / Math.pow(10, (digitCount - i - 1)))
|
||
|
||
dist = end - start
|
||
|
||
if Math.abs(dist) > @MAX_VALUES
|
||
# We need to subsample
|
||
frames = []
|
||
|
||
# Subsequent digits need to be faster than previous ones
|
||
incr = dist / (@MAX_VALUES + @MAX_VALUES * boosted * DIGIT_SPEEDBOOST)
|
||
cur = start
|
||
|
||
while (dist > 0 and cur < end) or (dist < 0 and cur > end)
|
||
frames.push Math.round cur
|
||
cur += incr
|
||
|
||
if frames[frames.length - 1] isnt end
|
||
frames.push end
|
||
|
||
boosted++
|
||
else
|
||
frames = [start..end]
|
||
|
||
# We only care about the last digit
|
||
for frame, i in frames
|
||
frames[i] = Math.abs(frame % 10)
|
||
|
||
digits.push frames
|
||
|
||
@resetDigits()
|
||
|
||
for frames, i in digits.reverse()
|
||
if not @digits[i]
|
||
@addDigit ' ', (i >= fractionalCount)
|
||
|
||
@ribbons[i] ?= @digits[i].querySelector('.odometer-ribbon-inner')
|
||
@ribbons[i].innerHTML = ''
|
||
|
||
if diff < 0
|
||
frames = frames.reverse()
|
||
|
||
for frame, j in frames
|
||
numEl = document.createElement('div')
|
||
numEl.className = 'odometer-value'
|
||
numEl.innerHTML = frame
|
||
|
||
@ribbons[i].appendChild numEl
|
||
|
||
if j == frames.length - 1
|
||
addClass numEl, 'odometer-last-value'
|
||
if j == 0
|
||
addClass numEl, 'odometer-first-value'
|
||
|
||
if start < 0
|
||
@addDigit '-'
|
||
|
||
mark = @inside.querySelector('.odometer-radix-mark')
|
||
mark.parent.removeChild(mark) if mark?
|
||
|
||
if fractionalCount
|
||
@addSpacer @format.radix, @digits[fractionalCount - 1], 'odometer-radix-mark'
|
||
|
||
Odometer.options = window.odometerOptions ? {}
|
||
|
||
setTimeout ->
|
||
# We do this in a seperate pass to allow people to set
|
||
# window.odometerOptions after bringing the file in.
|
||
if window.odometerOptions
|
||
for k, v of window.odometerOptions
|
||
Odometer.options[k] ?= v
|
||
, 0
|
||
|
||
Odometer.init = ->
|
||
if not document.querySelectorAll?
|
||
# IE 7 or 8 in Quirksmode
|
||
return
|
||
|
||
elements = document.querySelectorAll (Odometer.options.selector or '.odometer')
|
||
|
||
for el in elements
|
||
el.odometer = new Odometer {el, value: (el.innerText ? el.textContent)}
|
||
|
||
if document.documentElement?.doScroll? and document.createEventObject?
|
||
# IE < 9
|
||
_old = document.onreadystatechange
|
||
document.onreadystatechange = ->
|
||
if document.readyState is 'complete' and Odometer.options.auto isnt false
|
||
Odometer.init()
|
||
|
||
_old?.apply this, arguments
|
||
else
|
||
document.addEventListener 'DOMContentLoaded', ->
|
||
if Odometer.options.auto isnt false
|
||
Odometer.init()
|
||
, false
|
||
|
||
|
||
if typeof define is 'function' and define.amd
|
||
# AMD. Register as an anonymous module.
|
||
define ['jquery'], ->
|
||
Odometer
|
||
else if typeof exports is not 'undefined'
|
||
# CommonJS
|
||
module.exports = Odometer
|
||
else
|
||
# Browser globals
|
||
window.Odometer = Odometer
|