How to create a visual image library with HTML5 Canvas

Original author: David Catuhe
  • Transfer
This morning, having opened the mail, I received another mailing from Code Project , which described an interesting way to create an image gallery using the Canvas element. The article seemed interesting enough and I decided to publish its translation

. from a translator: Some suggestions promoting IE and some obviously obvious things were removed from the article. I myself am not a supporter of the IE browser and not all of the methods described below are ideal. But as an overview of HTML5 features and attempts at a new application of Canvas, the article is quite interesting.
Link to the article on the code project
Link to the original


As a fan of user interfaces, I could not miss the opportunity to develop something with HTML5 Canvas. This tool provides a great many new ways to display pictures and data on the web. In this article we will go through one of them.

  • Application Overview
  • Instruments
  • HTML5 Page
  • Data retrieval
  • Map loading & cache handling
  • Map display
  • Mouse control
  • State preservation
  • Animation
  • Work with different devices
  • Conclusion


Application Overview



We will make an application that allows us to display the Magic the Gathering © map collection . Scrolling and zoom will be available to users when using the mouse (for example, as in Bing Maps).



The finished application can be viewed here: bolaslenses.catuhe.com
Sources can be downloaded here: www.catuhe.com/msdn/bolaslenses.zip

Maps are stored in Windows Azure Storage and use the Azure Content Distribution Network ( CDN : a service that provides / deploys data next to end users) for maximum performance. The ASP.NET service is used to return a list of maps (using the JSON format).



Instruments



To write our application, we will use Visual Studio 2010 SP1 with Web Standards Update . This extension adds IntelliSense support for HTML5 pages (this is a really important thing).
Our solution will contain an HTML5 page along with .js files. About debugging: Visual Studio allows you to set breakpoints and work with them directly in your environment.


Debugging in Visual Studio 2010

And so we have a modern development environment with IntelliSense and debugging support. Therefore, we are ready to start, and to begin with, we will write an HTML5 page.



HTML5 page


Our page will be built around HTML5 canvas, which we will use to draw maps: code

If we look at our page, we can notice that it is divided into 2 parts:
  • Title with title, logo and special links.
  • The main part containing the canvas element and tooltips that will display the status of the application. And a hidden image (backImage) used as a source for maps not yet loaded.

We also added a stylesheet full.css : stylesheet . So we got the following page:



Styles is a powerful tool that allows you to create an infinite number of displays.

Our interface is now ready and we can see how to get map data for display.

Data retrieval


The server provides a list of maps using the JSON format at the following link:
bolaslenses.catuhe.com/Home/ListOfCards/?colorString=0 The
URL takes one parameter (colorString) to select the desired color (0 = all).
When developing with JavaScript, it would be nice to see what we already have today (this is true for other programming languages, but it is very important for JavaScript): you must ask yourself if there was something that we were going to develop, Already created in existing frameworks?
Indeed, there are many open source JavaScript projects in the world. One of them is jQuery , which provides an abundance of convenient features.
Thus, in our case, to connect to the URL of our server and get a list of maps, we can use XmlHttpRequest and have fun with the parsing of the returned JSON. Or we can use jQuery.
We will use the getJSON function, which takes care of everything for us:
function getListOfCards() {
        var url = "http://bolaslenses.catuhe.com/Home/ListOfCards/?jsoncallback=?";
        $.getJSON(url, { colorString: "0" }, function (data) {
            listOfCards = data;
            $("#cardsCount").text(listOfCards.length + " cards displayed");
            $("#waitText").slideToggle("fast");
        });
    }


As we can see, our function saves the list of cards in the variable listOfCards and calls 2 jQuery functions:
  • text - changes the text of the tag
  • slideToggle - hides / shows the tag by animating its height

The listOfCards list contains objects in the format:
  • ID : Card ID
  • Path : relative map path (no extension)

It should be noted that the server URL is called with “? Jsoncallback =?” suffix. Ajax calls are limited by the security of connecting to the same address as the called script. However, there is a solution called JSONP that will allow us to make joint calls to the server. And fortunately, jQuery can handle everything alone, you only need to add the correct suffix.
As soon as we get our list of cards, we can configure the loading and caching of images.

Map loading & cache handling


The main trick of our application in drawing only the cards that are visible on the screen. The display window is determined by the zoom level and the indent (x, y) of the entire system.

var visuControl = { zoom : 0.25, offsetX : 0, offsetY : 0 };



The entire system is identified by 14,819 cards, which are distributed over more than 200 columns and 75 lines.
We must also know that each card is available in three versions:
  • High resolution: 480x680 without compression (.jpg suffix)
  • Medium resolution: 240x340 with standard compression (.50.jpg suffix)
  • Low resolution: 120x170 with strong compression (.25.jpg suffix)

