reimandlab/Visualistion-Framework-for-Genome-Mutations

View on GitHub
website/static/tracks.js

Summary

Maintainability
F
5 days
Test Coverage
var Tracks = function ()
{
    var scale = 1.0
    var scrollArea, scalableArea, scalableElem, tracks
    var needle_plot
    var position = 0
    var dispatch = d3.dispatch('zoomAndMove')
    var first_scale_factor
    var sequence_elements, baseCharSize
    var is_ready = false
    var controls, box
    var is_animation_running
    var tooltip

    var config = {
        scroll_step: 0.8, // how many screen (to left or right) should be scrolled after clicking scroll button
        zoom_step: 2,
        zoom_speed: 100,
        animations_speed: 300,
        animations_ease: 'quad',
        box: null,
        min_zoom: 1,
        max_zoom: 10
    };

    function configure(new_config)
    {
        // Automatic configuration update:
        update_object(config, new_config)
    }

    function calculate_zoom(direction)
    {
        // scale down slower toward 0
        var new_zoom = scale + direction * scale / 15;

        if(new_zoom > config.max_zoom)
        {
            return config.max_zoom
        }
        else if(new_zoom < config.min_zoom)
        {
            return config.min_zoom
        }
        else
        {
            return new_zoom
        }
    }

    function get_scale_factor()
    {
        var content_width = scalableElem.scrollWidth;
        var width = scalableArea.width();

        return width / content_width
    }

    function _setAAPosition(new_position, stop_callback, update_zoom)
    {
        position = trim_position(new_position)

        scrollTo(position)

        if(update_zoom)
            _setZoom(scale, true)

        if(!stop_callback && needle_plot)
        {
            needle_plot.setAAPosition(position, true)
        }
        dispatch.zoomAndMove(this)

    }

    function _getZoom()
    {
        return first_scale_factor * scale
    }

    function _setZoom(new_zoom, stop_callback)
    {
        return _setZoomAndMove(new_zoom, _getAAPosition(), stop_callback, true)
    }

    function trim_position(new_position, new_zoom, skip_zoom_effect)
    {
        if(new_position < 0)
            return 0
        else {
            var end = config.sequenceLength

            if(!skip_zoom_effect)
                end -= _visibleAminoacidsCount(new_zoom)

            if(new_position > end)
                return end
            else
                return new_position
        }
    }

    function _setZoomAndMove(new_zoom, new_pos, stop_callback, skip_animation, center_view)
    {
        new_pos = trim_position(new_pos, new_zoom)

        var styles = scalableElem.style;

        var invoke_callback = (!stop_callback && needle_plot)

        var initial_scale = scale

        var step = function(now, fx)
        {
            if(fx.prop === 'p')
            {
                if(center_view)
                    return

                position = now
                scrollTo(position)
                return
            }

            scale = now
            config.char_size = getCharSize()

            if(center_view)
            {
                // as I want the chosen aminoacid to remain in place when zooming:
                // central = first_visible + visible_count / 2
                // I use the central before zoom and central after zoom to find
                // the first_visible_after_zoom which satisfies condition: central=const
                // new_pos is the first visible aminoacid to be targeted,
                // position is the first visible aminoacid to be shown in this step
                position = new_pos + _visibleAminoacidsCount() / 2 * (1 - initial_scale / scale)
            }

            styles.transform = 'scaleX(' +  first_scale_factor * scale + ')'
            scrollTo(position)

            if(invoke_callback)
                needle_plot.setZoomAndAAPosition(scale, position, true)

        }

        is_animation_running = true
        $({s: scale, p: position})
            .animate(
                {s: new_zoom, p: new_pos},
                {
                    duration: skip_animation ? 0 : config.zoom_speed,
                    ease: config.animations_ease,
                    queue: false,
                    step: step,
                    complete: function() {
                        dispatch.zoomAndMove(this)
                        is_animation_running = false
                    }
                }
            )

    }

    function zoomKeepingCentral(direction)
    {
        var zoom = calculate_zoom(direction)
        var first_visible_aa = _getAAPosition()
        _setZoomAndMove(zoom, first_visible_aa, false, false, true)
    }

    function zoomIn()
    {
        zoomKeepingCentral(+config.zoom_step)
    }

    function zoomOut()
    {
        zoomKeepingCentral(-config.zoom_step)
    }

    function _getAAPosition()
    {
        return position
    }

    function _visibleAminoacidsCount(scale_to_use)
    {
        return config.min_zoom * config.sequenceLength / (scale_to_use ? scale_to_use : scale)
    }

    /**
     * Move by one screen in given direction
     * @param direction - move one screen right if +1, one screen left if -1
     * @param {boolean} animate - should the scrolling be animated?
     */
    function scroll(direction, animate)
    {
        var new_pos_screen = _getAAPosition() + _visibleAminoacidsCount() * direction
        new_pos_screen = trim_position(new_pos_screen)

        if(new_pos_screen === position)
            return

        is_animation_running = true
        $({screen_position: position})
            .animate(
                {screen_position: new_pos_screen},
                {
                    duration: animate ? config.animations_speed : 0,
                    ease: config.animations_ease,
                    queue: false,
                    step: function(now)
                    {
                        position = now
                        scrollTo(now)
                        needle_plot.setAAPosition(now, true)
                        dispatch.zoomAndMove(this)
                    },
                    complete: function() {
                        is_animation_running = false
                    }
                }
            )
    }

    function scrollLeft()
    {
        scroll(-config.scroll_step, true)
    }

    function scrollRight()
    {
        scroll(+config.scroll_step, true)
    }

    function computeBaseCharSize()
    {
        sequence_elements.wrapInner('<span></span>');
        var span = sequence_elements.children('span');
        var charSize = span.innerWidth() / config.sequenceLength;
        span.contents().unwrap();
        return charSize;
    }

    function getCharSize()
    {
        return baseCharSize * _getZoom();
    }

    function getSequenceLength()
    {
        var seq = $.trim(sequence_elements.text())
        return seq.length
    }

    function scrollTo(new_position)
    {
        scrollArea.scrollLeft(new_position * config.char_size)
    }

    function parsePosition(position_string)
    {
        if(position_string === undefined)
            return undefined
        return trim_position(parseInt(position_string, 10), undefined, true)
    }

    function scrollToCallback()
    {
        var input = $('.scroll-to-input')

        // - 1: sequence is 1 based but position is 0 based
        var user_input = $(input).val()

        var pos
        var zoom

        var correct_expression = /^\s*(\d+)\s*([-|:]\s*(\d+))?\s*$/

        var matched_range = user_input.match(correct_expression)
        var feedback = $('.scroll-feedback')

        function warn() {
            feedback.html('<span class="glyphicon glyphicon-warning-sign"></span>')
        }

        if(matched_range)
        {
            // get range
            var range = [matched_range[1], matched_range[3]]

            // convert to int, trim if the values exceed protein length or 0
            range = range.map(parsePosition)

            // make first coordinate 0-based
            pos = range[0] - 1

            // if we have both parts of expression (two numbers)
            if(range[1] !== undefined)
            {
                var len = range[1] - range[0]

                // if user provides range with the second value being smaller,
                // it may be good to warn the user; moreover we do not want to
                // divide by zero.
                if(len < 1)
                    return warn()

                // calculate zoom such that we will see only the desired range
                zoom = config.min_zoom * config.sequenceLength / len

                // and trim it to max allowed zoom
                zoom = Math.min(zoom, config.max_zoom)
            }
            else
            {
                // zoom in as close to the mutation as we can get
                zoom = config.max_zoom

                // move the position to the center
                pos -= _visibleAminoacidsCount(zoom) / 2
            }
            // clear the feedback (the empty string is important!)
            feedback.html('')
        }
        else
        {
            // incorrect input
            warn()
        }

        // zoom and move: run callback, animate, do not attempt to center on given position
        _setZoomAndMove(zoom, pos, false, false, false)
    }

    function initButtons(buttons, func, context)
    {
        for(var i = 0; i < buttons.length; i++)
        {
            var button = $(buttons[i])
            if(context)
            {
                button.click($.proxy(func, context))
            }
            else
            {
                button.click(func)
            }
        }
    }

    function initFields(fields, func)
    {
        function call(e)
        {
            if(e.keyCode === 13)
            {
                func.call(this)
            }
        }

        for(var i = 0; i < fields.length; i++)
        {
            $(fields[i]).keyup(call)
        }
    }

    function initControls()
    {
        var buttons = controls.find('.scroll-left')
        initButtons(buttons, scrollLeft, scrollArea)

        buttons = controls.find('.scroll-right')
        initButtons(buttons, scrollRight, scrollArea)

        var innerDiv = box.children('.inner')

        buttons = controls.find('.zoom-out')
        initButtons(buttons, zoomOut, innerDiv)

        buttons = controls.find('.zoom-in')
        initButtons(buttons, zoomIn, innerDiv)

        buttons = controls.find('.scroll-to')
        initButtons(buttons, scrollToCallback)

        buttons = controls.find('.scroll-to-input')
        initFields(buttons, scrollToCallback)
    }

    function onManualScroll(event)
    {
        var scroll = $(event.target).scrollLeft()

        if(!is_animation_running)
        {
            _setAAPosition(scroll / config.char_size, false)
        }
    }

    var publicSpace = {
        init: function(new_config)
        {
            configure(new_config)

            box = $(config.box)
            tracks = box.find('.tracks')

            scrollArea = tracks.find('.scroll-area')
            scrollArea.on('scroll', onManualScroll)
            scalableArea = tracks.find('.scalable')
            scalableElem = scalableArea.get(0)

            var sequence = tracks.find('.sequence')
            sequence_elements = sequence.children('.elements')

            var conservation = tracks.find('.conservation')
            var scores = conservation.find('i')

            var values = scores.map(function(i, obj){
                return parseFloat($(this).attr('v'))
            })

            var min = Math.min.apply(null, values)
            var max = Math.max.apply(null, values)
            scores.each(function(i, obj)
            {
                var obj = $(this)
                var value = parseFloat(obj.attr('v'))
                var text = value + ': '
                if(value >= 0){
                    obj.css('background', 'rgb(' + (255 - (value / max * 255)) + ', 255, 255)')
                    text += 'conserved'
                }
                else {
                    var r = 255 - (value / min * 255)
                    obj.css('background', 'rgb(255, ' + r + ', ' + r + ')')
                    text += 'accelerated'
                }
                obj.attr('title', text)
            })


            config.sequenceLength = getSequenceLength()
            config.char_size = getCharSize()

            controls = $($.find('.tracks-controls'))

            initControls()

            publicSpace.refreshScaleFactor()

            _setZoom(1)

            // initialize all popovers on tracks
            tooltip = Tooltip()
            tooltip.init({
                id: 'tracks',
                template: function(data){
                    var elem = $(this)
                    var templated = (
                        '<h5>' + elem.data('title') + '</h5>' +
                        elem.data('content')
                    )
                    return templated
                },
                viewport: scrollArea.get(0)
            })

            var kinases = d3.selectAll('.has-tooltip')
                .each(function(data){
                    // if we add tooltips, let's remove original title tooltip
                    // and reuse this data in the new tooltip
                    $(this).data('title', this.title)
                    this.title = ''
                })
                .call(tooltip.bind)

            dispatch.on('zoomAndMove', function(){
                tooltip.moveToElement()
            })

            $('.subtracks_collapsed').click(function(){
                var track_name = $(this).data('track');
                $('.' + track_name + ' .collapsible').toggleClass('hidden')
            })

            publicSpace.show()

            is_ready = true;
        },
        setNeedlePlotInstance: function(instance)
        {
            needle_plot = instance
        },
        setZoom: _setZoom,
        setAAPosition: _setAAPosition,
        adjustMaxZoom: function()
        {
            // optimal max zoom is a zoom which allows to zoom in to a normal size of a character
            config.max_zoom = 1 / first_scale_factor
            return config.max_zoom
        },
        setZoomAndMove: _setZoomAndMove,
        refreshScaleFactor: function()
        {
            first_scale_factor = get_scale_factor();
            baseCharSize = computeBaseCharSize();
        },
        isReady: function()
        {
            return is_ready;
        },
        hide: function()
        {
            box.find('.tracks-box').addClass('invisible');
            controls.addClass('invisible');

            if(config.animations_speed)
                box.animate({height: 0}, config.animations_speed);
        },
        show: function()
        {
            var internal_box = box.find('.tracks-box');
            internal_box.removeClass('invisible');
            controls.removeClass('invisible');

            if(config.animations_speed)
                box.animate(
                    {height: internal_box.height()},
                    {
                        duration: config.animations_speed,
                        complete: function () {
                            // reset to original value (so domains can be expanded)
                            box.css('height', '')
                        }
                    }
                );
        },
        scrollTo: function (position) {
            $('.scroll-to-input').val(position)
            scrollToCallback();
        },
        destroy: function ()
        {
            if(tooltip) tooltip.remove()
        }

    }

    return publicSpace
}