jWorldmaps: The Anatomy of a jQuery Mapping Plugin

G. Andrew Duthie | August 11, 2011

 

Ever want to know how to write a jQuery plugin? For some of you, the answer may be yes, because learning anything new and cool is what drives you. For others, the answer may well be no, because learning in the abstract isn’t as important as solving the particular problem you’re facing today. In this article, I’m aiming to satisfy both groups by showing you how I solved a particular problem with my website code by creating a jQuery plugin of my own.

Background

One common desire for website creators/owners is a tool or tools that will help them measure the reach of their sites. While there are lots of options for analytics today, sometimes something simpler and more visible is useful as well. From the very early days of the web, people have been implementing hit counters for this purpose. One of my colleagues at Microsoft, Brian Hitney, has created what is essentially a hit counter on steroids, called Worldmaps, which I’ve used pretty much since its creation. Worldmaps, which runs on Microsoft Azure for improved scalability, uses an image (or hidden tracking pixel) to track and geolocate visitors to your website, and provides you with stats on how many visitors you’re getting, how your site ranks compared to other Worldmaps sites, and also provides images of your visitors plotted out on the map. It is this last piece of functionality that I loved having available on my site.

What’s the Problem?

Implementing Worldmaps isn’t terribly difficult, nor is showing the map of visitor locations. Once you’ve signed up for an account, you simply add a map for the desired site, and then you can add the map image to your site using an HTML <img> tag. Easy, right? So why do I need a plugin?

Well, in my case, I wasn’t satisfied to just use the standard 160 pixel wide thumbnail map. I wanted to offer more detail, which Worldmaps makes available in larger format (450 and 700 pixel wide) maps, when a user rolls over the map with the mouse.

How I Was Doing It

Right now, you may be thinking, “well that’s easy too, just use a little jQuery, or even just hand-write the JavaScript” and you’d be right…that’s exactly where I started. I started with a couple of functions that would modify the width of the <img> and swap out the picture when the user moused over the map. This version was reasonably simple, but the map image jumped from one size to the other, rather than smoothly animating, so I pretty quickly moved to a solution using jQuery’s event and animation support:

$("#WMImg").hover(function () {
    $(this).attr("src", "http://www.myworldmaps.net/map.ashx/[ID]/small/notrack");
    $(this).css("z-index", "999");
    $(this).animate({
        width: "450px"
    }, 500);
}, function () {
    $(this).attr("src", "http://www.myworldmaps.net/map.ashx/[ID]/thumb/notrack");
    $(this).css("z-index", "1");
    $(this).animate({
        width: "200px"
    }, 500);
});

where WMImg was the id of the <img> element containing the map, and [ID] was the Worldmaps unique ID for the map to be displayed. Simple enough, but there are a few issues that made me want a better solution.

Why a Plugin?

It may seem like writing a plugin for a simple task like this is a lot of overhead, and if I was just putting a single map onto a single site, it probably would be. But like many of you, I run several sites, and wanted to be able to implement my animated map easily on any of them, without cutting and pasting a bunch of JavaScript each time. Additionally, the script above only works properly when the map is located on the left side of a site layout. What if I have a site where I want the map on the right, and have it animate its width to the left? And last, but not least, for more complicated scripts that include variables, how do I avoid conflicts with other variables that might share the same name?

Building a plugin allows me to neatly solve all these problems, and makes it easy for me to specify smart defaults for the code, while allowing users of the plugin to override these options with their preferred values.

Anatomy of a Plugin

Before we jump into the code for the jWorldmaps plugin, let’s take a brief look at the parts that make up a jQuery plugin, so you’ll be better able to understand the final code. If you want a deeper look at the topic, the jQuery documentation on plugins can be found at: http://docs.jquery.com/Plugins/Authoring (note that there are some differences between the approach I took and that which the official docs suggests).

Basic Plugin Skeleton

We get started with an anonymous function:

(function($) {
// plugin code here
})(jQuery);

This is also known in JavaScript as a closure, and  it does two important things for us. First, it protects the internal contents of our plugin from being overwritten, if our internal variable names happen to coincide with something else in the page. Only what we explicitly expose will be visible outside the closure. Additionally, by passing in the $ argument, and (jQuery) at the end, we ensure that our plugin can use the $ shorthand for referring to jQuery, even if another framework or library on the page is using the $ symbol for referencing itself.

Next, we add the plugin function itself:

