543 lines
14 KiB
CoffeeScript
543 lines
14 KiB
CoffeeScript
|
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
|