Thus, depending on the zoom level, we will download the necessary version to optimize the network.
To do this, we will develop a function that will give the desired image for the card. In addition, the function will refer to the image below if the map for the desired level has not yet been uploaded to the server:
    function imageCache(substr, replacementCache) {
        var extension = substr;
        var backImage = document.getElementById("backImage");
        this.load = function (card) {
            var localCache = this;
            if (this[card.ID] != undefined)
                return;
            var img = new Image();
            localCache[card.ID] = { image: img, isLoaded: false };
            currentDownloads++;
            img.onload = function () {
                localCache[card.ID].isLoaded = true;
                currentDownloads--;
            };
            img.onerror = function() {
                currentDownloads--;
            };
            img.src = "http://az30809.vo.msecnd.net/" + card.Path + extension;
        };
        this.getReplacementFromLowerCache = function (card) {
            if (replacementCache == undefined)
                return backImage;
            return replacementCache.getImageForCard(card);
        };
        this.getImageForCard = function(card) {
            var img;
            if (this[card.ID] == undefined) {
                this.load(card);
                img = this.getReplacementFromLowerCache(card);
            }
            else {
                if (this[card.ID].isLoaded)
                    img = this[card.ID].image;
                else
                    img = this.getReplacementFromLowerCache(card);
            }
            return img;
        };
    }

ImageCache gives the suffix and the desired cache.
Here are 2 important features:
  • load : this function will load the desired image and save it to the cache ( msecnd.net url is the address of the map in Azure CDN)
  • getImageForCard : this function returns a picture from the cache if it has already been downloaded before or loads it on a new one, puts it in the cache

To process 3 cache levels, we will declare 3 variables:
    var imagesCache25 = new imageCache(".25.jpg");
    var imagesCache50 = new imageCache(".50.jpg", imagesCache25);
    var imagesCacheFull = new imageCache(".jpg", imagesCache50);


Choosing the right cache depends on the zoom:
    function getCorrectImageCache() {
        if (visuControl.zoom <= 0.25)
            return imagesCache25;
        if (visuControl.zoom <= 0.8)
            return imagesCache50;
        return imagesCacheFull;
    }


For user feedback, we will add a timer that will control a tooltip that displays the number of already loaded pictures:
    function updateStats() {
        var stats = $("#stats");
        stats.html(currentDownloads + " card(s) currently downloaded.");
        if (currentDownloads == 0 && statsVisible) {
            statsVisible = false;
            stats.slideToggle("fast");
        }
        else if (currentDownloads > 1 && !statsVisible) {
            statsVisible = true;
            stats.slideToggle("fast");
        }
    }
    setInterval(updateStats, 200);


Note: it is better to use jQuery to simplify the animation.
Now let's move on to the map display.

Map display


To draw our maps, we need to fill the canvas element using its 2D context (which exists only if the browser supports HTML5 canvas):
    var mainCanvas = document.getElementById("mainCanvas");
    var drawingContext = mainCanvas.getContext('2d');


Drawing will be performed by the processListOfCards function (called 60 times per second):
    function processListOfCards() {
        if (listOfCards == undefined) {
            drawWaitMessage();
            return;
        }
        mainCanvas.width = document.getElementById("center").clientWidth;
        mainCanvas.height = document.getElementById("center").clientHeight;
        totalCards = listOfCards.length;
        var localCardWidth = cardWidth * visuControl.zoom;
        var localCardHeight = cardHeight * visuControl.zoom;
        var effectiveTotalCardsInWidth = colsCount * localCardWidth;
        var rowsCount = Math.ceil(totalCards / colsCount);
        var effectiveTotalCardsInHeight = rowsCount * localCardHeight;
        initialX = (mainCanvas.width - effectiveTotalCardsInWidth) / 2.0 - localCardWidth / 2.0;
        initialY = (mainCanvas.height - effectiveTotalCardsInHeight) / 2.0 - localCardHeight / 2.0;
        // Clear
        clearCanvas();
        // Computing of the viewing area
        var initialOffsetX = initialX + visuControl.offsetX * visuControl.zoom;
        var initialOffsetY = initialY + visuControl.offsetY * visuControl.zoom;
        var startX = Math.max(Math.floor(-initialOffsetX / localCardWidth) - 1, 0);
        var startY = Math.max(Math.floor(-initialOffsetY / localCardHeight) - 1, 0);
        var endX = Math.min(startX + Math.floor((mainCanvas.width - initialOffsetX - startX * localCardWidth) / localCardWidth) + 1, colsCount);
        var endY = Math.min(startY + Math.floor((mainCanvas.height - initialOffsetY - startY * localCardHeight) / localCardHeight) + 1, rowsCount);
        // Getting current cache
        var imageCache = getCorrectImageCache();
        // Render
        for (var y = startY; y < endY; y++) {
            for (var x = startX; x < endX; x++) {
                var localX = x * localCardWidth + initialOffsetX;
                var localY = y * localCardHeight + initialOffsetY;
                // Clip
                if (localX > mainCanvas.width)
                    continue;
                if (localY > mainCanvas.height)
                    continue;
                if (localX + localCardWidth < 0)
                    continue;
                if (localY + localCardHeight < 0)
                    continue;
                var card = listOfCards[x + y * colsCount];
                if (card == undefined)
                    continue;
                // Get from cache
                var img = imageCache.getImageForCard(card);
                // Render
                try {
                    if (img != undefined)
                        drawingContext.drawImage(img, localX, localY, localCardWidth, localCardHeight);
                } catch (e) {
                    $.grep(listOfCards, function (item) {
                        return item.image != img;
                    });
                }
            }
        };
        // Scroll bars
        drawScrollBars(effectiveTotalCardsInWidth, effectiveTotalCardsInHeight, initialOffsetX, initialOffsetY);
        // FPS
        computeFPS();
    }