(function($) {
    $.fn.YourPluginName = function(){
        // plugin code here
    };
})(jQuery);

Here, we’re extending jQuery’s fn object with our plugin’s main function. This will allow us to use jQuery’s selector syntax to find our target element, and then invoke our plugin on it, like so:

$("#MyElementID").YourPluginName();

Of course, since the stub above has no actual plugin implementation, the plugin wouldn’t do very much at this point.

Defining Defaults

For my purposes, I need a way to provide default values for my plugin, while still allowing the end-user of the plugin to override the values as appropriate. I can do this by allowing an options parameter to be passed into my function, and defining defaults when my function is called, then using the .extend method exposed by jQuery’s fn object to merge the contents of the options passed in with my defaults (only properties that have been explicitly defined in the options object will be overridden in the defaults):

(function($) {
    $.fn.YourPluginName = function(options){
        // define defaults
        var defaults = {
            'width': 250,
            'height': 100
            // etc.
        };
        if (options) { 
            $.extend(defaults, options);
        }
    };
})(jQuery);

Now when we call the plugin, we can pass in desired values to override one or more of the defaults:

$("#MyElementID").YourPluginName({
    'width': 400
});

Adding Methods

YourPluginName as defined above is the entry point into our plugin. Any code within the anonymous function assigned to $.fn.YourPluginName will be executed when the page code calls the plugin function. But that doesn’t mean you should just stick all your plugin code directly in this function in one big blob. Instead, consider adding methods to encapsulate specific pieces of your plugins functionality. Just add a named function outside the scope of your main function, and call it from within the main function. Because the entire plugin is wrapped in a closure, these functions will not be visible to code outside the plugin:

(function($) {
    $.fn.YourPluginName = function(options){
        // define defaults
        var defaults = {
            'width': 250,
            'height': 100
            // etc.
        };
        if (options) { 
            $.extend(defaults, options);
        }
        doStuff(defaults);
    };
    function doStuff(defaults) {
        // do some stuff
    };
})(jQuery);

Note that if you want to provide more than one public method users can call on your plugin, you should be aware of proper namespacing for your methods.

Maintaining Chainability

Another important consideration for a plugin is chainability, which is one of the major benefits of working with jQuery. Chainability is what allows us to use more than one jQuery method in the same line of code, simply by chaining additional calls, like so:

$("#myElementID").show().css("background-color", "red");

In order to maintain this chainability in our plugin, we need to return the this keyword, like so:

(function($) {
    $.fn.YourPluginName = function(options){
        // define defaults
        var defaults = {
            'width': 250,
            'height': 100
            // etc.
        };
        if (options) { 
            $.extend(defaults, options);
        }
        doStuff(defaults);
        return this;
    };
    function doStuff(defaults) {
        // do some stuff
    };
    // maintain chainability
})(jQuery);

That’s pretty much all there is to it, in terms of the basic structure of a plugin. Now let’s get into the specific implementation of jWorldmaps.

The jWorldMaps Plugin

As a starting point for the jWorldmaps plugin, I used a tutorial by Dan Wellman on nettuts+, which can be found here. Wellman takes a slightly different approach in some areas from the code above, so I’ll explain the differences as we go along, though we’ll end up with the same basic result, despite the differences.

Example Plugin Code

Now that we’ve had a chance to look at the basic structure of a plugin, let’s take a look at how I implemented the jWorldmaps plugin [GAD - feel free to move the code to a separate listing, if that makes more sense in terms of formatting]:

(function($) {
    $.jWorldMaps = {
        defaults: {
            initialWidth: 200,
            hoverWidth: 450,
            animate: true,
            worldMapId: "",
            tracking: true,
            trackingOnly: false,
            animateTo: "right"
        }
    };
    $.fn.extend({
        jWorldMaps:function(config) {
        
            var config = $.extend({}, $.jWorldMaps.defaults, config);
                    
            config.imgContainer = this.attr("id");
            
            loadWorldMap(config);

            if (config.animate && (!config.trackingOnly))
            {
                $("#wm" + config.worldMapId).bind({ 
                    mouseenter: function(){
                        $("#wm" + config.worldMapId).attr("src", "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/small/notrack");
                        $("#wm" + config.worldMapId).css("z-index", "999999");
                        switch(config.animateTo)
                        {
                            case "left":
                            {
                                $("#wm" + config.worldMapId).animate({
                                    width: config.hoverWidth,
                                    left: -(config.hoverWidth - config.initialWidth)
                                }, 500);
                                break;
                            }
                            case "right":
                            {
                                $("#wm" + config.worldMapId).animate({
                                    width: config.hoverWidth
                                }, 500);
                                break;
                            }
                        }
                    },
                    
                    mouseleave: function(){
                        $("#wm" + config.worldMapId).attr("src", "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/thumb/notrack");
                        $("#wm" + config.worldMapId).css("z-index", "1");
                        switch(config.animateTo)
                        {
                            case "left":
                            {
                                $("#wm" + config.worldMapId).animate({
                                    width: config.initialWidth,
                                    left: 0
                                }, 500);
                                break;
                            }
                            case "right":
                            {
                                $("#wm" + config.worldMapId).animate({
                                    width: config.initialWidth
                                }, 500);
                                break;
                            }
                        }
                    }
                });
            }
            
            return this;
        }
    });
        
    function loadWorldMap(config) {
        if(!config.trackingOnly)
        {
            //create visible map
            $("<img />").attr({ 
                id: "wm" + config.worldMapId,
                border: 0,
                style: "position: relative;",
                src: "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/thumb/notrack",
                title: "My WorldMap",
                alt: "My WorldMap",
                width: config.initialWidth
            }).appendTo("#" + config.imgContainer).wrap('<a href="http://www.myworldmaps.net/mapstats.aspx?mapid=' + config.worldMapId + '" target="_blank">');
        }
        if(config.tracking || config.trackingOnly)
        {
            //create tracking pixel
            $("<img />").attr({ 
                id: "wmTrack" + config.worldMapId,
                src: "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/ping",
                height: 1,
                width: 1
            }).appendTo("#" + config.imgContainer);
        }
    };
})(jQuery);

From the beginning, I’ve taken a slightly different approach, based on Wellman’s article, by initializing the plugin by first creating a jWorldMaps object with a nested defaults object containing properties for the plugin, along with default values. Next, I use fn.extend to merge the newly-created jWorldMaps object with a function that accepts some options that I’m naming config, then using .extend again to merge the config options passed in by the plugin user with the defaults object, and storing those in a local variable called config.

Next, I store for future reference a local copy of the ID of the element in which the map will be displayed:

config.imgContainer = this.attr("id");

Then I call a method to load the initial map graphic (or tracking pixel, depending on the options provided) into the container element:

loadWorldMap(config);

Note that the loadWorldMap method is not visible to the outside world, thanks to the closure that wraps the plugin, and the fact that I have not explicitly exposed it outside the plugin. The code in loadWorldMap simply checks to see whether we want to display a full map, or just a tracking pixel, or both, and renders the appropriate <img> elements using jQuery’s .append method (and in the case of the larger graphic, wraps the image with a link to the WorldMaps stats for the given ID using jQuery’s .wrap method. Note the use of chaining in this section, which helps make the code a bit more compact:

function loadWorldMap(config) {
    if(!config.trackingOnly)
    {
        //create visible map
        $("<img />").attr({ 
            id: "wm" + config.worldMapId,
            border: 0,
            style: "position: relative;",
            src: "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/thumb/notrack",
            title: "My WorldMap",
            alt: "My WorldMap",
            width: config.initialWidth
        }).appendTo("#" + config.imgContainer).wrap('<a href="http://www.myworldmaps.net/mapstats.aspx?mapid=' + config.worldMapId + '" target="_blank">');
    }
    if(config.tracking || config.trackingOnly) {
        //create tracking pixel
        $("<img />").attr({ 
            id: "wmTrack" + config.worldMapId,
            src: "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/ping",
            height: 1,
            width: 1
        }).appendTo("#" + config.imgContainer);
    }
};

Once the <img> element (or elements) has been rendered, we check to see if the plugin user has disabled animation, or is just using the tracking pixel, and if neither of those is true, we use jQuery’s .bind method to bind the mouseenter and mouseleave events to anonymous functions that perform the animation for us. Note that the plugin supports animating the size of the map to either left or right.

if (config.animate && (!config.trackingOnly))
{
    $("#wm" + config.worldMapId).bind({ 
        mouseenter: function(){
            $("#wm" + config.worldMapId).attr("src", "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/small/notrack");
            $("#wm" + config.worldMapId).css("z-index", "999999");
            switch(config.animateTo){
                case "left": {
                    $("#wm" + config.worldMapId).animate({
                        width: config.hoverWidth,
                        left: -(config.hoverWidth - config.initialWidth)
                    }, 500);
                    break;
                }
                case "right":{
                    $("#wm" + config.worldMapId).animate({
                        width: config.hoverWidth
                    }, 500);
                    break;
                }
            }
        },
                    
        mouseleave: function(){
            $("#wm" + config.worldMapId).attr("src", "http://www.myworldmaps.net/map.ashx/" + config.worldMapId + "/thumb/notrack");
            $("#wm" + config.worldMapId).css("z-index", "1");
            switch(config.animateTo) {
                case "left": {
                    $("#wm" + config.worldMapId).animate({
                        width: config.initialWidth,
                        left: 0
                    }, 500);
                    break;
                }
                case "right":{
                    $("#wm" + config.worldMapId).animate({
                        width: config.initialWidth
                    }, 500);
                    break;
                }
            }
        }
    });
}

Finally, we return the keyword this, in order to maintain chainability as mentioned earlier.

Improvements over existing version

So, what did we gain by going to all this effort? Several important things: First, creating a plugin makes reuse quite a bit easier. Folks who wish to use the plugin don’t need to know anything about the internals of how the plugin works, they can simply provide the WorldMaps ID for the map they want to display, and the plugin takes care of the rest. Second, the plugin is cleaner in its implementation than dropping a chunk of JavaScript code directly into the page where the plugin will be rendered. Third, the wrapping of the plugin in a closure ensures that our plugin code is isolated from the rest of the page, and won’t conflict with other scripts on the page. As more and more functionality is implemented on the client side, this last improvement will become particularly important.

Implementation instructions

So now that we have our plugin, how do we use it? Well it’s pretty simple, actually. First, we give the plugin a container to work with, at the place in our page where we want the map to appear. A div does the trick nicely (note that the ID is arbitrary, so you can choose whatever unique value you like):

<div id="wmContainer"></div>

Next, we need a reference to the jQuery library, as well as to the plugin library, which Brian Hitney is kindly hosting on the WorldMaps site itself:

<script type="text/JavaScript" src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.4.min.js"></script>
<script type="text/JavaScript" src="http://cdn.myworldmaps.net/scripts/jWorldMaps-1.0.min.js"></script>

Note that in both cases, the files are hosted using CDNs, which can help with script performance. Next, we use the jQuery ID selector syntax to grab a hold of our container div, and call the .jWorldMaps function on it, passing in the worldMapId for the desired map, and setting the tracking to “false”. As noted in the comment below, the ID below is for the Worldmaps service itself, so you’ll need to substitute your own ID to see your specific map.

<script type="text/JavaScript">
    // This worldMapId is for WorldMaps itself, 
    // replace it with yours before deployment!
    $("#wmContainer").jWorldMaps({
        worldMapId : "495a96ed-a6ac-495b-a134-72c434eea880", 
        tracking: false
    });
</script>

That’s it, we’re done. And if everything works, here’s what you’ll see initially when you load the page:

And upon rolling over the image, it should smoothly animate to a larger size and map:

Note how the larger map not only provides enhanced resolution, but additional information on ranking within the Worldmaps leaderboards.

Conclusion

In this article, you’ve seen how you can easily encapsulate rich functionality into a jQuery plugin. Writing plugins offers numerous benefits including simplifying reuse, cleaning up your website code, and preventing client-side widgets and functions from clobbering one another due to conflicts in the global namespace. Additionally, you’ve now got a great tool for tracking your website visitors that you can easily plug into your web sites. Full instructions for using the plugin can be found at http://devhammer.net/apps_code#jWorldMaps.

If you have comments on the plugin or this article, or want to make suggestions for how the plugin could be improved, you can reach me via my blog at http://devhammer.net/contact.

Additional Resources

 

About the Author

G. Andrew Duthie, aka devhammer, is the Developer Evangelist for Microsoft’s Mid-Atlantic States district, where he provides support and education for developers working with the .net development platform. In addition to his work with Microsoft, Andrew is the author of several books on ASP.NET and web development, and has spoken at numerous industry conferences from VSLive! and ASP.NET Connections, to Microsoft’s Professional Developer Conference (PDC) and Tech-Ed. Andrew has been participating in the user group community since way back in 1997, when one of his co-workers dragged him out to the Internet Developers User Group in Tyson's Corner, VA, and he's been hooked ever since.

Andrew is also the creator and developer of Community Megaphone, a site designed for promoting and finding developer community events.

Find Andrew on: