timeline/js/timeline.js
author veltr
Wed, 04 Jul 2012 16:13:49 +0200
changeset 73 642ef9139fad
parent 72 a000f6a29dfa
child 74 e107c77600e8
permissions -rw-r--r--
Replaced "Production" by "Publication". Added beautiful arrows

/*
 * Main Timeline code
 */

window.Tlns = {
    Utils : {},
    Defaults : {},
    Templates : {},
    Classes : {}
};

/* Utility Functions */

Tlns.Utils.SetDefaults = function(_object, _defaults, _options) {
    var _options = _options || {};
    _(_defaults).each(function(_v, _k) {
        if(/^m(in|ax)_/.test(_k)) {
            var _tab = _k.split('_')
            if( typeof _object[_tab[1]] !== "undefined") {
                var _fn = (_tab[0] === "min" ? Math.max : Math.min);
                _object[_tab[1]] = _fn(_object[_tab[1]], _v);
            }
        } else {
            if( typeof _options[_k] !== "undefined") {
                _object[_k] = _options[_k];
            } else {
                _object[_k] = _v;
            }
        }
    });
}

Tlns.Utils.dateFormat = function(_date, _template) {
    if (typeof _data !== "object") {
        _date = new Date(_date);
    }
    function zeroPad(_n) {
        return (_n < 10 ? "0" : "") + _n
    }
    var _params = {
        hours: _date.getHours(),
        "0hours": zeroPad(_date.getHours()),
        minutes: _date.getMinutes(),
        "0minutes": zeroPad(_date.getMinutes()),
        seconds: _date.getSeconds(),
        "0seconds": zeroPad(_date.getSeconds()),
        dayOfWeek: ["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"][_date.getDay()],
        shortDayOfWeek: ["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"][_date.getDay()],
        dayOfMonth: _date.getDate(),
        "0dayOfMonth": zeroPad(_date.getDate()),
        monthNumber: 1+_date.getMonth(),
        "0monthNumber": zeroPad(1+_date.getMonth()),
        monthName: ["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"][_date.getMonth()],
        shortMonthName: ["jan","fev","mar","avr","mai","jun","jul","aou","sep","oct","nov","dec"][_date.getMonth()],
        year: _date.getFullYear()
    }
    return Mustache.to_html(_template, _params);
}

Tlns.Utils.guid = function() {
    return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g,function() {
        return Math.floor(Math.random()*16).toString(16);
    });
}

Tlns.Utils.drawArrow = function(_ctx, _color, _x1, _y1, _x2, _y2) {
    _ctx.strokeStyle = _color;
    _ctx.fillStyle = _color;
    _ctx.beginPath();
    _ctx.moveTo(_x1,_y1);
    _ctx.lineTo(_x2,_y2);
    _ctx.stroke();
    var _mod = Math.sqrt(Math.pow(_x2 - _x1, 2) + Math.pow(_y2 - _y1, 2)),
        _xu = (_x2 - _x1) / _mod,
        _yu = (_y2 - _y1) / _mod,
        _xm = (_x1 + _x2) / 2,
        _ym = (_y1 + _y2) / 2,
        _arrowWidth = 4,
        _arrowLength = 8,
        _x3 = _xm - _arrowLength * _xu + _arrowWidth * _yu,
        _y3 = _ym - _arrowLength * _yu - _arrowWidth * _xu,
        _x4 = _xm - _arrowLength * _xu - _arrowWidth * _yu,
        _y4 = _ym - _arrowLength * _yu + _arrowWidth * _xu;
    _ctx.beginPath();
    _ctx.moveTo(_x3, _y3);
    _ctx.lineTo(_xm, _ym);
    _ctx.lineTo(_x4, _y4);
    _ctx.fill();
    _ctx.stroke();
}

/* Defaults */

