📜 ⬆️ ⬇️

Interactive map of the shopping center on HTML5 canvas

Introduction


The customer was assigned the next task - to show statistics on store attendance, use of escalators, elevators and corridors on the maps of shopping centers. The map needs to be able to mark up - indicate the points, where to show the statistics and what specific statistics. And, of course, show these statistics for the selected time period and filters. Where the data is taken from and where it is stored is a separate large topic, outside the brackets of this article.

If you spit, you say, we take the vector map of the shopping center in svg and supplement it with data. Beautiful, modern, fast. There are even ready-made solutions like jVectorMap.

Only here there are no vector maps of the necessary shopping centers, there are only those pictures provided by the owners of the centers. Absolutely different in style and content. And a large number of centers (about 300) do not allow to redraw them into vectors quickly and cheaply. And the addition of new shopping centers will require additional work.
')
Therefore, it was decided to use HTML5 canvas both for marking the map and for displaying data.

Choosing a framework


Working directly with the canvas API is not very convenient, but there are a lot of tools to facilitate the work. Requirements for the framework in our case:
  1. Object model over canvas API.
  2. The ability to draw and scale the image.
  3. Interactivity:
    • the possibility of manipulating objects at the stage of map layout,
    • ability to scale and move around the map.
  4. The ability to export / import tagged objects.
  5. The presence of detailed events.
  6. High rendering speed.

Fabric.js , EaselJS , Raphaël , Paper.js and Processing.js came under consideration.
Fabric.js meets all requirements. Given the current experience with him, it was decided to take it as a basis. Later in the examples version 1.4.4 was used.



Canvas and drawing a map


Take the card:
image

In the markup of the page we will create a plain canvas:
<canvas id="canvas" width="1000px" height="400px" style="border: 1px solid black"> 

Let's make a fabric.js canvas out of it, setting the necessary parameters at the same time:
 var element = $('#canvas'), //      canvas = new fabric.Canvas(element.get(0), { selection: false, //     scale: 1, //     renderOnAddRemove: false, //  -,        moveCursor: 'default', //  ,    hoverCursor: 'default' }); 


Scaling and moving around the map


The size of the maps can be anything, so it is necessary to give the user the opportunity to scale and move freely around it using the mouse. In essence, such manipulations are a transformation of all objects on the map, that is, a change in size and position.
Therefore, we have to keep the initial and current state:
 var baseWidth = 0, //   baseHeight= 0, //   baseScale = 1, //   width = 0, //   height = 0, //   transX = 0, //     x transY = 0, //     y scale = 1; //     

Apply the transformation of objects on the canvas will be as follows:
 var applyTransform = function () { var maxTransX, maxTransY, minTransX, minTransY, group; //        x if (baseWidth * scale <= width) { //      maxTransX = (width - baseWidth * scale) / (2 * scale); minTransX = (width - baseWidth * scale) / (2 * scale); } else { //   maxTransX = 0; minTransX = (width - baseWidth * scale) / scale; } //     if (transX > maxTransX) { transX = maxTransX; } else if (transX < minTransX) { transX = minTransX; } //      y if (baseHeight * scale <= height) { maxTransY = (height - baseHeight * scale) / (2 * scale); minTransY = (height - baseHeight * scale) / (2 * scale); } else { maxTransY = 0; minTransY = (height - baseHeight * scale) / scale; } if (transY > maxTransY) { transY = maxTransY; } else if (transY < minTransY) { transY = minTransY; } //         group = new fabric.Group(canvas.getObjects()); group.scaleX = scale / canvas.scale; group.scaleY = scale / canvas.scale; group.left = group.getWidth() / 2 + transX * scale; group.top = group.getHeight() / 2 + transY * scale; group.destroy(); //      canvas.scale = scale; //      canvas.renderAll(); }; 

Separate function will set the scale:
 var setScale = function (scaleToSet, anchorX, anchorY) { var zoomMax = 5, //  5-   zoomMin = 1, //   -    zoomStep; //    //  ,   if (scaleToSet > zoomMax * baseScale) { scaleToSet = zoomMax * baseScale; } else if (scaleToSet < zoomMin * baseScale) { scaleToSet = zoomMin * baseScale; } //   - ,     . //   anchorX  anchorY. //        . if (typeof anchorX != 'undefined' && typeof anchorY != 'undefined') { zoomStep = scaleToSet / scale; // ,      , //      . transX -= (zoomStep - 1) / scaleToSet * anchorX; transY -= (zoomStep - 1) / scaleToSet * anchorY; } scale = scaleToSet; applyTransform(); }; 

It now remains to subscribe to mouse events:
 var bindContainerEvents= function () { var mouseDown = false, oldPageX, oldPageY, container = $(canvas.wrapperEl); container.mousemove(function (e) { //   if (mouseDown) { //      transX -= (oldPageX - e.pageX) / scale; transY -= (oldPageY - e.pageY) / scale; applyTransform(); oldPageX = e.pageX; oldPageY = e.pageY; return false; } }).mousedown(function (e) { //        mouseDown = true; oldPageX = e.pageX; oldPageY = e.pageY; return false; }); $('body').mouseup(function () { mouseDown = false; }); //    container.mousewheel(function (event, delta, deltaX, deltaY) { var offset = element.offset(), //     centerX = event.pageX - offset.left, //  x   centerY = event.pageY - offset.top, //  y   zoomStep = Math.pow(1.3, deltaY); //  ,   . setScale(scale * zoomStep, centerX, centerY); //    event.preventDefault(); }); }; 

Here we used jQuery Mousewheel to handle the scrolling of the mouse wheel.
In addition, for users of touch devices, we will do separate event handling. Then the usual patterns of touching "move" (one-finger touch), "increase" and "reduce" (two-finger touch) will please the owners of such devices.
 var bindContainerTouchEvents = function () { var touchStartScale, touchStartDistance, container = $(canvas.wrapperEl), touchX, touchY, centerTouchX, centerTouchY, lastTouchesLength, handleTouchEvent = function (e) { var touches = e.originalEvent.touches, offset, currentScale, transXOld, transYOld; if (e.type == 'touchstart') { lastTouchesLength = 0; } if (touches.length == 1) { //   if (lastTouchesLength == 1) { transXOld = transX; transYOld = transY; transX -= (touchX - touches[0].pageX) / scale; transY -= (touchY - touches[0].pageY) / scale; applyTransform(); if (transXOld != transX || transYOld != transY) { e.preventDefault(); } } touchX = touches[0].pageX; touchY = touches[0].pageY; } else if (touches.length == 2) { //  if (lastTouchesLength == 2) { currentScale = Math.sqrt( Math.pow(touches[0].pageX - touches[1].pageX, 2) + Math.pow(touches[0].pageY - touches[1].pageY, 2) ) / touchStartDistance; setScale(touchStartScale * currentScale, centerTouchX, centerTouchY); e.preventDefault(); } else { //   ,   offset = element.offset(); if (touches[0].pageX > touches[1].pageX) { centerTouchX = touches[1].pageX + (touches[0].pageX - touches[1].pageX) / 2; } else { centerTouchX = touches[0].pageX + (touches[1].pageX - touches[0].pageX) / 2; } if (touches[0].pageY > touches[1].pageY) { centerTouchY = touches[1].pageY + (touches[0].pageY - touches[1].pageY) / 2; } else { centerTouchY = touches[0].pageY + (touches[1].pageY - touches[0].pageY) / 2; } centerTouchX -= offset.left; centerTouchY -= offset.top; touchStartScale = scale; touchStartDistance = Math.sqrt( Math.pow(touches[0].pageX - touches[1].pageX, 2) + Math.pow(touches[0].pageY - touches[1].pageY, 2) ); } } lastTouchesLength = touches.length; }; container.bind('touchstart', handleTouchEvent); container.bind('touchmove', handleTouchEvent); }; 

The magic of transformations and event handling is taken from the jVector .

Finally, load the map and draw it:
 fabric.util.loadImage('Map.png', function(img) { var map = new fabric.Image(img), curBaseScale; if (('ontouchstart' in window) || (window.DocumentTouch && document instanceof DocumentTouch)) { bindContainerTouchEvents(); } else { bindContainerEvents(); } //      baseWidth = map.width; baseHeight = map.height; width = element.width(); height = element.height(); //            map.set({ hasRotatingPoint: false, hasBorders: false, hasControls: false, lockScalingY: true, lockScalingX: true, selectable: false, left: map.width / 2, top: map.height / 2, originX: 'center', originY: 'center' }); canvas.add(map); // ,      curBaseScale = baseScale; if (width / height > baseWidth / baseHeight) { baseScale = height / baseHeight; } else { baseScale = width / baseWidth; } scale *= baseScale / curBaseScale; transX *= baseScale / curBaseScale; transY *= baseScale / curBaseScale; canvas.setWidth(width); canvas.setHeight(height); applyTransform(); //   ,   createMarkers(); }); 


Tags on the map


We have already received an easy-to-use map, it remains to learn how to put labels on it and then show them with the necessary data.
It is best to use vector objects, then at any map magnification they will look great.
In addition to the label, we’ll also add text showing statistics of visits to this map point. The text will be readable on any map if it is wrapped in a rectangle with a solid fill. For the correct positioning of the text and the wrapper relative to each other, set originX and originY to 'center'.
 var markerColor = '#2567d5'; var addMarker = function(point, text) { //   var marker = new fabric.Path('m 11,-19.124715 c -8.2234742,0 -14.8981027,-6.676138 -14.8981027,-14.9016 0,-5.633585 3.35732837,-10.582599 6.3104192,-14.933175 C 4.5507896,-52.109948 9.1631953,-59.34619 11,-61.92345 c 1.733396,2.518329 6.760904,9.975806 8.874266,13.22971 3.050966,4.697513 6.023837,8.647788 6.023837,14.667425 0,8.225462 -6.674629,14.9016 -14.898103,14.9016 zm 0,-9.996913 c 2.703016,0 4.903568,-2.201022 4.903568,-4.904687 0,-2.703664 -2.200552,-4.873493 -4.903568,-4.873493 -2.7030165,0 -4.903568,2.169829 -4.903568,4.873493 0,2.703665 2.2005515,4.904687 4.903568,4.904687 z"', { width: 40, height: 80, scaleX: scale, scaleY: scale, left: point.x, top: point.y, originX: 'center', originY: 'center', fill: markerColor, stroke: '#2e69b6', text: text //      / }), //  textObject = new fabric.Text(text, { fontSize: 30, originX: 'center', fill: markerColor, originY: 'center' }), //    background = new fabric.Rect({ width: 100, height: 40, originX: 'center', originY: 'center', fill: 'white', stroke: 'black' }), //      textGroup = new fabric.Group([background, textObject], { scaleX: scale, scaleY: scale, left: point.x + 20 * scale, //    top: point.y - 30 * scale //    }); canvas.add(marker); canvas.add(textGroup); }; 

Now it’s easy to map a couple of tags:
  addMarker({x: 550, y: 390}, '#0:500'); addMarker({x: 460, y: 120}, '#1:300'); canvas.renderAll(); 

The result will be as follows:


Editing

Introduce editing mode - when clicking on the map we will create a new label. For an example, the simple checkbox and the flag are enough for us:
  <div><input type="checkbox" onclick="window.isEditing = this.checked" id="editing"/><label for="editing">Editing</label></div> 

Now you can write the createMarkers function:
 var createMarkers = function() { var markersCount = 0; //    window.isEditing = false; //    canvas.on('mouse:down', function (options) { var position; if (!window.isEditing) { return; } //      position = canvas.getPointer(options.e); //  -     addMarker(position, '#' + markersCount++ + ':' + Math.round(Math.random() * 1000)); //    canvas.renderAll(); }); }; 

With this feature, you can turn the card into a mash from tags or a work of art:


Naturally, you can add the ability to select the color and type of label, related information and so on. For example, the label may be an escalator icon:
 var circle = new fabric.Circle({ radius: 22.5 }), path1 = new fabric.Path('M31,31h-2L15,17H9c-1.1027832,0-2,0.8971558-2,2c0,1.1027832,0.8972168,2,2,2h2l14,14h6c1.1027832,0,2-0.8972168,2-2C33,31.8971558,32.1027832,31,31,31z', { originX: 'center', originY: 'center', fill: markerColor }), path2 = new fabric.Path('M22.5,2C11.1782227,2,2,11.1781616,2,22.5S11.1782227,43,22.5,43S43,33.8218384,43,22.5S33.8217773,2,22.5,2z M26.5,7C27.8806152,7,29,8.1192627,29,9.5c0,1.3806763-1.1193848,2.5-2.5,2.5c-1.3807373,0-2.5-1.1193237-2.5-2.5C24,8.1192627,25.1192627,7,26.5,7z M26.5,13.0023804c1.380249-0.0330811,2.5,0.2385864,2.5,3s0,8,0,8l-6-7C23,17.0023804,25.0908203,13.0361938,26.5,13.0023804z M31,38h-7L10,24H9c-2.7614746,0-5-2.2385864-5-5s2.2385254-5,5-5h7l14,14h1c2.7613525,0,5,2.2385864,5,5S33.7613525,38,31,38z', { originX: 'center', originY: 'center', fill: markerColor }), marker = new fabric.Group([circle, path1, path2], { width: 40, height: 80, scaleX: scale, scaleY: scale, left: point.x, top: point.y, originX: 'center', originY: 'center', fill: markerColor, }); 



In addition, fabric.js allows you to edit objects - move, resize, rotate, etc. So the user gets ample opportunities to create readable and easy to analyze images.

Zones

In our case, the customer also wanted to be able to select zones on the map and show statistics on these zones. We decided to use as a zone a standard transparent polygon with an arbitrary number of points marked by the user with mouse clicks. Such a polygon is always closed and easily mapped. We will end the zone with a double click. And to remove the last added point by pressing the backspace or delete buttons - in case of an error.
Then the zone marking can be done as follows.
 canvas.on('mouse:down', function (options) { addExtendZone(options.e); }).on('mouse:move', function (options) { drawZone(options.e); }); $(document).on('dblclick', finishZone).on('keydown', undoZonePoint); //     ,    var convertPointToRelative = function(point, object) { return { x: (point.x - object.left) / scale, y: (point.y - object.top) / scale }; }; var addExtendZone = function(mouseEvent) { var position = canvas.getPointer(mouseEvent); //      if (currentEditingZone) { currentEditingZone.points.push(convertPointToRelative(position, currentEditingZone)); return; } //   -   3 ,      currentEditingZone = new fabric.Polygon( [{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: -1, y: -1 }], { scaleX: scale, scaleY: scale, left: position.x, top: position.y, fill: new fabric.Color(markerColor).setAlpha(0.3).toRgba(), stroke: '#2e69b6', }); canvas.add(currentEditingZone); canvas.renderAll(); }; var drawZone = function(mouseEvent) { var points; if (currentEditingZone) { //       ,    points = currentEditingZone.points; points[points.length - 1] = convertPointToRelative(canvas.getPointer(mouseEvent), currentEditingZone); canvas.renderAll(); } }; var finishZone = function () { if (!currentEditingZone) { return; } //   ,     currentEditingZone.points.pop(); currentEditingZone = null; }; var undoZonePoint = function(event) { //  backspace  delete if (currentEditingZone && (event.which == 8 || event.which == 46)) { var points = currentEditingZone.points, isDeleted = points.length <= 3; points[points.length - 2] = points[points.length - 1]; points.pop(); //    if (isDeleted) { canvas.remove(currentEditingZone); currentEditingZone = null; } canvas.renderAll(); event.preventDefault(); } }; 


Result


Putting it all together, we were able to put labels and zones on an arbitrary map, unload / load them at will and draw with specific data on visits to these points or areas on the map. Like that:


Thus, the variety and functionality of modern technologies and frameworks allows you to visualize data in a pleasant and flexible way with minimal time and effort. HTML5 canvas, along with fabric.js, provide the developer with the tools to create fast and convenient interactive systems.

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


All Articles