I was building a graph with Flot.js and wanted to display descriptive labels, placed above some vertical lines inside the graph, inside a gutter. I ran into the problem that, as the positioning of these labels was absolute, these labels would regularly overlap each other, making them unreadable. By setting a bottom css value for each label (eg. vertically positioning the labels) I could have placed labels on top of another, but this required doing this manually for each label, something that is not possible when creating a dynamic graph that changes every x hours/days/weeks/years. I had to come up with another solution.

This is why I developed a jQuery plugin that does, what I call, ‘Collision Detection’, which:

  1. Enumerates all the labels
  2. Orders the labels based on the order they appear visually (based on the left css property)
  3. Takes each label and determines if the next label will overlap it, and if so, move the next label up a level
  4. Checks if that next level has room for the label (eg. checks if that level does not already have a label in it that this label would subsequently overlap) and moves the label up a level again if it does not fit, repeating this until it finds a level in which the label will fit
  5. Finally, calculates the required height for each level, and sets that as a bottom css position for each element in the next level

I did not want to introduce any new elements (like a wrapper div for each level), so the plugin will basically leave everything untouched, besides setting two CSS properties for the elements concerned.

Demo

Click here for a demo.
The demo features a set of labels that overlap. Click the ‘demo’ button to run with the default settings and watch the labels get sorted and positioned so there is no overlapping, while the labels’ pointers are still at the intended position.
You can click the ‘randomize’ button to randomize the position of the labels, after which you can run one of the demo’s again.

Note: the animations are for demo purposes, and are not handled by the plugin but by the demo CSS.

Usage

Copy the code (see below) to a JS file (like hnl.collision.detection.js) and include it in your project. Then, to initiate the collision detection:

$('elementContainingLabels').CollisionDetection();

(Of course replacing ‘elementContainingLabels’ for your own element. In the demo it is the element with class gutter.)

Options

You can pass one option to the plugin that will determine the spacing between the labels. It is aptly named spacing. This value defaults to 1, and will accept negative spacing (see demo). Example: $('elementContainingLabels').CollisionDetection({spacing:10});

Work to be done

The plugin is pretty straightforward and basic, though I still would like to add some functionality, like aligning the labels from top to bottom as opposed to how it is now (bottom to top). I’ll update the code when I get around to that.

Code

Here is the code, as per jan 7th 2014:

/*!
 * jQuery Collision Detection - v1.0 - 1/7/2014
 * http://www.hnldesign.nl/work/code/collision-prev…n-using-jquery/
 *
 * Copyright (c) 2014 HN Leussink
 * Dual licensed under the MIT and GPL licenses.
 *
 * Example: http://code.hnldesign.nl/demo/hnl.collision.detection.html
 */

(function ($, document, window) {
    "use strict";

    function CollisionDetection(el, opts) {
        this.container =    $(el);
        this.colliders =    this.container.children().removeData('level');

        this.defaults = {
            levelMemory : { level: [], levelObjects : [] },
            spacing : 1
        };

        this.opts       =   $.extend(this.defaults, opts);

        this.init();
    }

    CollisionDetection.prototype.init = function () {
        var o = this.opts, t = this;
        t.process(t.colliders);
    };

    CollisionDetection.prototype.sort = function (els) {
        var o = this.opts, t = this, x = els.sort(function (a, b) {
            //sort elements by left positioning
            var a_left = $(a).position().left, b_left = $(b).position().left, ret;
            if (a_left < b_left) {
                ret = -1;
            } else if (a_left > b_left) {
                ret = 1;
            } else {
                ret = 0;
            }
            return ret;
        }).detach();
        //reattach elements
        t.container.append(x);
    };

    CollisionDetection.prototype.leveler = function (els) {
        var o = this.opts, t = this;

        els.each(function (i) {
            var this_ele = $(this),
                next_ele = this_ele.next();
            var this_props = {
                'height'    : this_ele.outerHeight(false),
                'left'      : this_ele.position().left,
                'width'     : this_ele.outerWidth(false)
            };
            var next_props = (next_ele.length > 0) ? {
                'height'    : next_ele.outerHeight(false),
                'left'      : next_ele.position().left,
                'width'     : next_ele.outerWidth(false)
            } : null;

            var thisLevel = parseInt(this_ele.data('level'), 10),  newLevel;
            if (isNaN(thisLevel)) {
                thisLevel = 0;
                this_ele.data('level', thisLevel);
            }

            //store amount of pixels 'filled' in level
            o.levelMemory.level[thisLevel] = (this_props.left + this_props.width);

            //level the next element
            if (next_ele.length > 0) {
                if ((this_props.left + this_props.width) > next_props.left) {
                    $.each(o.levelMemory.level, function (level, filled) {
                        if (filled < next_props.left) {
                            newLevel = level;
                            return false; //break out of loop
                        }
                    });
                    if (newLevel === undefined) {
                        newLevel = o.levelMemory.level.length;
                    }
                }
                next_ele.data('level', newLevel);
            }

            //push element into the right level object
            if (!o.levelMemory.levelObjects[thisLevel]) { o.levelMemory.levelObjects[thisLevel] = []; }
            o.levelMemory.levelObjects[thisLevel].push(this_ele);
        });
    };

    CollisionDetection.prototype.setDimensions = function () {
        var o = this.opts, t = this, prevHeight = 0;

        //set each level to the correct css bottom value
        $(o.levelMemory.levelObjects).each(function (i) {
            prevHeight += o.spacing;
            var level = $(this).map(function () {return this.toArray(); }),
                thisHeight = Math.max.apply(null, $(this).map(function () { return $(this).outerHeight(false); }));
            level.css('bottom', i === 0 ? 0 : prevHeight);
            prevHeight += thisHeight;
        });

        //set container to match height of elements inside
        t.container.css({
            height: prevHeight
        });
    };

    CollisionDetection.prototype.process = function (els) {
        var o = this.opts, t = this;

        //sort elements based on their appearance (left css property)
        t.sort(els);

        //arrange elements into levels
        t.leveler(els);

        //set dimensions
        t.setDimensions();
    };

    $.fn.CollisionDetection = function (opts) {
        return this.each(function () {
            new CollisionDetection(this, opts);
        });
    };

})(jQuery, document, window);

That's it. Let me know if you find this code useful and intend to use it!

Creative Commons License
jQuery Collision Detection Plugin by HN Leussink is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.