Tlns.Defaults.Timeline = {
    container : "timeline",
    width : 950,
    height : 200,
    url_univers : '',
    min_width : 400,
    min_height : 100,
    main_width : 800,
    timescales : [{
        label : "Mois",
        span : 32 * 86400 * 1000,
        grid_interval : 5 * 86400 * 1000,
        grid_date_format : '{{dayOfMonth}} {{shortMonthName}}',
        start_date_format : '{{dayOfMonth}} {{shortMonthName}}',
        end_date_format : '{{dayOfMonth}} {{shortMonthName}} {{year}}'
    }, {
        label : "Semaine",
        span : 8 * 86400 * 1000,
        grid_interval : 86400 * 1000,
        grid_date_format : '{{shortDayOfWeek}} {{0dayOfMonth}}/{{0monthNumber}}',
        start_date_format : '{{dayOfMonth}} {{shortMonthName}}',
        end_date_format : '{{dayOfMonth}} {{shortMonthName}}'
    }, {
        label : "2 jours",
        span : 2 * 86400 * 1000,
        grid_interval : 8 * 3600 * 1000,
        grid_date_format : '{{shortDayOfWeek}} {{0dayOfMonth}}/{{0monthNumber}} {{hours}}h',
        start_date_format : '{{dayOfMonth}} {{shortMonthName}}',
        end_date_format : '{{dayOfMonth}} {{shortMonthName}}'
    }, {
        label : "Demi-Journée",
        span : 12 * 3600 * 1000,
        grid_interval : 2 * 3600 * 1000,
        grid_date_format : '{{hours}}h',
        start_date_format : '{{dayOfMonth}} {{shortMonthName}} {{hours}}h',
        end_date_format : '{{dayOfMonth}} {{shortMonthName}} {{hours}}h'
    }, {
        label : "3 Heures",
        span : 3 * 3600 * 1000,
        grid_interval : 30 * 60 * 1000,
        grid_date_format : '{{0hours}}:{{0minutes}}',
        start_date_format : '{{dayOfMonth}} {{shortMonthName}} {{0hours}}:{{0minutes}}',
        end_date_format : '{{0hours}}:{{0minutes}}'
    }, {
        label : "1 Heure",
        span : 60 * 60 * 1000,
        grid_interval : 15 * 60 * 1000,
        grid_date_format : '{{0hours}}:{{0minutes}}',
        start_date_format : '{{dayOfMonth}} {{shortMonthName}} {{0hours}}:{{0minutes}}',
        end_date_format : '{{0hours}}:{{0minutes}}'
    }],
    level: 0,
    central_time: 0,
    sync_now: true,
    url_occurrences: '',
    occurrences: [],
    cluster_spacing: 10,
    tooltip_date_format: '{{dayOfMonth}} {{shortMonthName}} {{year}} {{0hours}}:{{0minutes}}'
}

for (var _i = 0; _i < Tlns.Defaults.Timeline.timescales.length; _i++) {
    Tlns.Defaults.Timeline.timescales[_i].level = _i;
}

/* Templates */

Tlns.Templates.Timeline = '<div class="Tl-TopBar"><div class="Tl-TopBar-Button Tl-Border-Right"><div class="Tl-TopBar-AddButton"></div></div><div class="Tl-TopBar-Spacer Tl-Border-Right"></div>'
    + '<div class="Tl-TopBar-Button Tl-Border-Right"><div class="Tl-TopBar-PreviousButton"></div></div><div class="Tl-TopBar-TimeSpan Tl-TopBar-TextBtn Tl-Border-Right">--/--</div>'
    + '<div class="Tl-TopBar-Button Tl-Border-Right"><div class="Tl-TopBar-SyncButton"></div></div><div class="Tl-TopBar-Button Tl-Border-Right"><div class="Tl-TopBar-NextButton"></div></div><div class="Tl-TopBar-Spacer Tl-Border-Right"></div>'
    + '<div class="Tl-TopBar-Timescales">{{#timescales}}<div class="Tl-TopBar-Button Tl-TopBar-TextBtn Tl-Border-Right" data-level="{{level}}">{{label}}</div>{{/timescales}}</div></div>'
    + '<div class="Tl-BottomPart"><ul class="Tl-UniversLabels"></ul>'
    + '<div class="Tl-MainPart"><div class="Tl-Layer Tl-Grid"></div><canvas class="Tl-Layer Tl-Canvas"></canvas><canvas class="Tl-Layer Tl-Linking-Canvas"></canvas><div class="Tl-Layer Tl-Occurrences"></div>'
    + '<ul class="Tl-Adding"><li class="Tl-AddingTitle">Ajout d\'une occurrence</li><li><span>Narrative</span><div class="Tl-AddOccurrence Tl-Occnarrative" occurrence-type="narrative" title="Glisser sur la frise chronologique pour ajouter"></div></li>'
    + '<li><span>De Publication</span><div class="Tl-AddOccurrence Tl-Occpublication" occurrence-type="publication" title="Glisser sur la frise chronologique pour ajouter"></div></li></ul></div>'
    + '<div class="Tl-Overlay-Container"><div class="Tl-Overlay-Box"><div class="Tl-Overlay"><div class="Tl-Overlay-Tip-Top"></div><div class="Tl-Overlay-Main"></div><div class="Tl-Overlay-Tip-Bottom"></div></div></div></div></div>';

Tlns.Templates.Univers = '<span class="Tl-UniversText">{{title}}</span>';

Tlns.Templates.Occurrence = '{{#clusters}}<div class="Tl-Cluster Tl-Occ{{type}}" style="left: {{x}}px; top: {{y}}px;" cluster-contents="{{contents}}">'
    + '<div class="Tl-ClusterCount">{{occurrences.length}}</div></div>{{/clusters}}'
    + '{{#occurrences}}<div class="Tl-Occurrence Tl-OccOnGrid Tl-Occ{{type}}{{#editing}} Tl-Editing{{/editing}}" occurrence-id="{{id}}" style="left: {{x}}px; top: {{y}}px;">'
    + '{{#locked}}<div class="Tl-Locked"></div>{{/locked}}<div class="Tl-Link"{{#editing}} style="display: block"{{/editing}}></div></div>{{/occurrences}}{{#open_cluster}}<div class="Tl-ClusterOverlay" style="left: {{x}}px; top: {{y}}px;">'
    + '{{#occurrences}}<div class="Tl-Occurrence Tl-OccInCluster Tl-Occ{{type}}{{#editing}} Tl-Editing{{/editing}}" occurrence-id="{{id}}">'
    + '{{#locked}}<div class="Tl-Locked"></div>{{/locked}}<div class="Tl-Link"{{#editing}} style="display: block"{{/editing}}></div></div>{{/occurrences}}</div>{{/open_cluster}}';
    
Tlns.Templates.OccurrenceTooltip = '<h3 class="Tl-Tooltip-Title">{{title}}</h3><p class="Tl-Tooltip-Date">{{formatted_date}}</p>'
    + '<p class="Tl-Tooltip-Description">{{description}}</p><p class="Tl-Tooltip-Characters">{{univers.mainCharacter}}{{#characters}}, {{.}}{{/characters}}</p>'

/* Classes */

Tlns.Classes.Timeline = function(_options) {

    /* Setting Defaults */
    Tlns.Utils.SetDefaults(this, Tlns.Defaults.Timeline, _options);

    /* Setting container CSS */
    this.$ = $('#' + this.container);
    this.$.addClass('Tl-Main');
    this.$.css({
        width : this.width + "px",
        height : this.height + "px"
    });
    this.$.html(Mustache.to_html(Tlns.Templates.Timeline, this));
    
    this.main_height = this.height - this.$.find('.Tl-TopBar').outerHeight();
    this.$.find('.Tl-BottomPart').css("height", this.main_height + "px");
    this.$.find('.Tl-MainPart').css("width", this.main_width + "px");
    this.$.find('.Tl-Overlay-Container').css("left", (this.$.find('.Tl-BottomPart').outerWidth() - this.main_width) + "px");
    this.$.find('canvas.Tl-Layer').attr({
        width: this.main_width,
        height: this.main_height
    });
    var _o = this.$.find('.Tl-MainPart').offset();
    this.dragging_bounds = {
        left: _o.left,
        top: _o.top,
        right: _o.left + this.$.find('.Tl-MainPart').outerWidth(),
        bottom: _o.top + this.$.find('.Tl-MainPart').outerHeight(),
    };
    
    var _this = this;
    
    this.throttledDrawGrid = _.throttle(function() {
        _this.drawGrid();
    }, 150);
    
    this.setLevel(this.level);
    
    this.$.find('.Tl-TopBar-Timescales>div').click(function() {
        _this.setLevel($(this).attr("data-level"));
    });
    
    this.$.find('.Tl-TopBar-SyncButton').click(function() {
        _this.sync_now = !_this.sync_now;
        _this.drawGrid();
    })
    
    this.$.find('.Tl-TopBar-PreviousButton').click(function() {
        _this.offsetTime(-_this.timescales[_this.level].span / 4);
    });
    
    this.$.find('.Tl-TopBar-NextButton').click(function() {
        _this.offsetTime(_this.timescales[_this.level].span / 4);
    });
    
    this.$.find('.Tl-MainPart').mousedown(function(_event) {
        _this.onMouseDown(_event);
        return false;
    });
    
    this.$.find('.Tl-MainPart').mousemove(function(_event) {
        _this.onMouseMove(_event);
        return false;
    });
    
    this.$.find('.Tl-MainPart').mouseup(function(_event) {
        _this.onMouseUp(_event);
        return false;
    });
    
    this.$.find('.Tl-MainPart').mousewheel(function(_event, _delta) {
        var _newLevel = Math.max(0,Math.min(_this.timescales.length-1, (_delta < 0 ? -1 : 1) + parseInt(_this.level)));
        if (_newLevel != _this.level) {
            _this.hideTooltip();
            var _deltaX = _event.pageX - _this.dragging_bounds.left,
                _tAtMouse = _this.timeFromMouse(_event.pageX),
                _newScale = _this.main_width / (_this.timescales[_newLevel].span),
                _newStart = _tAtMouse - _deltaX / _newScale;
            _this.central_time = _newStart + _this.timescales[_newLevel].span / 2;
            _this.setLevel(_newLevel);
        }
        return false;
    });
    
    this.$.find('.Tl-Overlay-Box').mouseover(function(_event) {
        $(this).show();
    }).mouseout(function(_event) {
        $(this).hide();
    });
    
    this.$.find('.Tl-TopBar-AddButton').click(function() {
        $(this).toggleClass('active');
        _this.$.find('.Tl-Adding').toggle();
    });
    
    this.$.find('.Tl-AddOccurrence').mousedown(function(_event) {
        var _el = $(this),
            _type = _el.attr("occurrence-type"),
            _d = _this.timeFromMouse(_event.pageX),
            _u = _this.universFromMouse(_event.pageY),
            _occ = _this.createOrUpdateOccurrence(
                _type,
                {
                    date: _d,
                    titre: '<Nouvelle occurrence>',
                    univers: _this.univers[_u].id,
                    publie: true
                }
            );
        _occ.just_created = true;
        _occ.editing = true;
        _this.editing_occurrence = _occ;
        _this.dragging_type = "occurrence";
        window.setTimeout(function () {
            _this.$.find('.Tl-TopBar-AddButton').removeClass('active');
            _this.$.find('.Tl-Adding').hide();
        }, 200);
        _this.throttledDrawGrid();
    }).mouseup(function(_event) {
        _this.onMouseUp(_event);
        return false;
    });
    
    /* Loading Univers */
    $.getJSON(this.url_univers, function(_data) {
        _this.onUniversLoaded(_data);
    });
}

Tlns.Classes.Timeline.prototype.onMouseDown = function(_event) {
    this.mouse_down = true;
    this.is_dragging = false;
    this.start_pos = {
        x: _event.pageX,
        y: _event.pageY
    };
    if (typeof this.dragging_type === "undefined") {
        this.time_at_start = this.central_time;
        this.dragging_type = "timeline";
    }
}

Tlns.Classes.Timeline.prototype.onMouseUp = function(_event) {
    if (this.is_dragging) {
        switch (this.dragging_type) {
            case "occurrence":
                this.editing_occurrence.editing = false;
                this.editing_occurrence.just_created = false;
                this.throttledDrawGrid();
            break;
            case "link":
                this.editing_occurrence.editing = false;
                this.throttledDrawGrid();
                var _ctx = this.$.find('.Tl-Linking-Canvas')[0].getContext('2d');
                _ctx.clearRect(0,0,this.main_width, this.main_height);
            break;
        }
    } else {
        if (this.dragging_type == "occurrence" && this.editing_occurrence.just_created) {
            this.deleteOccurrence(this.editing_occurrence.type, this.editing_occurrence.id);
            this.throttledDrawGrid();
        }
    }
    this.mouse_down = false;
    this.is_dragging = false;
    this.dragging_type = undefined;
}

Tlns.Classes.Timeline.prototype.timeFromX = function(_x) {
    return Math.max(this.start_time,Math.min(this.end_time, this.start_time + _x / this.current_scale));
}

Tlns.Classes.Timeline.prototype.timeFromMouse = function(_pageX) {
    return this.timeFromX(_pageX - this.dragging_bounds.left);
}

Tlns.Classes.Timeline.prototype.universFromY = function(_y) {
    return Math.max(0,Math.min(this.univers.length, Math.floor(_y / this.univers_height)))
}

Tlns.Classes.Timeline.prototype.universFromMouse = function(_pageY) {
    return this.universFromY(_pageY - this.dragging_bounds.top);
}

Tlns.Classes.Timeline.prototype.onMouseMove = function(_event) {
    if (this.mouse_down) {
        this.is_dragging = true;
        this.hideTooltip();
        switch (this.dragging_type) {
            case "occurrence":
                var _d = this.timeFromMouse(_event.pageX);
                this.editing_occurrence.date = _d;
                var _u = this.universFromMouse(_event.pageY);
                this.editing_occurrence.univers = this.univers[_u];
                this.editing_occurrence.univers_id = this.univers[_u].id;
                this.throttledDrawGrid();
            break;
            case "timeline":
                this.setTime(this.time_at_start + Math.floor(( this.start_pos.x - _event.pageX ) / this.current_scale));
            break;
            case "link":
                var _ctx = this.$.find('.Tl-Linking-Canvas')[0].getContext('2d');
                _ctx.clearRect(0,0,this.main_width, this.main_height);
                Tlns.Utils.drawArrow(
                    _ctx,
                    '#800080',
                    this.editing_occurrence.x,
                    this.editing_occurrence.y + Math.floor(this.univers_height / 2),
                    _event.pageX - this.dragging_bounds.left,
                    _event.pageY - this.dragging_bounds.top
                );
            break;
        }
    }
}

Tlns.Classes.Timeline.prototype.onUniversLoaded = function(_data) {
    this.univers = [];
    if(_data.length) {
        this.univers_height = Math.floor(this.main_height / _data.length);
    }
    for(var _i = 0; _i < _data.length; _i++) {
        this.univers.push(new Tlns.Classes.Univers(_data[_i], this, _i));
    }
    this.loadOccurrences();
}

Tlns.Classes.Timeline.prototype.offsetTime = function(_timeOffset) {
    this.setTime(this.central_time + _timeOffset);
}

Tlns.Classes.Timeline.prototype.setTime = function(_centralTime) {
    this.sync_now = false;
    this.central_time = _centralTime;
    this.throttledDrawGrid();
}

Tlns.Classes.Timeline.prototype.setLevel = function(_level) {
    if (_level >= 0 && _level < this.timescales.length) {
        this.$.find('.Tl-TopBar-Timescales>div').each(function() {
            var _el = $(this);
            if (_el.attr("data-level") == _level) {
                _el.addClass("active");
            } else {
                _el.removeClass("active");
            }
        });
        this.level = _level;
        this.throttledDrawGrid();
    }
}

Tlns.Classes.Timeline.prototype.drawGrid = function() {
    var _now = new Date().valueOf();
    if (this.sync_now) {
        this.central_time = _now;
        this.$.find('.Tl-TopBar-SyncButton').addClass("active");
    } else {
        this.$.find('.Tl-TopBar-SyncButton').removeClass("active");
    }
    var _timescale = this.timescales[this.level],
        _offset = new Date().getTimezoneOffset() * 60000;
    this.current_scale = this.main_width / (_timescale.span)
    this.start_time = this.central_time - (_timescale.span / 2);
    this.end_time = this.central_time + (_timescale.span / 2);
    var _grid_width = Math.floor(_timescale.grid_interval * this.current_scale),
        _roundstart = Math.floor((this.start_time - _offset) / _timescale.grid_interval) * _timescale.grid_interval + _offset,
        _html = '';
    this.$.find('.Tl-TopBar-TimeSpan').html(Tlns.Utils.dateFormat(this.start_time, _timescale.start_date_format) + ' - ' + Tlns.Utils.dateFormat(this.end_time, _timescale.end_date_format));
    for (var _t = _roundstart; _t < this.end_time; _t += _timescale.grid_interval) {
        var _x = this.current_scale * (_t - this.start_time);
        if (_x > 0) {
            _html += '<div class="Tl-Grid-Column" style="width:' + _grid_width + 'px; left: ' + _x + 'px">'
            + '<div class="Tl-Grid-Label">' + Tlns.Utils.dateFormat(_t, _timescale.grid_date_format) + '</div></div>';
        }
    }
/*
 
    for (var _t = _roundstart; _t < _tmax; _t += _timescale.grid_interval) {
        var _isMajor = !(Math.floor((_t - _offset) / _timescale.grid_interval) % _timescale.major_interval);
        _html += '<div class="Tl-Grid-Column' + ( _isMajor ? ' Tl-Grid-Major' : '' ) + '" style="width:' + _grid_width + 'px; left: ' + _scale * (_t - this.start_time) + 'px">'
        + ( _isMajor ? '<div class="Tl-Grid-Label">' + Tlns.Utils.dateFormat(_t, _timescale.date_format) + '</div>' : '' ) + '</div>';
    }
*/
    if (this.start_time <= _now && this.end_time >= _now) {
        _html += '<div class="Tl-Grid-Now" style="left: ' + this.current_scale * (_now - this.start_time) + 'px"></div>'
    }
    this.$.find('.Tl-Grid').html(_html);
    this.drawOccurrences();
}

Tlns.Classes.Timeline.prototype.loadOccurrences = function() {
    var _url = Mustache.to_html(this.url_occurrences, {
            from_time: this.start_time,
            to_time: this.end_time
        }),
        _this = this;
    $.getJSON(_url, function(_data) {
        _this.onOccurrencesLoaded(_data);
    });
}

Tlns.Classes.Timeline.prototype.onOccurrencesLoaded = function(_data) {
    if (typeof _data.occurrencesNarratives === "object" && _data.occurrencesNarratives !== null) {
        for (var _i = 0; _i < _data.occurrencesNarratives.length; _i++) {
            this.createOrUpdateOccurrence("narrative", _data.occurrencesNarratives[_i]);
        }
        for (var _i = 0; _i < _data.occurrencesPublication.length; _i++) {
            this.createOrUpdateOccurrence("publication", _data.occurrencesPublication[_i]);
        }
    }
    if (!this.mouse_down) {
        this.drawOccurrences();
    }
}

Tlns.Classes.Timeline.prototype.deleteOccurrence = function(_id) {
    this.occurrences = _(this.occurrences).reject(function(_occ) {
        return occ.id == _id;
    });
}

Tlns.Classes.Timeline.prototype.getOccurrence = function(_id) {
    return _(this.occurrences).find(function(_occ) {
        return _occ.id == _id;
    });
}

Tlns.Classes.Timeline.prototype.createOrUpdateOccurrence = function(_type, _data) {
    var _id = _type + "_" + _data.id,
        _occurrence = this.getOccurrence(_id);
    if (typeof _occurrence === "undefined") {
        _occurrence = new Tlns.Classes.Occurrence(this);
        this.occurrences.push(_occurrence);
    }
    _occurrence.update(_type, _data);
    return _occurrence;
}

Tlns.Classes.Timeline.prototype.showTooltip = function(_x, _y, _html, _isUp) {
    this.$.find('.Tl-Overlay-Box')
        .removeClass(_isUp ? 'Tl-Overlay-Down' : 'Tl-Overlay-Up')
        .addClass(_isUp ? 'Tl-Overlay-Up' : 'Tl-Overlay-Down')
        .show()
        .css({
            left: _x + "px",
            top: _y + "px"
        });
    this.$.find('.Tl-Overlay-Main').html(_html);
}

Tlns.Classes.Timeline.prototype.hideTooltip = function() {
    this.$.find('.Tl-Overlay-Box').hide();
}

Tlns.Classes.Timeline.prototype.drawOccurrences = function() {
    var _this = this,
        _visible = _(this.occurrences).filter(function(_occ) {
        return (_occ.date >= _this.start_time && _occ.date <= _this.end_time && _occ.published);
    });
    _(_visible).each(function(_occ) {
        _occ.x = _this.current_scale * (_occ.date - _this.start_time);
        _occ.y = _occ.univers.y;
        _occ.in_cluster = false;
    });
    
    var _moved = true;
    while (_moved) {
        _moved = false;
        for (var _i = 0; _i < _visible.length; _i++) {
            for (var _j = 0; _j < _i; _j++) {
                if (_visible[_j].univers_id == _visible[_i].univers_id
                    && _visible[_j].x != _visible[_i].x
                    && Math.abs(_visible[_j].x-_visible[_i].x) < this.cluster_spacing
                ) {
                    _moved = true;
                    _visible[_i].x = this.cluster_spacing * Math.round(_visible[_i].x / this.cluster_spacing);
                    _visible[_j].x = this.cluster_spacing * Math.round(_visible[_j].x / this.cluster_spacing);
                }
            }
        }
    }
    var _clusters = [],
        _openCluster = false;
    for (var _i = 0; _i < _visible.length; _i++) {
        for (var _j = 0; _j < _i; _j++) {
            if (_visible[_j].univers_id == _visible[_i].univers_id && _visible[_j].x == _visible[_i].x) {
                _visible[_j].in_cluster = true;
                _visible[_i].in_cluster = true;
                var _x = _visible[_j].x,
                    _y = _visible[_j].y;
                    _cluster = _(_clusters).find(function(_c) { return _c.x == _x && _c.y == _y });
                if (typeof _cluster === "undefined") {
                    _cluster = { x: _x, y: _y, occurrences: [] };
                    _clusters.push(_cluster);
                }
                if ("undefined" === typeof _(_cluster.occurrences).find(function(_o) {
                    return _o.type == _visible[_j].type && _o.id == _visible[_j].id;
                })) {
                    _cluster.occurrences.push(_visible[_j]);
                }
                if ("undefined" === typeof _(_cluster.occurrences).find(function(_o) {
                    return _o.type == _visible[_i].type && _o.id == _visible[_i].id;
                })) {
                    _cluster.occurrences.push(_visible[_i]);
                }
            }
        }
    }
    _(_clusters).each(function(_cluster) {
        _cluster.occurrences = _(_cluster.occurrences).sortBy(function(_o) {
            return _o.date;
        });
        _cluster.type = _cluster.occurrences[0].type;
        _cluster.contents = _cluster.occurrences.map(function(_o) {
            return _o.type + ":" + _o.id;
        }).join("|");
        if (_cluster.contents == _this.open_cluster) {
            _openCluster = _cluster;
        }
        for (var _i = 1; _i < _cluster.occurrences.length; _i++) {
            if (_cluster.occurrences[_i].type !== _cluster.type) {
                _cluster.type = "both";
                break;
            }
        }
    });
    
    
    var _links = [];
    
    _(_visible).each(function(_occurrence) {
        _(_occurrence.dependsOn).each(function(_dependance) {
            var _parent = _(_visible).find(function(_o) {
                return _o.id == _dependance;
            });
            if (typeof _parent !== "undefined") {
                _links.push({
                    from_x: _occurrence.x,
                    from_y: _occurrence.y + Math.floor(_this.univers_height / 2),
                    to_x: _parent.x,
                    to_y: _parent.y + Math.floor(_this.univers_height / 2)
                });
            }
        });
    });
    
    var _ctx = this.$.find('.Tl-Canvas')[0].getContext('2d');
    _ctx.clearRect(0,0,this.main_width, this.main_height);
    _(_links).each(function(_link) {
        Tlns.Utils.drawArrow(_ctx, "#505050", _link.from_x,_link.from_y, _link.to_x,_link.to_y);
    });
    
    var _html = Mustache.to_html(Tlns.Templates.Occurrence, {
        occurrences:_(_visible).reject(function(_o) {return _o.in_cluster}),
        clusters: _clusters,
        open_cluster: _openCluster
    });
    this.$.find('.Tl-Occurrences').html(_html);
    
    
    if (_openCluster) {
        var _w = this.$.find('.Tl-Occurrence').width(),
            _ww = _w * _openCluster.occurrences.length;
        this.$.find('.Tl-ClusterOverlay').css({
            "margin-left": - Math.floor(_ww/2) + "px",
            width: _ww
        });
        _(_openCluster.occurrences).each(function(_o, _i) {
            _o.y = _o.y - 20;
            _o.x = _o.x - (_ww / 2) + ((_i + .5) * _w);
        });
    }
    
    this.$.find('.Tl-Occurrence').mousedown(function() {
        var _el = $(this),
            _id = _el.attr("occurrence-id");
        if (typeof _id !== "undefined") {
            _this.editing_occurrence = _this.getOccurrence(_id);
            if (typeof _this.dragging_type === "undefined" && typeof _this.editing_occurrence !== "undefined" && !_this.editing_occurrence.locked) {
                _this.dragging_type = "occurrence";
                _this.editing_occurrence.editing = true;
            }
        }
    }).mouseover(function(_event) {
        var _el = $(this),
            _id = _el.attr("occurrence-id");
        if (typeof _id !== "undefined") {
            var _occurrence = _this.getOccurrence(_id);
            if (!_occurrence.locked) {
                _el.find('.Tl-Link').show();
            }
            if (!_this.is_dragging) {
                _occurrence.formatted_date = Tlns.Utils.dateFormat(_occurrence.date,_this.tooltip_date_format);
                var _html = Mustache.to_html(Tlns.Templates.OccurrenceTooltip, _occurrence);
                _this.showTooltip(_occurrence.x, _occurrence.y, _html, (_event.pageY - _this.dragging_bounds.top) >= (.4 * _this.main_height) );
            }
        }
    }).mouseout(function() {
        var _el = $(this),
            _id = _el.attr("occurrence-id");
        if (typeof _id !== "undefined") {
            var _occurrence = _this.getOccurrence(_id);
            _this.hideTooltip();
            if (!_occurrence.editing) {
                $(this).find('.Tl-Link').hide();
            }
        }
    }).mouseup(function() {
        var _el = $(this);
        if (_this.dragging_type == "link") {
            _this.editing_occurrence.addDependency(_el.attr("occurrence-id"));
        }
    });
    
    this.$.find('.Tl-Link').mousedown(function() {
        var _el = $(this).parent(),
            _id = _el.attr("occurrence-id");
        _this.editing_occurrence = _this.getOccurrence(_id);
        if (typeof _this.editing_occurrence !== "undefined" && !_this.editing_occurrence.locked) {
            _this.dragging_type = "link";
            _this.editing_occurrence.editing = true;
        }
    })
    
    this.$.find('.Tl-Cluster').click(function() {
        var _el = $(this),
            _contents = _el.attr("cluster-contents");
        if (_this.open_cluster == _contents) {
            _this.open_cluster = false;
        } else {
            _this.open_cluster = _contents;
        }
        _this.throttledDrawGrid();
    })
}

Tlns.Classes.Timeline.prototype.getUnivers = function(_id) {
    return _(this.univers).find(function(_univ) {
        return (_univ.id == _id);
    });
}

/*
 * Univers
 */

Tlns.Classes.Univers = function(_data, _timeline, _index) {
    this.id = _data.id;
    this.index = _index;
    this.title = _data.nom;
    this.mainCharacter = _data.personnage;
    this.y = (_timeline.univers_height * _index);

    this.$label = $('<li>').css({
        height : _timeline.univers_height + "px"
    }).html(Mustache.to_html(Tlns.Templates.Univers, this))
      .addClass((_index % 2) ? 'Tl-Line-Odd' : 'Tl-Line-Even');
    
    _timeline.$.find('.Tl-UniversLabels').append(this.$label);
    var _txt = _data.nom,
        _span = this.$label.find('span');

    while (_span.outerWidth() > (_timeline.width - _timeline.main_width) && _txt) {
        _txt = _txt.substr(0, _txt.length - 1);
        _span.html(_txt + '&hellip;');
    }
}

/*
 * Occurrence
 */

Tlns.Classes.Occurrence = function(_timeline) {
    this.timeline = _timeline;
}

Tlns.Classes.Occurrence.prototype.update = function(_type, _data) {
    this.type = _type;
    this.original_id = _data.id || Tlns.Utils.guid();
    this.id = _type + "_" + this.original_id;
    this.date = _data.date || _data.datePublication;
    this.title = _data.titre || "<untitled>";
    this.univers_id = _data.univers;
    this.univers = this.timeline.getUnivers(this.univers_id);
    this.status = _data.statut;
    this.published = _data.publie || false;
    this.locked = _data.verrouille || false;
    this.characters = _data.personnagesSecondaires || [];
    this.dependsOn = _(_data.dependDe || []).map(function(_id) {
        return "narrative_" + _id;
    });
    this.description = _data.description || "";
}

Tlns.Classes.Occurrence.prototype.addDependency = function(_id) {
    if (_(this.dependsOn).indexOf(_id) == -1) {
        this.dependsOn.push(_id);
    }
}

Tlns.Classes.Occurrence.prototype.toString = function() {
    return "Occurrence " + this.type + ': "' + this.title + '"';
}