⬆️ ⬇️

How to create a visual image library with HTML5 Canvas

This morning, after opening the mail, I received another newsletter from the Code Project , which described an interesting way of creating an image gallery using the Canvas element. The article seemed quite interesting and I decided to publish its translation.



Note from the translator: Some of the sentences that advertised IE and some obviously obvious things were removed from the article. I myself am not a supporter of the IE browser and not all the methods described below are ideal. But as an overview of the features of HTML5 and attempts at a new use 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 images and data on the web. In this article we will go through one of them.

')





Application Overview





We will make an application that will allow us to display the Magic the Gathering © card collection. Users will be able to scroll and zoom 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 near end users) for maximum performance. ASP.NET service is used to return a list of maps (using 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. Debugging Pro: Visual Studio allows you to set breakpoints and work with them right 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 we will write an HTML5 page to get started







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:



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







Styles are a powerful tool that allows you to create an infinite number of mappings.



Our interface is now ready and we can see how to get data about maps 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 have to ask yourself if it wasn’t what we are 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 to take 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 maps to the listOfCards variable and calls 2 jQuery functions:



The listOfCards list contains objects in the format:



It should be noted that the server URL is invoked with the “? Jsoncallback =?” Suffix. Ajax calls are limited to connecting to the same address as the script being called. 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 receive our list of cards, we can customize the loading and caching of images.



Loading Cards & Processing Cache



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



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







The entire system is defined by 14,819 maps, which cover more than 200 columns and 75 lines.

We should also know that each card is available in three versions:



Thus, depending on the zoom level, we will download the correct version to optimize network performance.

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 quality 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:



To handle 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 the tooltip that displays the number of pictures already loaded:

  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 animation.

And now we proceed to talk about the display of maps.



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'); 




The drawing will be done 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:



  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<br>" + points); } 




  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); } 






  // 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; }); 




  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); } 




  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"); } 




Map rendering is mainly based 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 a zoom level of 0.8)7
Windows Phone Mango (at a zoom level of 0.8)20 (!!)




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



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



Mouse control


For normal viewing of the collection of our maps we need to be able to control the mouse (including the wheel).

To scroll, 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 in case of a shift of the display window:

  function Move(posx, posy, previousX, previousY) { currentAddX = (posx - previousX) / visuControl.zoom; currentAddY = (posy - previousY) / visuControl.zoom; } MouseHelper.registerMouseMove(mainCanvas, Move); 




As a reminder, jQuery also provides tools for managing mouse events.

To control the wheel you will have to adjust 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 registration 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 easy to implement, but it will improve the quality of work with the user.



State saving





Also, in order to make viewing more convenient, we will save 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 the browser is closed) 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 allow our users to double-click on the map for zooming and focusing 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 a 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); }; }; 




Using function:

  //    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 many parameters as you wish.



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 property CSS 3: The media-queries. With this technology, we can apply styles according to some requests, such as a specific screen size:

  <link href="Content/full.css" rel="stylesheet" type="text/css" /> <link href="Content/mobile.css" rel="stylesheet" type="text/css" media="screen and (max-width: 480px)" /> <link href="Content/mobile.css" rel="stylesheet" type="text/css" media="screen and (max-device-width: 480px)" /> 




Here we see that if the screen width is less than 480 pixels, 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 header and will 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 excellent features, such as hardware accelerated rendering.

This type of development is simplified using frameworks such as jQuery.



In conclusion, I will say that in order to be convinced of something - you need to try it!

Source: https://habr.com/ru/post/134574/



All Articles