This feature is built around several key points:
  • If the list of maps has not yet been loaded, we display a hint indicating that the loading is still in progress:

    var pointCount = 0;
    function drawWaitMessage() {
        pointCount++;
        if (pointCount > 200)
            pointCount = 0;
        var points = "";
        for (var index = 0; index < pointCount / 10; index++)
            points += ".";
        $("#waitText").html("Loading...Please wait
" + points); }

  • Subsequently, we determine the position of the display window (in terms of maps and coordinates), then we clear the canvas:

    function clearCanvas() {
        mainCanvas.width = document.body.clientWidth - 50;
        mainCanvas.height = document.body.clientHeight - 140;
        drawingContext.fillStyle = "rgb(0, 0, 0)";
        drawingContext.fillRect(0, 0, mainCanvas.width, mainCanvas.height);
    }


  • Then we display the list of maps and call the canvas function of the drawImage context. The specific image is provided by the active cache (zoom dependent):

    // Get from cache
    var img = imageCache.getImageForCard(card);
    // Render
    try {
        if (img != undefined)
            drawingContext.drawImage(img, localX, localY, localCardWidth, localCardHeight);
    } catch (e) {
        $.grep(listOfCards, function (item) {
            return item.image != img;
        });

  • We also need to draw a scroll bar using the RoundedRectangle function , which uses quadratic curves:

    function roundedRectangle(x, y, width, height, radius) {
        drawingContext.beginPath();
        drawingContext.moveTo(x + radius, y);
        drawingContext.lineTo(x + width - radius, y);
        drawingContext.quadraticCurveTo(x + width, y, x + width, y + radius);
        drawingContext.lineTo(x + width, y + height - radius);
        drawingContext.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
        drawingContext.lineTo(x + radius, y + height);
        drawingContext.quadraticCurveTo(x, y + height, x, y + height - radius);
        drawingContext.lineTo(x, y + radius);
        drawingContext.quadraticCurveTo(x, y, x + radius, y);
        drawingContext.closePath();
        drawingContext.stroke();
        drawingContext.fill();
    }
    function drawScrollBars(effectiveTotalCardsInWidth, effectiveTotalCardsInHeight, initialOffsetX, initialOffsetY) {
        drawingContext.fillStyle = "rgba(255, 255, 255, 0.6)";
        drawingContext.lineWidth = 2;
        // Vertical
        var totalScrollHeight = effectiveTotalCardsInHeight + mainCanvas.height;
        var scaleHeight = mainCanvas.height - 20;
        var scrollHeight = mainCanvas.height / totalScrollHeight;
        var scrollStartY = (-initialOffsetY + mainCanvas.height * 0.5) / totalScrollHeight;
        roundedRectangle(mainCanvas.width - 8, scrollStartY * scaleHeight + 10, 5, scrollHeight * scaleHeight, 4);
        // Horizontal
        var totalScrollWidth = effectiveTotalCardsInWidth + mainCanvas.width;
        var scaleWidth = mainCanvas.width - 20;
        var scrollWidth = mainCanvas.width / totalScrollWidth;
        var scrollStartX = (-initialOffsetX + mainCanvas.width * 0.5) / totalScrollWidth;
        roundedRectangle(scrollStartX * scaleWidth + 10, mainCanvas.height - 8, scrollWidth * scaleWidth, 5, 4);
    }

  • Finally, we need to calculate the number of frames per second:

    function computeFPS() {
        if (previous.length > 60) {
            previous.splice(0, 1);
        }
        var start = (new Date).getTime();
        previous.push(start);
        var sum = 0;
        for (var id = 0; id < previous.length - 1; id++) {
            sum += previous[id + 1] - previous[id];
        }
        var diff = 1000.0 / (sum / previous.length);
        $("#cardsCount").text(diff.toFixed() + " fps. " + listOfCards.length + " cards displayed");
    }


Drawing maps mainly relies on the ability of the browser to speed up the rendering of the canvas element. For example, this is performance on my machine with a minimum zoom level (0.05):



Browser
Fps
Internet Explorer 9thirty
Firefox 5thirty
Chrome 1217
iPad (at zoom level 0.8)7
Windows Phone Mango (at zoom level 0.8)20 (!!)


The site even works on mobile phones and tablets if they support HTML5.

Here we can see the internal strength of HTML5 browsers that can handle full-screen maps more than 30 times per second. This is possible with hardware acceleration ( -hardware acceleration ).

Mouse control

For a normal view of the collection of our cards, we need to be able to control the mouse (including the wheel).
For scrolling, we simply handle the onmouvemove, onmouseup, and onmousedown events.

Onmouseup and onmousedown events will be used to track mouse clicks:
    var mouseDown = 0;
    document.body.onmousedown = function (e) {
        mouseDown = 1;
        getMousePosition(e);
        previousX = posx;
        previousY = posy;
    };
    document.body.onmouseup = function () {
        mouseDown = 0;
    };


The onmousemove event is connected to the canvas element and is used to move the view:
    var previousX = 0;
    var previousY = 0;
    var posx = 0;
    var posy = 0;
    function getMousePosition(eventArgs) {
        var e;
        if (!eventArgs)
            e = window.event;
        else {
            e = eventArgs;
        }
        if (e.offsetX || e.offsetY) {
            posx = e.offsetX;
            posy = e.offsetY;
        }
        else if (e.clientX || e.clientY) {
            posx = e.clientX;
            posy = e.clientY;
        }        
    }
    function onMouseMove(e) {
        if (!mouseDown)
            return;
        getMousePosition(e);
        mouseMoveFunc(posx, posy, previousX, previousY);
        previousX = posx;
        previousY = posy;
    }


This function (onMouseMove) calculates the current position and provides the previous value if the shift of the display window is moved:
    function Move(posx, posy, previousX, previousY) {
        currentAddX = (posx - previousX) / visuControl.zoom;
        currentAddY = (posy - previousY) / visuControl.zoom;
    }
    MouseHelper.registerMouseMove(mainCanvas, Move);


I remind you that jQuery also provides tools for managing mouse events.
To control the wheel, you will have to adapt to each browser individually, since they all work differently in this case:
    function wheel(event) {
        var delta = 0;
        if (event.wheelDelta) {
            delta = event.wheelDelta / 120;
            if (window.opera)
                delta = -delta;
        } else if (event.detail) { /** Mozilla case. */
            delta = -event.detail / 3;
        }
        if (delta) {
            wheelFunc(delta);
        }
        if (event.preventDefault)
            event.preventDefault();
        event.returnValue = false;
    }


Event Log Function:
    MouseHelper.registerWheel = function (func) {
        wheelFunc = func;
        if (window.addEventListener)
            window.addEventListener('DOMMouseScroll', wheel, false);
        window.onmousewheel = document.onmousewheel = wheel;
    };
    // Использование
    MouseHelper.registerWheel(function (delta) {
        currentAddZoom += delta / 500.0;
    });


Finally, we add a little inertia while moving the mouse (or during zoom) to give a feeling of smoothness:
    // Инерция
    var inertia = 0.92;
    var currentAddX = 0;
    var currentAddY = 0;
    var currentAddZoom = 0;
    function doInertia() {
        visuControl.offsetX += currentAddX;
        visuControl.offsetY += currentAddY;
        visuControl.zoom += currentAddZoom;
        var effectiveTotalCardsInWidth = colsCount * cardWidth;
        var rowsCount = Math.ceil(totalCards / colsCount);
        var effectiveTotalCardsInHeight = rowsCount * cardHeight
        var maxOffsetX = effectiveTotalCardsInWidth / 2.0;
        var maxOffsetY = effectiveTotalCardsInHeight / 2.0;
        if (visuControl.offsetX < -maxOffsetX + cardWidth)
            visuControl.offsetX = -maxOffsetX + cardWidth;
        else if (visuControl.offsetX > maxOffsetX)
            visuControl.offsetX = maxOffsetX;
        if (visuControl.offsetY < -maxOffsetY + cardHeight)
            visuControl.offsetY = -maxOffsetY + cardHeight;
        else if (visuControl.offsetY > maxOffsetY)
            visuControl.offsetY = maxOffsetY;
        if (visuControl.zoom < 0.05)
            visuControl.zoom = 0.05;
        else if (visuControl.zoom > 1)
            visuControl.zoom = 1;
        processListOfCards();
        currentAddX *= inertia;
        currentAddY *= inertia;
        currentAddZoom *= inertia;
        // Epsilon
        if (Math.abs(currentAddX) < 0.001)
            currentAddX = 0;
        if (Math.abs(currentAddY) < 0.001)
            currentAddY = 0;
    }


Such a small function is not difficult to implement, but it will improve the quality of work with the user.

State preservation



Also, in order to make viewing more convenient, we will keep the position of the display window and zoom. To do this, we use the localStorage service , which saves key / value pairs for a long time (data is saved after closing the browser) and is only available to the current window object:
    function saveConfig() {
        if (window.localStorage == undefined)
            return;
        // Zoom
        window.localStorage["zoom"] = visuControl.zoom;
        // Offsets
        window.localStorage["offsetX"] = visuControl.offsetX;
        window.localStorage["offsetY"] = visuControl.offsetY;
    }
    // Restore data
    if (window.localStorage != undefined) {
        var storedZoom = window.localStorage["zoom"];
        if (storedZoom != undefined)
            visuControl.zoom = parseFloat(storedZoom);
        var storedoffsetX = window.localStorage["offsetX"];
        if (storedoffsetX != undefined)
            visuControl.offsetX = parseFloat(storedoffsetX);
        var storedoffsetY = window.localStorage["offsetY"];
        if (storedoffsetY != undefined)
            visuControl.offsetY = parseFloat(storedoffsetY);
    }


Animation


To add more dynamism to our application, we will allow our users to double-click on the map for zoom and focus on it.

Our system should animate 3 values: two indents (offsets (X, Y)) and zoom. To do this, we use a function that will be responsible for animating the variable from the source to the final value with a given duration:
    var AnimationHelper = function (root, name) {
        var paramName = name;
        this.animate = function (current, to, duration) {
            var offset = (to - current);
            var ticks = Math.floor(duration / 16);
            var offsetPart = offset / ticks;
            var ticksCount = 0;
            var intervalID = setInterval(function () {
                current += offsetPart;
                root[paramName] = current;
                ticksCount++;
                if (ticksCount == ticks) {
                    clearInterval(intervalID);
                    root[paramName] = to;
                }
            }, 16);
        };
    };


Function Usage:
    // Подготовка параметров анимации
    var zoomAnimationHelper = new AnimationHelper(visuControl, "zoom");
    var offsetXAnimationHelper = new AnimationHelper(visuControl, "offsetX");
    var offsetYAnimationHelper = new AnimationHelper(visuControl, "offsetY");
    var speed = 1.1 - visuControl.zoom;
    zoomAnimationHelper.animate(visuControl.zoom, 1.0, 1000 * speed);
    offsetXAnimationHelper.animate(visuControl.offsetX, targetOffsetX, 1000 * speed);
    offsetYAnimationHelper.animate(visuControl.offsetY, targetOffsetY, 1000 * speed);

The advantage of AnimationHelper is that it is able to animate as many parameters as you want.

Work with different devices



Finally, we will make sure that our page can also be viewed on tablets, PCs and even phones.
To do this, we use the CSS 3: The media-queries property . With this technology, we can apply styles according to some requests, such as a specific screen size:


Here we see that if the screen width is less than 480 pixels, then the following style will be added:
    #legal
    {
        font-size: 8px;    
    }
    #title
    {
        font-size: 30px !important;
    }
    #waitText
    {
        font-size: 12px;
    }
    #bolasLogo
    {
        width: 48px;
        height: 48px;
    }
    #pictureCell
    {
        width: 48px;
    }


This style will reduce the size of the title and keep the site visible even if the browser width is less than 480 pixels (for example, on Windows Phone):


Conclusion


HTML5 / CSS 3 / JavaScript and Visual Studio 2010 allow you to develop portable and efficient solutions (within a browser that supports HTML5) with some great features, such as hardware accelerated rendering.
This type of development is simplified by using frameworks such as jQuery.

In conclusion, I will say that to make sure of something - you need to try it!

Also popular now: